//! Shared utility functions. use std::borrow::Cow; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; pub use kigi_config::kigi_home; /// Path to `$KIGI_SHARE_DIR/pager.toml`. pub fn pager_toml_path() -> PathBuf { kigi_home().join("pager.toml") } /// User-facing label for the user kigi directory (``~/.kigi`` or ``$KIGI_SHARE_DIR``). /// /// Derived from resolved [`kigi_home()`] vs `kigi_config::default_kigi_home()`, /// not from whether `KIGI_SHARE_DIR` is set in the environment. pub fn display_kigi_home_prefix() -> String { if kigi_home() == kigi_config::default_kigi_home() { "~/.kigi".to_string() } else { "$KIGI_SHARE_DIR".to_string() } } /// User-facing path under [`kigi_home()`], e.g. ``~/.kigi/config.toml``. pub fn display_user_kigi_path(relative: impl AsRef) -> String { let rel = relative.as_ref(); let prefix = display_kigi_home_prefix(); if rel.as_os_str().is_empty() { return prefix; } format!("{prefix}/{}", rel.display()) } /// Abbreviate an absolute path for display: prefer [`kigi_home()`], then `$HOME`. pub fn abbreviate_path(path: &str) -> Cow<'_, str> { let path_buf = Path::new(path); let kigi = kigi_home(); if let Ok(rest) = path_buf.strip_prefix(&kigi) { let prefix = display_kigi_home_prefix(); if rest.as_os_str().is_empty() { return Cow::Owned(prefix); } return Cow::Owned(format!("{prefix}/{}", rest.display())); } if let Ok(home) = std::env::var("HOME") && !home.is_empty() && let Some(rest) = path.strip_prefix(&home) { if rest.is_empty() { return Cow::Borrowed("~"); } if rest.starts_with('/') { return Cow::Owned(format!("~{rest}")); } } Cow::Borrowed(path) } /// True when `path` is under user [`kigi_home()`] (not project `{cwd}/.kigi`). pub fn is_under_user_kigi_home(path: &Path) -> bool { path.starts_with(kigi_home()) } /// Format a duration as a compact human-friendly string. /// /// Uses consistent rounding for visual stability: /// - Under 10s: `"5.2s"` (one decimal for granularity) /// - 10-59s: `"32s"` (no decimal) /// - 1m-59m: `"2m5s"` /// - 1h+: `"1h2m"` pub fn format_duration(d: Duration) -> String { let total_secs = d.as_secs(); if total_secs < 10 { return format!("{:.1}s", d.as_secs_f64()); } if total_secs < 60 { return format!("{total_secs}s"); } let mins = total_secs / 60; let secs = total_secs % 60; if mins < 60 { return format!("{mins}m{secs}s"); } let hours = mins / 60; let remaining_mins = mins % 60; format!("{hours}h{remaining_mins}m") } /// Format a duration as a coarse recency string for "time ago" / age /// displays (e.g. dashboard row age column and peek panel prefix). /// /// Buckets chosen for the agent dashboard so the column stays compact /// and doesn't distract with second-level churn: /// - < 1 minute: `"just now"` /// - minutes: `"1m"` … `"59m"` /// - hours: `"1h"` … `"23h"` /// - days: `"1d"` … `"29d"` /// - months (≈30d+): `"1mo"` … `"11mo"` /// - years (≈365d+): `"1y"` … pub fn format_time_ago(d: Duration) -> String { let secs = d.as_secs(); if secs < 60 { return "just now".to_string(); } if secs < 3600 { let mins = secs / 60; return format!("{mins}m"); } if secs < 86400 { let hours = secs / 3600; return format!("{hours}h"); } let days = secs / 86400; if days < 30 { return format!("{days}d"); } if days < 365 { let months = days / 30; return format!("{months}mo"); } let years = days / 365; format!("{years}y") } /// Convert unix-epoch millis into a wall-clock [`SystemTime`]. /// /// Used for dashboard recency that originates as a wall-clock timestamp (the /// leader roster's `last_change_unix_ms`). A non-positive value — the /// `#[serde(default)]` `0` sentinel for a missing roster timestamp — falls /// back to "now". pub fn system_time_from_unix_ms(unix_ms: i64) -> SystemTime { if unix_ms <= 0 { return SystemTime::now(); } UNIX_EPOCH .checked_add(Duration::from_millis(unix_ms as u64)) .unwrap_or_else(SystemTime::now) } /// Project a monotonic [`Instant`] onto the wall clock as the [`SystemTime`] /// it corresponds to (`SystemTime::now() - instant.elapsed()`). /// /// The dashboard stores row recency as a wall-clock `SystemTime` so on-disk /// roster timestamps (which can predate this process — even the machine's /// boot — and so are unrepresentable as a monotonic `Instant`) sit in the same /// comparable space as local rows. Local rows hold live `Instant` anchors; /// this maps them across. A fixed anchor ages correctly because only `now` /// advances, and the sub-millisecond skew between the two `now()` samples is /// invisible to the minute-granularity [`format_time_ago`] buckets. pub fn system_time_from_instant(instant: Instant) -> SystemTime { SystemTime::now() .checked_sub(instant.elapsed()) .unwrap_or_else(SystemTime::now) } /// Decode common HTML entities (`&`, `<`, `>`, `"`, `'`) /// that may appear in LLM-generated session summaries. pub fn decode_html_entities(s: &str) -> std::borrow::Cow<'_, str> { if !s.contains('&') { return std::borrow::Cow::Borrowed(s); } let mut out = s.to_string(); out = out.replace("&", "&"); out = out.replace("<", "<"); out = out.replace(">", ">"); out = out.replace(""", "\""); out = out.replace("'", "'"); out = out.replace("'", "'"); out = out.replace("'", "'"); std::borrow::Cow::Owned(out) } pub fn parse_schedule_interval_secs(human: &str) -> Option { let s = human.trim_start(); if !s.starts_with("every ") { return None; } let rest = s[6..].trim_start(); let (num_str, unit) = if let Some(sp) = rest.find(char::is_whitespace) { (&rest[..sp], &rest[sp + 1..]) } else if rest.len() >= 2 { let (d, u) = rest.split_at(rest.len() - 1); (d, u) } else { return None; }; let n: u64 = num_str.parse().ok()?; let unit = unit.trim(); let secs_per = match unit { "s" | "second" | "seconds" => 1, "m" | "minute" | "minutes" => 60, "h" | "hour" | "hours" => 3600, "d" | "day" | "days" => 86400, _ => return None, }; Some(n * secs_per) } #[cfg(test)] mod tests { use super::*; #[test] fn subsecond() { assert_eq!(format_duration(Duration::from_millis(500)), "0.5s"); } #[test] fn under_ten_seconds() { assert_eq!(format_duration(Duration::from_secs_f64(5.23)), "5.2s"); } #[test] fn ten_seconds_no_decimal() { assert_eq!(format_duration(Duration::from_secs(10)), "10s"); } #[test] fn seconds_no_decimal() { assert_eq!(format_duration(Duration::from_secs_f64(12.3)), "12s"); } #[test] fn thirty_seconds() { assert_eq!(format_duration(Duration::from_secs(30)), "30s"); } #[test] fn minutes() { assert_eq!(format_duration(Duration::from_secs(125)), "2m5s"); } #[test] fn hours() { assert_eq!(format_duration(Duration::from_secs(3725)), "1h2m"); } #[test] fn time_ago_just_now() { assert_eq!(format_time_ago(Duration::from_secs(0)), "just now"); assert_eq!(format_time_ago(Duration::from_secs(30)), "just now"); assert_eq!(format_time_ago(Duration::from_secs(59)), "just now"); } #[test] fn time_ago_minutes() { assert_eq!(format_time_ago(Duration::from_secs(60)), "1m"); assert_eq!(format_time_ago(Duration::from_secs(125)), "2m"); assert_eq!(format_time_ago(Duration::from_secs(3599)), "59m"); } #[test] fn time_ago_hours() { assert_eq!(format_time_ago(Duration::from_secs(3600)), "1h"); assert_eq!(format_time_ago(Duration::from_secs(7200)), "2h"); assert_eq!(format_time_ago(Duration::from_secs(86399)), "23h"); } #[test] fn time_ago_days() { assert_eq!(format_time_ago(Duration::from_secs(86400)), "1d"); assert_eq!(format_time_ago(Duration::from_secs(172800)), "2d"); assert_eq!(format_time_ago(Duration::from_secs(2_592_000 - 1)), "29d"); // just under 30d } #[test] fn time_ago_months() { assert_eq!(format_time_ago(Duration::from_secs(2_592_000)), "1mo"); // 30d assert_eq!(format_time_ago(Duration::from_secs(5_184_000)), "2mo"); // 359d is still 11mo (359/30=11); 360d would be 12mo. assert_eq!(format_time_ago(Duration::from_secs(359 * 86400)), "11mo"); } #[test] fn time_ago_years() { assert_eq!(format_time_ago(Duration::from_secs(31_536_000)), "1y"); // 365d assert_eq!(format_time_ago(Duration::from_secs(63_072_000)), "2y"); } fn now_unix_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_millis() as i64 } /// A real past timestamp survives the round-trip and renders its true age — /// including ages beyond the machine's uptime, which a monotonic `Instant` /// could not represent (its floor is system boot). #[test] fn system_time_from_unix_ms_renders_real_age() { let two_hours_ago = now_unix_ms() - 2 * 3_600_000; let elapsed = system_time_from_unix_ms(two_hours_ago) .elapsed() .unwrap_or_default(); assert_eq!(format_time_ago(elapsed), "2h"); let forty_five_days_ago = now_unix_ms() - 45 * 86_400_000; let elapsed = system_time_from_unix_ms(forty_five_days_ago) .elapsed() .unwrap_or_default(); assert_eq!(format_time_ago(elapsed), "1mo"); } /// A zero / missing timestamp (the `#[serde(default)]` sentinel) falls back /// to "now" rather than the unix epoch (1970). #[test] fn system_time_from_unix_ms_zero_falls_back_to_now() { let elapsed = system_time_from_unix_ms(0).elapsed().unwrap_or_default(); assert!(elapsed.as_secs() < 5, "zero sentinel must fall back to now"); } /// A future timestamp (clock skew) renders as "just now": `elapsed()` errors /// on a future `SystemTime`, and callers default that to a zero duration. #[test] fn system_time_from_unix_ms_future_renders_just_now() { let future = now_unix_ms() + 10_000_000; let elapsed = system_time_from_unix_ms(future) .elapsed() .unwrap_or_default(); assert_eq!(format_time_ago(elapsed), "just now"); } /// A fixed `Instant` projects to a stable wall-clock moment, so its age /// reflects time-since-anchor (here ~10m) rather than re-anchoring to now. #[test] fn system_time_from_instant_reflects_elapsed() { let ten_min_ago = Instant::now() - Duration::from_secs(600); let elapsed = system_time_from_instant(ten_min_ago) .elapsed() .unwrap_or_default(); assert_eq!(format_time_ago(elapsed), "10m"); } #[test] fn parses_every_5_minutes() { assert_eq!(parse_schedule_interval_secs("every 5 minutes"), Some(300)); } #[test] fn parses_every_5m_short() { assert_eq!(parse_schedule_interval_secs("every 5m"), Some(300)); } #[test] fn parses_every_10s() { assert_eq!(parse_schedule_interval_secs("every 10s"), Some(10)); } #[test] fn parses_every_1_hour() { assert_eq!(parse_schedule_interval_secs("every 1 hour"), Some(3600)); } #[test] fn parses_every_1_day() { assert_eq!(parse_schedule_interval_secs("every 1 day"), Some(86400)); } #[test] fn decode_html_entities_no_entities() { let s = "hello world"; let out = decode_html_entities(s); assert!(matches!(out, std::borrow::Cow::Borrowed(_))); assert_eq!(out.as_ref(), s); } #[test] fn decode_html_entities_amp() { assert_eq!(decode_html_entities("foo & bar").as_ref(), "foo & bar"); } #[test] fn decode_html_entities_multiple() { assert_eq!( decode_html_entities("1 < 2 && 3 > 2").as_ref(), "1 < 2 && 3 > 2" ); } #[test] fn decode_html_entities_quotes() { assert_eq!( decode_html_entities(""hello" & 'world'").as_ref(), "\"hello\" & 'world'" ); } #[test] fn unknown_schedule_returns_none() { assert_eq!(parse_schedule_interval_secs("foo bar"), None); assert_eq!(parse_schedule_interval_secs("every foo"), None); assert_eq!(parse_schedule_interval_secs("every 5x"), None); } #[test] fn display_kigi_home_prefix_default_install() { if std::env::var("KIGI_SHARE_DIR").is_ok() { return; } assert_eq!(display_kigi_home_prefix(), "~/.kigi"); } #[test] fn display_user_kigi_path_joins_relative() { let path = display_user_kigi_path("config.toml"); assert!(path.ends_with("/config.toml") || path.ends_with("\\config.toml")); assert!(path.contains(".kigi") || path.contains("$KIGI_SHARE_DIR")); } #[test] fn abbreviate_path_uses_home_when_under_default_kigi() { if let Ok(home) = std::env::var("HOME") { if home.is_empty() { return; } let full = format!("{home}/.kigi/memory/MEMORY.md"); let abbreviated = abbreviate_path(&full); assert!( abbreviated.contains("memory/MEMORY.md"), "got {abbreviated}" ); } } #[test] #[serial_test::serial] fn abbreviate_path_empty_home_does_not_fake_tilde() { let prev = std::env::var("HOME").ok(); unsafe { std::env::set_var("HOME", ""); } assert_eq!(abbreviate_path("/foo").as_ref(), "/foo"); match prev { Some(home) => unsafe { std::env::set_var("HOME", home) }, None => unsafe { std::env::remove_var("HOME") }, } } }