//! Small display formatters shared across tabs. /// Human-readable byte size: `999 B`, `1.2 KB`, `4.7 MB`, `1.3 GB`. pub fn human_size(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; let mut value = bytes as f64; let mut unit = 0; while value >= 1000.0 && unit < UNITS.len() - 1 { value /= 1000.0; unit += 1; } if unit == 0 { format!("{} B", bytes) } else { format!("{:.1} {}", value, UNITS[unit]) } } /// `YYYY-MM-DD HH:MM` in local time; raw seconds if out of range. pub fn fmt_mtime(unix_secs: i64) -> String { use chrono::TimeZone; match chrono::Local.timestamp_opt(unix_secs, 0) { chrono::LocalResult::Single(dt) => dt.format("%Y-%m-%d %H:%M").to_string(), _ => unix_secs.to_string(), } } /// Relative time for recent events, absolute for old ones: "just now", /// "5 min ago", "3 h ago", else `YYYY-MM-DD HH:MM`. pub fn fmt_ago(unix_secs: u64) -> String { let age = quicksearch_core::log::now_unix().saturating_sub(unix_secs); if age < 60 { "just now".to_string() } else if age < 3600 { format!("{} min ago", age / 60) } else if age < 86_400 { format!("{} h ago", age / 3600) } else { fmt_mtime(unix_secs as i64) } } /// A configured interval as a phrase to drop after "every": `90 min`, /// `24 h`, `3 days`. pub fn fmt_interval(minutes: u64) -> String { if minutes == 0 { // The scheduler treats 0 as always-due. return "run".to_string(); } if minutes < 60 { return format!("{} min", minutes); } if minutes.is_multiple_of(1440) { let days = minutes / 1440; return if days == 1 { // "24 h", not "1 day": a staleness window reads better in hours. "24 h".to_string() } else { format!("{} days", days) }; } if minutes.is_multiple_of(60) { return format!("{} h", minutes / 60); } format!("{} h {} min", minutes / 60, minutes % 60) } /// Group thousands for counts: `1,234,567`. pub fn group_thousands(n: u64) -> String { let digits = n.to_string(); let mut out = String::with_capacity(digits.len() + digits.len() / 3); for (i, c) in digits.chars().enumerate() { if i > 0 && (digits.len() - i).is_multiple_of(3) { out.push(','); } out.push(c); } out } /// Files/sec display. Never renders a nonzero rate as "0.0": slow rates /// switch to a per-minute figure. pub fn fmt_rate(files_per_sec: f64) -> String { if files_per_sec <= 0.0 { "0 files/s".to_string() } else if files_per_sec >= 10.0 { format!("{:.0} files/s", files_per_sec) } else if files_per_sec >= 1.0 { format!("{:.1} files/s", files_per_sec) } else { format!("{:.0} files/min", (files_per_sec * 60.0).max(1.0)) } } /// Search duration: milliseconds under a second, seconds above. pub fn fmt_elapsed(d: std::time::Duration) -> String { let ms = d.as_millis(); if ms >= 1000 { format!("{:.1} s", d.as_secs_f64()) } else { format!("{} ms", ms) } } /// A running clock: `0:07`, `4:32`, `1:04:12`. Seconds are always two /// digits so the text does not change width every tick. pub fn fmt_duration_clock(d: std::time::Duration) -> String { let secs = d.as_secs(); let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60); if h > 0 { format!("{}:{:02}:{:02}", h, m, s) } else { format!("{}:{:02}", m, s) } } /// What a finished configuration reconciliation did, in one line. A clause /// whose count is zero is left out rather than printed as "0", and a pass /// that found nothing to change still reports that it ran. pub fn fmt_reconcile_summary(deleted: usize, recontented: usize) -> String { let entries = |n: usize| { format!( "{} {}", group_thousands(n as u64), if n == 1 { "entry" } else { "entries" } ) }; let mut parts: Vec = Vec::new(); if deleted > 0 { parts.push(format!("{} removed", entries(deleted))); } if recontented > 0 { parts.push(format!("{} re-examined", entries(recontented))); } if parts.is_empty() { return "Configuration change applied".to_string(); } format!("Configuration change applied · {}", parts.join(" · ")) } /// Middle-truncate a path to at most `max_chars` characters. Char-count /// based and `Ui`-free; rows with a real pixel budget use the width-aware /// `ui_util::middle_elide` instead. pub fn middle_truncate(s: &str, max_chars: usize) -> String { let chars: Vec = s.chars().collect(); if chars.len() <= max_chars || max_chars < 5 { return s.to_string(); } let keep = max_chars - 1; let head = keep / 2; let tail = keep - head; let mut out: String = chars[..head].iter().collect(); out.push('…'); out.extend(&chars[chars.len() - tail..]); out } #[cfg(test)] mod tests { use super::*; #[test] fn sizes() { assert_eq!(human_size(0), "0 B"); assert_eq!(human_size(999), "999 B"); assert_eq!(human_size(1200), "1.2 KB"); assert_eq!(human_size(4_700_000), "4.7 MB"); assert_eq!(human_size(1_300_000_000), "1.3 GB"); } #[test] fn intervals() { assert_eq!(fmt_interval(0), "run"); assert_eq!(fmt_interval(1), "1 min"); assert_eq!(fmt_interval(59), "59 min"); assert_eq!(fmt_interval(60), "1 h", "the shipped default"); assert_eq!(fmt_interval(90), "1 h 30 min"); assert_eq!(fmt_interval(120), "2 h"); // A whole day is the one multiple-of-1440 case that stays in hours. assert_eq!(fmt_interval(1440), "24 h"); assert_eq!(fmt_interval(2880), "2 days"); assert_eq!(fmt_interval(10_080), "7 days"); } #[test] fn thousands() { assert_eq!(group_thousands(0), "0"); assert_eq!(group_thousands(999), "999"); assert_eq!(group_thousands(1000), "1,000"); assert_eq!(group_thousands(1_234_567), "1,234,567"); } #[test] fn rates_never_show_zero_for_nonzero() { assert_eq!(fmt_rate(0.0), "0 files/s"); assert_eq!(fmt_rate(2543.0), "2543 files/s"); // Not 3.14: clippy reads that as a botched `PI` and denies it. assert_eq!(fmt_rate(3.12), "3.1 files/s"); assert_eq!(fmt_rate(0.4), "24 files/min"); assert_eq!(fmt_rate(0.001), "1 files/min", "floor at 1/min, never 0.0"); } #[test] fn elapsed_units() { use std::time::Duration; assert_eq!(fmt_elapsed(Duration::from_millis(0)), "0 ms"); assert_eq!(fmt_elapsed(Duration::from_millis(7)), "7 ms"); assert_eq!(fmt_elapsed(Duration::from_millis(999)), "999 ms"); assert_eq!(fmt_elapsed(Duration::from_millis(1000)), "1.0 s"); assert_eq!(fmt_elapsed(Duration::from_millis(2340)), "2.3 s"); } #[test] fn ago_buckets() { let now = quicksearch_core::log::now_unix(); assert_eq!(fmt_ago(now), "just now"); assert_eq!(fmt_ago(now - 59), "just now"); assert_eq!(fmt_ago(now - 120), "2 min ago"); assert_eq!(fmt_ago(now - 7200), "2 h ago"); assert!(fmt_ago(now - 200_000).contains('-'), "old = absolute date"); } #[test] fn reconcile_summaries_omit_what_did_not_happen() { assert_eq!( fmt_reconcile_summary(0, 0), "Configuration change applied", "a pass with nothing to do still reports that it ran" ); assert_eq!( fmt_reconcile_summary(1, 0), "Configuration change applied · 1 entry removed" ); assert_eq!( fmt_reconcile_summary(1204, 0), "Configuration change applied · 1,204 entries removed" ); assert_eq!( fmt_reconcile_summary(0, 7), "Configuration change applied · 7 entries re-examined" ); assert_eq!( fmt_reconcile_summary(2, 3), "Configuration change applied · 2 entries removed · 3 entries re-examined" ); } #[test] fn truncation() { assert_eq!(middle_truncate("short", 20), "short"); let t = middle_truncate("/very/long/path/to/some/file.txt", 15); assert!(t.chars().count() <= 15); assert!(t.contains('…')); assert!(t.starts_with("/very")); assert!(t.ends_with("e.txt")); } }