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