Skip to main content

recall_echo/
status.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Memory system health check.
6//!
7//! Quick status overview of the four-layer memory system.
8//! For the full ASCII art dashboard, use the `dashboard` module.
9
10use std::fs;
11use std::path::Path;
12
13use crate::config;
14use crate::ephemeral;
15use crate::error::RecallError;
16use crate::paths;
17
18const BOLD: &str = "\x1b[1m";
19const GREEN: &str = "\x1b[32m";
20const YELLOW: &str = "\x1b[33m";
21const RED: &str = "\x1b[31m";
22const DIM: &str = "\x1b[2m";
23const RESET: &str = "\x1b[0m";
24
25pub fn run() -> Result<(), RecallError> {
26    run_with_base(&paths::entity_root()?)
27}
28
29pub fn run_with_base(entity_root: &Path) -> Result<(), RecallError> {
30    let memory = entity_root.join("memory");
31    if !memory.exists() {
32        return Err(RecallError::NotInitialized(
33            "memory/ directory not found. Run `recall-echo init` first.".into(),
34        ));
35    }
36
37    let mut issues: Vec<String> = Vec::new();
38
39    // Header
40    let overall = if memory.join("conversations").exists()
41        && memory.join("EPHEMERAL.md").exists()
42        && memory.join("MEMORY.md").exists()
43    {
44        format!("{GREEN}healthy{RESET}")
45    } else {
46        issues.push("Run `recall-echo init` to complete setup".to_string());
47        format!("{YELLOW}incomplete{RESET}")
48    };
49
50    eprintln!("\n{BOLD}recall-echo{RESET} — {overall}\n");
51
52    // MEMORY.md
53    let memory_path = memory.join("MEMORY.md");
54    if memory_path.exists() {
55        let lines = fs::read_to_string(&memory_path)
56            .unwrap_or_default()
57            .lines()
58            .count();
59        let pct = (lines as f32 / 200.0 * 100.0) as u32;
60        let bar = progress_bar(pct, 4);
61        let color = if pct > 90 {
62            RED
63        } else if pct > 70 {
64            YELLOW
65        } else {
66            GREEN
67        };
68        eprintln!("  MEMORY.md       {color}{lines}/200 lines ({pct}%){RESET}  {bar}");
69        if pct > 70 {
70            issues.push(format!("MEMORY.md approaching limit ({pct}%)"));
71        }
72    } else {
73        eprintln!("  MEMORY.md       {DIM}not found{RESET}");
74        issues.push("MEMORY.md not found".to_string());
75    }
76
77    // EPHEMERAL.md
78    let cfg = config::load(&memory);
79    let max_entries = cfg.ephemeral.max_entries;
80    let ephemeral_path = memory.join("EPHEMERAL.md");
81    if ephemeral_path.exists() {
82        let count = ephemeral::count_entries(&ephemeral_path).unwrap_or(0);
83        eprintln!("  EPHEMERAL       {count}/{max_entries} sessions");
84    } else {
85        eprintln!("  EPHEMERAL       {DIM}not found{RESET}");
86    }
87
88    // Archives
89    let conversations_dir = memory.join("conversations");
90    if conversations_dir.exists() {
91        let (count, total_bytes) = count_conversations(&conversations_dir);
92        let size_str = format_bytes(total_bytes);
93        eprintln!("  Archives        {count} conversations ({size_str})");
94
95        if count > 0 {
96            let (oldest, newest) = find_date_range(&conversations_dir);
97            if let Some(newest) = newest {
98                eprintln!("  Last archived   {newest}");
99            }
100            if let Some(oldest) = oldest {
101                eprintln!("  Oldest archive  {oldest}");
102            }
103        }
104    } else {
105        eprintln!("  Archives        {DIM}not initialized{RESET}");
106    }
107
108    // Graph — the flat-file status command deliberately does not open the
109    // store (the daemon may hold it); `graph status` reports real counts.
110    let graph_dir = memory.join("graph");
111    if graph_dir.exists() {
112        eprintln!(
113            "  Graph           {DIM}present — run `recall-echo graph status` for counts{RESET}"
114        );
115    }
116
117    // Issues
118    eprintln!();
119    if issues.is_empty() {
120        eprintln!("  {GREEN}No issues detected.{RESET}");
121    } else {
122        for issue in &issues {
123            eprintln!("  {YELLOW}!{RESET} {issue}");
124        }
125    }
126    eprintln!();
127
128    Ok(())
129}
130
131fn count_conversations(dir: &Path) -> (usize, u64) {
132    let mut count = 0;
133    let mut total = 0u64;
134    if let Ok(entries) = fs::read_dir(dir) {
135        for entry in entries.flatten() {
136            let name = entry.file_name();
137            let name = name.to_string_lossy();
138            if name.starts_with("conversation-") && name.ends_with(".md") {
139                count += 1;
140                total += entry.metadata().map(|m| m.len()).unwrap_or(0);
141            }
142        }
143    }
144    (count, total)
145}
146
147fn find_date_range(dir: &Path) -> (Option<String>, Option<String>) {
148    let mut dates: Vec<String> = Vec::new();
149    if let Ok(entries) = fs::read_dir(dir) {
150        for entry in entries.flatten() {
151            let name = entry.file_name();
152            let name = name.to_string_lossy();
153            if name.starts_with("conversation-") && name.ends_with(".md") {
154                if let Ok(content) = fs::read_to_string(entry.path()) {
155                    for line in content.lines().take(10) {
156                        if let Some(date) = line.strip_prefix("date: ") {
157                            let d = date.trim().trim_matches('"');
158                            if let Some(day) = d.split('T').next() {
159                                dates.push(day.to_string());
160                            }
161                            break;
162                        }
163                    }
164                }
165            }
166        }
167    }
168    dates.sort();
169    let oldest = dates.first().cloned();
170    let newest = dates.last().cloned();
171    (oldest, newest)
172}
173
174fn format_bytes(bytes: u64) -> String {
175    if bytes < 1024 {
176        format!("{bytes} B")
177    } else if bytes < 1024 * 1024 {
178        format!("{:.1} KB", bytes as f64 / 1024.0)
179    } else {
180        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
181    }
182}
183
184fn progress_bar(pct: u32, width: usize) -> String {
185    let filled = (pct as usize * width / 100).min(width);
186    let empty = width - filled;
187    format!("{}{}", "█".repeat(filled), "░".repeat(empty))
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn status_on_initialized_env() {
196        let tmp = tempfile::tempdir().unwrap();
197        let root = tmp.path();
198        crate::init::run(root).unwrap();
199        assert!(run_with_base(root).is_ok());
200    }
201
202    #[test]
203    fn status_on_missing_dir() {
204        assert!(run_with_base(Path::new("/nonexistent")).is_err());
205    }
206
207    #[test]
208    fn format_bytes_ranges() {
209        assert_eq!(format_bytes(500), "500 B");
210        assert_eq!(format_bytes(2048), "2.0 KB");
211        assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB");
212    }
213
214    #[test]
215    fn progress_bar_display() {
216        assert_eq!(progress_bar(0, 4), "░░░░");
217        assert_eq!(progress_bar(50, 4), "██░░");
218        assert_eq!(progress_bar(100, 4), "████");
219    }
220
221    #[test]
222    fn count_conversations_basic() {
223        let tmp = tempfile::tempdir().unwrap();
224        fs::write(tmp.path().join("conversation-001.md"), "hello").unwrap();
225        fs::write(tmp.path().join("conversation-002.md"), "world").unwrap();
226        fs::write(tmp.path().join("notes.md"), "ignore").unwrap();
227        let (count, _) = count_conversations(tmp.path());
228        assert_eq!(count, 2);
229    }
230}