feat(cli): status shows last export age
Reads .relicario/last_backup (written by cmd_backup_export). Format: 'never' for fresh vaults, '4 days ago' otherwise. Closes the 'is my backup stale?' question without leaving the terminal.
This commit is contained in:
@@ -63,6 +63,26 @@ pub fn iso8601(unix_seconds: i64) -> String {
|
|||||||
.unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}"))
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1918,11 +1918,32 @@ fn cmd_status() -> Result<()> {
|
|||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.unwrap_or_else(|| "(no commits)".into());
|
.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!("Vault: {}", root.display());
|
||||||
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
||||||
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
||||||
println!("Devices: {device_count}");
|
println!("Devices: {device_count}");
|
||||||
println!("Last commit: {last_commit}");
|
println!("Last commit: {last_commit}");
|
||||||
|
println!("Last export: {last_backup_str}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn cmd_device(action: DeviceAction) -> Result<()> {
|
fn cmd_device(action: DeviceAction) -> Result<()> {
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn generate_works_outside_vault() {
|
fn generate_works_outside_vault() {
|
||||||
use assert_cmd::cargo::CommandCargoExt;
|
use assert_cmd::cargo::CommandCargoExt;
|
||||||
|
|||||||
Reference in New Issue
Block a user