Skip to main content

recall_echo/
status.rs

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