Skip to main content

rgx/
status.rs

1//! Shared rendering for `rgx --server status` (and `watch`). Used by the daemon with live in-RAM
2//! stats, and by the CLI when no daemon is running — in which case it still reports the on-disk
3//! index location, size, and age, read straight from the snapshot file.
4
5use std::path::Path;
6
7/// Everything the status block can show. In-RAM fields are `None` when no daemon is running.
8pub struct Status<'a> {
9    pub root: &'a Path,
10    pub snapshot: &'a Path,
11    pub running: bool,
12    /// The resident index is intentionally not persisted (cheap to rebuild); only set by the daemon.
13    pub ram_only: bool,
14    /// "ready" or "building N / M files"; only when the daemon is running.
15    pub state: Option<String>,
16    pub files: Option<usize>,
17    pub trigrams: Option<usize>,
18    pub memory_bytes: Option<u64>,
19}
20
21impl Status<'_> {
22    pub fn render(&self) -> String {
23        let row = |label: &str, value: &str| format!("  {label:<9} {value}\n");
24        let mut s = String::from("rgx index status\n\n");
25        s.push_str(&row("root", &self.root.display().to_string()));
26
27        // Daemon up -> show live state; daemon down -> say so. Stats are shown either way (loaded
28        // from the snapshot when there's no daemon).
29        if self.running {
30            if let Some(state) = &self.state {
31                s.push_str(&row("state", state));
32            }
33        } else {
34            s.push_str(&row("daemon", "not running (run a search to start it)"));
35        }
36        if let Some(f) = self.files {
37            s.push_str(&row("files", &human_count(f as u64)));
38        }
39        if let Some(t) = self.trigrams {
40            s.push_str(&row("trigrams", &human_count(t as u64)));
41        }
42        if let Some(m) = self.memory_bytes {
43            s.push_str(&row("index", &human_bytes(m)));
44        }
45
46        // RAM-only index: there is deliberately no snapshot, so say so instead of "not built yet".
47        if self.ram_only {
48            s.push_str(&row("snapshot", "ram-only (rebuilt on start)"));
49            return s;
50        }
51
52        // On-disk snapshot: size + last-sync age, then its location — shown even with no daemon.
53        match std::fs::metadata(self.snapshot) {
54            Ok(m) => {
55                let age = m
56                    .modified()
57                    .ok()
58                    .and_then(|t| t.elapsed().ok())
59                    .map(|d| format!("last synced {} ago", human_duration(d.as_secs())))
60                    .unwrap_or_else(|| "on disk".into());
61                s.push_str(&row(
62                    "snapshot",
63                    &format!("{} ({age})", human_bytes(m.len())),
64                ));
65            }
66            Err(_) => s.push_str(&row("snapshot", "not built yet")),
67        }
68        s.push_str(&format!("            {}\n", self.snapshot.display()));
69        s
70    }
71}
72
73/// Human-friendly counts: `758`, `93.6k`, `1.5m` (one decimal, lowercase k/m suffixes). The `< 999.95`
74/// guards keep a value that would round to `1000.0` at one decimal from showing `1000.0k` instead of
75/// rolling over to `1.0m`.
76pub fn human_count(n: u64) -> String {
77    if n < 1_000 {
78        n.to_string()
79    } else if n as f64 / 1_000.0 < 999.95 {
80        format!("{:.1}k", n as f64 / 1_000.0)
81    } else {
82        format!("{:.1}m", n as f64 / 1_000_000.0)
83    }
84}
85
86pub fn human_bytes(n: u64) -> String {
87    const U: [&str; 4] = ["B", "KB", "MB", "GB"];
88    let mut v = n as f64;
89    let mut i = 0;
90    // Promote before a value that would render as `1024.0` at one decimal (e.g. 1048575 -> 1.0 MB,
91    // not 1024.0 KB).
92    while v >= 1023.95 && i < U.len() - 1 {
93        v /= 1024.0;
94        i += 1;
95    }
96    if i == 0 {
97        format!("{n} B")
98    } else {
99        format!("{v:.1} {}", U[i])
100    }
101}
102
103pub fn human_duration(secs: u64) -> String {
104    match secs {
105        0..=59 => format!("{secs}s"),
106        60..=3599 => format!("{}m{}s", secs / 60, secs % 60),
107        3600..=86399 => format!("{}h{}m", secs / 3600, (secs % 3600) / 60),
108        _ => format!("{}d{}h", secs / 86400, (secs % 86400) / 3600),
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn counts_use_k_and_m_suffixes() {
118        assert_eq!(human_count(758), "758");
119        assert_eq!(human_count(93_596), "93.6k");
120        assert_eq!(human_count(549_600), "549.6k");
121        assert_eq!(human_count(1_500_000), "1.5m");
122    }
123
124    #[test]
125    fn no_daemon_status_shows_snapshot_location() {
126        let block = Status {
127            root: Path::new("/repo"),
128            snapshot: Path::new("/cache/rgx/abc/index.bin"),
129            running: false,
130            ram_only: false,
131            state: None,
132            files: None,
133            trigrams: None,
134            memory_bytes: None,
135        }
136        .render();
137        assert!(block.contains("daemon    not running"));
138        assert!(block.contains("/cache/rgx/abc/index.bin"));
139        assert!(block.contains("not built yet")); // file doesn't exist in test
140    }
141
142    #[test]
143    fn ram_only_status_reports_no_snapshot() {
144        let snapshot = Path::new("/cache/rgx/abc/index.bin");
145        let status = |ram_only| {
146            Status {
147                root: Path::new("/repo"),
148                snapshot,
149                running: true,
150                ram_only,
151                state: Some("ready".into()),
152                files: Some(120),
153                trigrams: Some(5000),
154                memory_bytes: Some(1024),
155            }
156            .render()
157        };
158
159        let ram = status(true);
160        assert!(ram.contains("ram-only (rebuilt on start)"));
161        // The snapshot path/age is suppressed in RAM-only mode but present when persisted, so the
162        // path string genuinely distinguishes the two (not a coincidence of the chosen path).
163        assert!(!ram.contains(&snapshot.display().to_string()));
164        assert!(status(false).contains(&snapshot.display().to_string()));
165    }
166}