1use 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 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 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 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 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 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 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}