diff --git a/crates/relicario-cli/src/helpers.rs b/crates/relicario-cli/src/helpers.rs index 28d89f5..a864f95 100644 --- a/crates/relicario-cli/src/helpers.rs +++ b/crates/relicario-cli/src/helpers.rs @@ -63,6 +63,26 @@ pub fn iso8601(unix_seconds: i64) -> 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::*; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 4b9db4c..54009ed 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1918,11 +1918,32 @@ fn cmd_status() -> Result<()> { .map(|s| s.trim().to_string()) .unwrap_or_else(|| "(no commits)".into()); + // Last backup age (read from marker written by cmd_backup_export). + let last_backup_path = vault.root().join(".relicario").join("last_backup"); + let last_backup_str = if last_backup_path.exists() { + let line = std::fs::read_to_string(&last_backup_path) + .unwrap_or_default() + .trim() + .to_string(); + // Parse the ISO-8601 we wrote in cmd_backup_export. + match chrono::DateTime::parse_from_rfc3339(&line) { + Ok(then) => { + let now = relicario_core::now_unix(); + let age = now - then.timestamp(); + crate::helpers::humanize_age(age.max(0)) + } + Err(_) => "unknown".to_string(), + } + } else { + "never".to_string() + }; + println!("Vault: {}", root.display()); println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)"); println!("Attachments: {attachment_count} ({attachment_bytes} bytes)"); println!("Devices: {device_count}"); println!("Last commit: {last_commit}"); + println!("Last export: {last_backup_str}"); Ok(()) } fn cmd_device(action: DeviceAction) -> Result<()> { diff --git a/crates/relicario-cli/tests/settings.rs b/crates/relicario-cli/tests/settings.rs index d01ee68..506ccec 100644 --- a/crates/relicario-cli/tests/settings.rs +++ b/crates/relicario-cli/tests/settings.rs @@ -109,6 +109,32 @@ fn status_reports_item_attachment_and_device_counts() { ); } +#[test] +fn status_shows_last_backup_line() { + let v = TestVault::init(); + let out = v.run(&["status"]); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("last export:") || stdout.to_lowercase().contains("last export:"), + "missing last export line: {stdout}"); + assert!(stdout.contains("never"), "fresh vault should report 'never': {stdout}"); +} + +#[test] +fn status_shows_recent_backup_after_export() { + let v = TestVault::init(); + let backup_path = v.path().join("v.relbak"); + v.run_with_backup_pass( + &["backup", "export", backup_path.to_str().unwrap()], + "test-backup-pass-2026", + ); + let out = v.run(&["status"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("last export:") || stdout.to_lowercase().contains("last export:"), + "{stdout}"); + assert!(!stdout.contains("never"), "should NOT say 'never' after export: {stdout}"); +} + #[test] fn generate_works_outside_vault() { use assert_cmd::cargo::CommandCargoExt;