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