Skip to main content

recall_echo/
status.rs

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