Locks the singular vs plural transition (1 minute ago vs 2 minutes ago) and each bucket boundary (59→60s minutes, 3599→3600s hours, 86400→86400×2 days, etc.) so future tweaks can't silently regress the user-facing labels.
139 lines
4.9 KiB
Rust
139 lines
4.9 KiB
Rust
//! CLI-side helpers: vault dir detection, hardened git shell-out, ISO-8601
|
|
//! timestamp formatting. Kept in their own module so every command handler
|
|
//! stays terse.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use chrono::DateTime;
|
|
|
|
/// Walk up from `start` looking for a directory containing `.relicario/`.
|
|
/// Returns the vault root (the directory that contains `.relicario/`).
|
|
/// Audit L8: refuses to operate outside an initialized vault.
|
|
pub fn find_vault_dir_from(start: &Path) -> Result<PathBuf> {
|
|
let mut cur = start.to_path_buf();
|
|
loop {
|
|
if cur.join(".relicario").is_dir() {
|
|
return Ok(cur);
|
|
}
|
|
if !cur.pop() {
|
|
bail!(
|
|
"no .relicario/ directory found in {} or any parent — \
|
|
run `relicario init` first",
|
|
start.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convenience wrapper that starts the search from `std::env::current_dir()`.
|
|
pub fn vault_dir() -> Result<PathBuf> {
|
|
let cwd = std::env::current_dir().context("failed to get current directory")?;
|
|
find_vault_dir_from(&cwd)
|
|
}
|
|
|
|
/// Path to the `.relicario/` configuration directory within the vault.
|
|
pub fn relicario_dir() -> Result<PathBuf> {
|
|
Ok(vault_dir()?.join(".relicario"))
|
|
}
|
|
|
|
/// Build a hardened `git` command — no hooks, no GPG signing, no editor.
|
|
/// Audit H4: prevents vault mutations from running hostile hooks, blocking on
|
|
/// GPG passphrase prompts (which would hold the master key alive), or entering
|
|
/// $EDITOR during rebase conflict markers.
|
|
pub fn git_command(repo: &Path, args: &[&str]) -> Command {
|
|
let mut cmd = Command::new("git");
|
|
cmd.current_dir(repo);
|
|
cmd.args([
|
|
"-c", "core.hooksPath=/dev/null",
|
|
"-c", "commit.gpgsign=false",
|
|
"-c", "core.editor=true",
|
|
]);
|
|
cmd.args(args);
|
|
cmd
|
|
}
|
|
|
|
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
|
|
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
|
/// a numeric string.
|
|
pub fn iso8601(unix_seconds: i64) -> String {
|
|
DateTime::from_timestamp(unix_seconds, 0)
|
|
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
|
.unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}"))
|
|
}
|
|
|
|
/// Format a duration (in seconds) as a coarse human-readable string:
|
|
/// "just now" / "5 minutes ago" / "4 days ago" / "3 months ago".
|
|
pub fn humanize_age(seconds: i64) -> String {
|
|
if seconds < 60 { return "just now".to_string(); }
|
|
if seconds < 3600 { return format!("{} minute{} ago", seconds / 60, plural(seconds / 60)); }
|
|
if seconds < 86_400 { return format!("{} hour{} ago", seconds / 3600, plural(seconds / 3600)); }
|
|
if seconds < 86_400 * 30 {
|
|
let d = seconds / 86_400;
|
|
return format!("{d} day{} ago", plural(d));
|
|
}
|
|
if seconds < 86_400 * 365 {
|
|
let m = seconds / (86_400 * 30);
|
|
return format!("{m} month{} ago", plural(m));
|
|
}
|
|
let y = seconds / (86_400 * 365);
|
|
format!("{y} year{} ago", plural(y))
|
|
}
|
|
|
|
fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn vault_dir_finds_marker_in_cwd() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
|
|
let found = find_vault_dir_from(tmp.path()).unwrap();
|
|
assert_eq!(found, tmp.path());
|
|
}
|
|
|
|
#[test]
|
|
fn vault_dir_finds_marker_in_parent() {
|
|
let tmp = TempDir::new().unwrap();
|
|
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
|
|
let subdir = tmp.path().join("sub/nested");
|
|
std::fs::create_dir_all(&subdir).unwrap();
|
|
let found = find_vault_dir_from(&subdir).unwrap();
|
|
assert_eq!(found, tmp.path());
|
|
}
|
|
|
|
#[test]
|
|
fn vault_dir_errors_when_missing() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let err = find_vault_dir_from(tmp.path()).unwrap_err();
|
|
assert!(err.to_string().contains(".relicario"));
|
|
}
|
|
|
|
#[test]
|
|
fn iso8601_formats_fixed_timestamp() {
|
|
// 2026-04-19T00:00:00Z = 1776556800
|
|
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
|
|
}
|
|
|
|
#[test]
|
|
fn humanize_age_buckets() {
|
|
assert_eq!(humanize_age(0), "just now");
|
|
assert_eq!(humanize_age(59), "just now");
|
|
assert_eq!(humanize_age(60), "1 minute ago");
|
|
assert_eq!(humanize_age(120), "2 minutes ago");
|
|
assert_eq!(humanize_age(3_599), "59 minutes ago");
|
|
assert_eq!(humanize_age(3_600), "1 hour ago");
|
|
assert_eq!(humanize_age(7_200), "2 hours ago");
|
|
assert_eq!(humanize_age(86_400), "1 day ago");
|
|
assert_eq!(humanize_age(86_400 * 2), "2 days ago");
|
|
assert_eq!(humanize_age(86_400 * 30), "1 month ago");
|
|
assert_eq!(humanize_age(86_400 * 60), "2 months ago");
|
|
assert_eq!(humanize_age(86_400 * 365), "1 year ago");
|
|
assert_eq!(humanize_age(86_400 * 365 * 3), "3 years ago");
|
|
}
|
|
}
|