1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::RecallEcho;
6
7const GREEN: &str = "\x1b[32m";
10const YELLOW: &str = "\x1b[33m";
11const RED: &str = "\x1b[31m";
12const CYAN: &str = "\x1b[36m";
13const DIM: &str = "\x1b[2m";
14const BOLD: &str = "\x1b[1m";
15const RESET: &str = "\x1b[0m";
16
17const LOGO: &str = r#"
18╦═╗╔═╗╔═╗╔═╗╦ ╦
19╠╦╝║╣ ║ ╠═╣║ ║
20╩╚═╚═╝╚═╝╩ ╩╩═╝╩═╝"#;
21
22const SEPARATOR: &str = " ──────────────────────────────────────────────────────────────";
23
24pub enum HealthLevel {
28 Healthy,
29 Watch,
30 Alert,
31}
32
33pub struct HealthAssessment {
34 pub level: HealthLevel,
35 pub warnings: Vec<String>,
36}
37
38impl HealthAssessment {
39 pub fn display(&self) -> String {
40 match self.level {
41 HealthLevel::Healthy => format!("{GREEN}HEALTHY{RESET}"),
42 HealthLevel::Watch => format!("{YELLOW}WATCH{RESET}"),
43 HealthLevel::Alert => format!("{RED}ALERT{RESET}"),
44 }
45 }
46}
47
48pub struct MemoryStats {
50 pub line_count: usize,
51 pub sections: Vec<(String, usize)>,
52 pub modified: Option<std::time::SystemTime>,
53}
54
55impl MemoryStats {
56 pub fn collect(recall: &RecallEcho) -> Self {
57 let memory_path = recall.memory_file();
58 if !memory_path.exists() {
59 return Self {
60 line_count: 0,
61 sections: Vec::new(),
62 modified: None,
63 };
64 }
65
66 let content = fs::read_to_string(&memory_path).unwrap_or_default();
67 let lines: Vec<&str> = content.lines().collect();
68 let line_count = lines.len();
69
70 let sections: Vec<(String, usize)> = find_sections(&lines)
71 .into_iter()
72 .map(|(name, _, size)| (name, size))
73 .collect();
74
75 let modified = fs::metadata(&memory_path)
76 .ok()
77 .and_then(|m| m.modified().ok());
78
79 Self {
80 line_count,
81 sections,
82 modified,
83 }
84 }
85
86 pub fn freshness_display(&self) -> String {
87 match self.modified {
88 Some(time) => format_age(time),
89 None => "unknown".to_string(),
90 }
91 }
92}
93
94pub struct EphemeralEntry {
96 pub log_num: String,
97 pub age_display: String,
98 pub duration: String,
99 pub message_count: String,
100 pub topics: String,
101}
102
103pub struct ArchiveStats {
105 pub count: usize,
106 pub total_bytes: u64,
107 pub newest_modified: Option<std::time::SystemTime>,
108}
109
110impl ArchiveStats {
111 pub fn collect(recall: &RecallEcho) -> Self {
112 let conv_dir = recall.conversations_dir();
113 if !conv_dir.exists() {
114 return Self {
115 count: 0,
116 total_bytes: 0,
117 newest_modified: None,
118 };
119 }
120
121 let entries: Vec<_> = fs::read_dir(&conv_dir)
122 .into_iter()
123 .flatten()
124 .filter_map(|e| e.ok())
125 .filter(|e| e.file_name().to_string_lossy().starts_with("conversation-"))
126 .collect();
127
128 let count = entries.len();
129 let mut total_bytes = 0u64;
130 let mut newest: Option<std::time::SystemTime> = None;
131
132 for entry in &entries {
133 if let Ok(meta) = entry.metadata() {
134 total_bytes += meta.len();
135 if let Ok(modified) = meta.modified() {
136 newest = Some(match newest {
137 Some(prev) if modified > prev => modified,
138 Some(prev) => prev,
139 None => modified,
140 });
141 }
142 }
143 }
144
145 Self {
146 count,
147 total_bytes,
148 newest_modified: newest,
149 }
150 }
151
152 pub fn freshness_display(&self) -> String {
153 match self.newest_modified {
154 Some(time) => format_age(time),
155 None => "no archives".to_string(),
156 }
157 }
158}
159
160pub fn render(recall: &RecallEcho, entity_name: &str, version: &str, max_memory_lines: usize) {
164 let memory_stats = MemoryStats::collect(recall);
165 let ephemeral_entries = parse_ephemeral_entries(recall);
166 let archive_stats = ArchiveStats::collect(recall);
167 let health = assess_health(&memory_stats, &archive_stats, max_memory_lines);
168
169 let logo_lines: Vec<&str> = LOGO.lines().skip(1).collect();
171 let meta_lines = [
172 format!("entity {CYAN}{entity_name}{RESET}"),
173 format!(
174 "memory {}/{} {} {}",
175 memory_stats.line_count,
176 max_memory_lines,
177 memory_bar(memory_stats.line_count, max_memory_lines),
178 memory_status_word(memory_stats.line_count, max_memory_lines),
179 ),
180 format!("sessions {}/5 entries", ephemeral_entries.len()),
181 format!(
182 "archive {} conversations ({})",
183 archive_stats.count,
184 format_bytes(archive_stats.total_bytes),
185 ),
186 format!("freshness {}", archive_stats.freshness_display()),
187 ];
188
189 println!();
190 let logo_width = 26;
191 for (i, logo_line) in logo_lines.iter().enumerate() {
192 if i < meta_lines.len() {
193 println!(
194 " {GREEN}{:<width$}{RESET} {}",
195 logo_line,
196 meta_lines[i],
197 width = logo_width,
198 );
199 } else {
200 println!(" {GREEN}{}{RESET}", logo_line);
201 }
202 }
203
204 for meta_line in meta_lines.iter().skip(logo_lines.len()) {
206 println!(" {:<width$} {}", "", meta_line, width = logo_width);
207 }
208
209 println!(" v{version}");
210 println!("{SEPARATOR}");
211
212 println!();
214 println!(
215 " {BOLD}Memory Health{RESET} {}",
216 health.display()
217 );
218 println!();
219
220 println!(
221 " {:<14} {} {:<8} {}",
222 "curated",
223 memory_bar(memory_stats.line_count, max_memory_lines),
224 format!("{}/{}", memory_stats.line_count, max_memory_lines),
225 memory_status_word(memory_stats.line_count, max_memory_lines),
226 );
227 println!(
228 " {:<14} {} {:<8} ok",
229 "ephemeral",
230 memory_bar(ephemeral_entries.len(), 5),
231 format!("{}/5", ephemeral_entries.len()),
232 );
233 println!(
234 " {:<14} {} conversations {}",
235 "archive",
236 archive_stats.count,
237 format_bytes(archive_stats.total_bytes),
238 );
239
240 for warning in &health.warnings {
242 println!(" {YELLOW}!{RESET} {warning}");
243 }
244
245 if !ephemeral_entries.is_empty() {
247 println!();
248 println!(" {BOLD}Recent Sessions{RESET}");
249 println!();
250
251 for entry in ephemeral_entries.iter().rev() {
252 println!(
253 " {DIM}#{:<4}{RESET} {DIM}{:<8}{RESET} {:<5} {:<8} {}",
254 entry.log_num,
255 entry.age_display,
256 entry.duration,
257 format!("{} msgs", entry.message_count),
258 entry.topics,
259 );
260 }
261 }
262
263 if !memory_stats.sections.is_empty() {
265 println!();
266 println!(" {BOLD}Memory Sections{RESET}");
267 println!();
268 println!(
269 " {} sections · {} lines · last updated {}",
270 memory_stats.sections.len(),
271 memory_stats.line_count,
272 memory_stats.freshness_display(),
273 );
274
275 let mut sorted: Vec<_> = memory_stats.sections.iter().collect();
276 sorted.sort_by(|a, b| b.1.cmp(&a.1));
277 let top: Vec<String> = sorted
278 .iter()
279 .take(3)
280 .map(|(name, size)| format!("{name} ({size} lines)"))
281 .collect();
282 if !top.is_empty() {
283 println!(" {DIM}largest: {}{RESET}", top.join(", "));
284 }
285 }
286
287 println!();
288}
289
290pub fn search_lines(recall: &RecallEcho, query: &str) -> Result<(), String> {
294 let conv_dir = recall.conversations_dir();
295 if !conv_dir.exists() {
296 println!(" No conversation archives found.");
297 return Ok(());
298 }
299
300 let files = list_conversation_files(&conv_dir)?;
301 if files.is_empty() {
302 println!(" No conversation archives found.");
303 return Ok(());
304 }
305
306 let query_lower = query.to_lowercase();
307 let mut total_matches = 0;
308
309 for file in &files {
310 let content = fs::read_to_string(file)
311 .map_err(|e| format!("Failed to read {}: {e}", file.display()))?;
312 let filename = file.file_name().unwrap_or_default().to_string_lossy();
313 let mut file_matches = Vec::new();
314
315 for (i, line) in content.lines().enumerate() {
316 if line.to_lowercase().contains(&query_lower) {
317 file_matches.push((i + 1, line.to_string()));
318 }
319 }
320
321 if !file_matches.is_empty() {
322 println!("\n {CYAN}{filename}{RESET}");
323 for (line_num, line) in file_matches.iter().take(5) {
324 let display = if line.len() > 100 {
325 format!("{}...", &line[..97])
326 } else {
327 line.to_string()
328 };
329 println!(" {DIM}{line_num:>4}{RESET} {display}");
330 }
331 if file_matches.len() > 5 {
332 println!(
333 " {DIM} ...and {} more matches{RESET}",
334 file_matches.len() - 5
335 );
336 }
337 total_matches += file_matches.len();
338 }
339 }
340
341 if total_matches == 0 {
342 println!(" No matches for \"{query}\"");
343 } else {
344 println!(
345 "\n {DIM}{total_matches} matches across {} files{RESET}",
346 files.len()
347 );
348 }
349
350 Ok(())
351}
352
353pub fn search_ranked(recall: &RecallEcho, query: &str) -> Result<(), String> {
355 let conv_dir = recall.conversations_dir();
356 if !conv_dir.exists() {
357 println!(" No conversation archives found.");
358 return Ok(());
359 }
360
361 let files = list_conversation_files(&conv_dir)?;
362 if files.is_empty() {
363 println!(" No conversation archives found.");
364 return Ok(());
365 }
366
367 let query_lower = query.to_lowercase();
368 let query_words: Vec<&str> = query_lower.split_whitespace().collect();
369 let mut scored: Vec<(f64, &PathBuf, Vec<String>)> = Vec::new();
370
371 for (idx, file) in files.iter().enumerate() {
372 let content = fs::read_to_string(file)
373 .map_err(|e| format!("Failed to read {}: {e}", file.display()))?;
374 let content_lower = content.to_lowercase();
375
376 let match_count = content_lower.matches(&query_lower).count();
377 if match_count == 0 {
378 continue;
379 }
380
381 let words_found = query_words
382 .iter()
383 .filter(|w| content_lower.contains(**w))
384 .count();
385 let word_ratio = words_found as f64 / query_words.len().max(1) as f64;
386
387 let recency = (idx as f64 + 1.0) / files.len() as f64;
388
389 let content_boost = if content_lower.contains(&format!("### user\n\n{}", query_lower)) {
390 1.5
391 } else {
392 1.0
393 };
394
395 let score = (match_count as f64 * word_ratio + recency) * content_boost;
396
397 let previews: Vec<String> = content
398 .lines()
399 .filter(|l| {
400 let lower = l.to_lowercase();
401 lower.contains(&query_lower) && !l.starts_with('#') && !l.starts_with("---")
402 })
403 .take(3)
404 .map(|l| {
405 if l.len() > 90 {
406 format!("{}...", &l[..87])
407 } else {
408 l.to_string()
409 }
410 })
411 .collect();
412
413 scored.push((score, file, previews));
414 }
415
416 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
417
418 if scored.is_empty() {
419 println!(" No matches for \"{query}\"");
420 return Ok(());
421 }
422
423 println!();
424 println!(
425 " {BOLD}Search Results{RESET} ({} files matched)\n",
426 scored.len()
427 );
428
429 for (score, file, previews) in scored.iter().take(10) {
430 let filename = file.file_name().unwrap_or_default().to_string_lossy();
431 println!(" {CYAN}{filename}{RESET} {DIM}(score: {score:.1}){RESET}");
432 for preview in previews {
433 println!(" {DIM}{preview}{RESET}");
434 }
435 }
436
437 println!();
438 Ok(())
439}
440
441pub fn auto_distill(recall: &RecallEcho, max_lines: usize) -> Result<(), String> {
445 let memory_path = recall.memory_file();
446 let memory_dir = recall.memory_dir();
447
448 if !memory_path.exists() {
449 println!(" MEMORY.md not found. Nothing to distill.");
450 return Ok(());
451 }
452
453 let content =
454 fs::read_to_string(&memory_path).map_err(|e| format!("Failed to read MEMORY.md: {e}"))?;
455 let lines: Vec<&str> = content.lines().collect();
456 let line_count = lines.len();
457
458 println!();
459 if line_count > (max_lines * 85 / 100) {
460 println!(
461 " {YELLOW}!{RESET} MEMORY.md at {line_count}/{max_lines} lines ({}%) — cleanup recommended",
462 line_count * 100 / max_lines,
463 );
464 } else {
465 println!(
466 " MEMORY.md at {line_count}/{max_lines} lines ({}%) — {GREEN}healthy{RESET}",
467 line_count * 100 / max_lines,
468 );
469 println!();
470 return Ok(());
471 }
472
473 let sections = find_sections(&lines);
475 let mut extractions: Vec<(String, usize, PathBuf)> = Vec::new();
476
477 for (name, start, size) in §ions {
478 if *size <= 30 {
479 continue;
480 }
481
482 let slug: String = name
483 .to_lowercase()
484 .chars()
485 .map(|c| if c.is_alphanumeric() { c } else { '-' })
486 .collect();
487 let slug = slug.trim_matches('-').to_string();
488 let topic_path = memory_dir.join(format!("{slug}.md"));
489
490 let section_lines: Vec<&str> = lines[*start..*start + *size].to_vec();
491 let section_content = section_lines.join("\n");
492
493 fs::write(&topic_path, format!("{section_content}\n"))
494 .map_err(|e| format!("Failed to write {}: {e}", topic_path.display()))?;
495
496 extractions.push((name.clone(), *size, topic_path));
497 }
498
499 if extractions.is_empty() {
500 let suggestions = analyze_non_section_issues(&lines);
501 if suggestions.is_empty() {
502 println!(" {DIM}No large sections to extract. Consider manual review.{RESET}");
503 } else {
504 println!();
505 println!(" {BOLD}Suggestions{RESET}");
506 println!();
507 for (i, s) in suggestions.iter().enumerate() {
508 println!(" {}. {s}", i + 1);
509 }
510 }
511 println!();
512 return Ok(());
513 }
514
515 let mut new_lines: Vec<String> = Vec::new();
517 let mut skip_until_next_section = false;
518
519 for (i, line) in lines.iter().enumerate() {
520 let is_extracted = extractions.iter().find(|(name, _, _)| {
521 sections
522 .iter()
523 .any(|(sname, start, _)| sname == name && *start == i)
524 });
525
526 if let Some(extraction) = is_extracted {
527 new_lines.push(line.to_string());
528 let rel_path = extraction
529 .2
530 .file_name()
531 .unwrap_or_default()
532 .to_string_lossy();
533 new_lines.push(format!("See memory/{rel_path} for details."));
534 new_lines.push(String::new());
535 skip_until_next_section = true;
536 continue;
537 }
538
539 if skip_until_next_section {
540 if (line.starts_with("# ") || line.starts_with("## ")) && i > 0 {
541 skip_until_next_section = false;
542 new_lines.push(line.to_string());
543 }
544 continue;
545 }
546
547 new_lines.push(line.to_string());
548 }
549
550 let new_content = new_lines.join("\n");
551 fs::write(&memory_path, format!("{new_content}\n"))
552 .map_err(|e| format!("Failed to write MEMORY.md: {e}"))?;
553
554 println!();
556 println!(" {BOLD}Extracted{RESET}");
557 println!();
558 for (name, size, path) in &extractions {
559 let rel = path.file_name().unwrap_or_default().to_string_lossy();
560 println!(" {GREEN}→{RESET} {name} ({size} lines) → memory/{rel}");
561 }
562
563 let new_line_count = new_content.lines().count();
564 println!();
565 println!(
566 " MEMORY.md: {line_count} → {new_line_count} lines ({}%)",
567 new_line_count * 100 / max_lines,
568 );
569 println!();
570
571 Ok(())
572}
573
574pub fn assess_health(
577 memory: &MemoryStats,
578 archive: &ArchiveStats,
579 max_memory_lines: usize,
580) -> HealthAssessment {
581 let mut warnings = Vec::new();
582 let mut level = HealthLevel::Healthy;
583
584 if memory.line_count > max_memory_lines * 90 / 100 {
585 warnings.push(format!(
586 "MEMORY.md at {}% — run distill",
587 memory.line_count * 100 / max_memory_lines,
588 ));
589 level = HealthLevel::Alert;
590 } else if memory.line_count > max_memory_lines * 75 / 100 {
591 warnings.push(format!(
592 "MEMORY.md approaching limit ({}%)",
593 memory.line_count * 100 / max_memory_lines,
594 ));
595 level = HealthLevel::Watch;
596 }
597
598 if archive.count == 0 {
599 warnings.push("No conversation archives yet".to_string());
600 if !matches!(level, HealthLevel::Alert) {
601 level = HealthLevel::Watch;
602 }
603 }
604
605 if let Some(newest) = archive.newest_modified {
606 if let Ok(elapsed) = newest.elapsed() {
607 if elapsed.as_secs() > 7 * 86400 {
608 warnings.push("Last archive is over 7 days old".to_string());
609 if !matches!(level, HealthLevel::Alert) {
610 level = HealthLevel::Watch;
611 }
612 }
613 }
614 }
615
616 HealthAssessment { level, warnings }
617}
618
619pub fn parse_ephemeral_entries(recall: &RecallEcho) -> Vec<EphemeralEntry> {
622 let ephemeral_path = recall.ephemeral_file();
623 if !ephemeral_path.exists() {
624 return Vec::new();
625 }
626
627 let content = match fs::read_to_string(&ephemeral_path) {
628 Ok(c) => c,
629 Err(_) => return Vec::new(),
630 };
631
632 let raw_entries: Vec<&str> = content
633 .split("\n---\n")
634 .map(|e| e.trim())
635 .filter(|e| !e.is_empty())
636 .collect();
637
638 raw_entries
639 .iter()
640 .enumerate()
641 .map(|(i, entry)| {
642 let first_line = entry.lines().next().unwrap_or("");
643
644 let date_str = first_line
646 .split('—')
647 .nth(1)
648 .or_else(|| first_line.split(" - ").nth(1))
649 .unwrap_or("")
650 .trim();
651
652 let duration = entry
654 .lines()
655 .find(|l| l.contains("**Duration**"))
656 .and_then(|l| {
657 l.split("~")
658 .nth(1)
659 .and_then(|s| s.split('|').next().map(|d| d.trim().to_string()))
660 })
661 .unwrap_or_else(|| "\u{2014}".to_string());
662
663 let msg_count = entry
665 .lines()
666 .find(|l| l.contains("**Messages**"))
667 .and_then(|l| {
668 l.split("**Messages**:").nth(1).and_then(|s| {
669 s.trim()
670 .split(|c: char| !c.is_ascii_digit())
671 .next()
672 .and_then(|n| n.parse::<u32>().ok())
673 })
674 })
675 .or_else(|| {
676 entry
677 .lines()
678 .find(|l| l.contains("messages"))
679 .and_then(|l| {
680 l.split('(')
681 .nth(1)
682 .and_then(|s| s.split_whitespace().next())
683 .and_then(|n| n.parse::<u32>().ok())
684 })
685 })
686 .unwrap_or(0);
687
688 let summary = entry
690 .lines()
691 .find(|l| l.contains("**Summary**"))
692 .and_then(|l| l.split("**Summary**:").nth(1))
693 .map(|s| {
694 let trimmed = s.trim();
695 if trimmed.len() > 50 {
696 format!("{}...", &trimmed[..47])
697 } else {
698 trimmed.to_string()
699 }
700 })
701 .unwrap_or_else(|| {
702 let topics: Vec<&str> = entry
704 .lines()
705 .filter(|l| l.starts_with("- ") && !l.contains("...and"))
706 .take(3)
707 .map(|l| l.trim_start_matches("- "))
708 .collect();
709
710 if topics.is_empty() {
711 "\u{2014}".to_string()
712 } else {
713 let joined: String = topics
714 .iter()
715 .map(|t| {
716 if t.len() > 30 {
717 format!("{}...", &t[..27])
718 } else {
719 t.to_string()
720 }
721 })
722 .collect::<Vec<_>>()
723 .join(", ");
724 if joined.len() > 60 {
725 format!("{}...", &joined[..57])
726 } else {
727 joined
728 }
729 }
730 });
731
732 EphemeralEntry {
733 log_num: format!("{}", i + 1),
734 age_display: if date_str.is_empty() {
735 "\u{2014}".to_string()
736 } else {
737 date_str.chars().take(16).collect()
738 },
739 duration,
740 message_count: msg_count.to_string(),
741 topics: summary,
742 }
743 })
744 .collect()
745}
746
747fn memory_bar(count: usize, max: usize) -> String {
748 let width = 10;
749 let filled = if max > 0 {
750 (count * width / max).min(width)
751 } else {
752 0
753 };
754 let empty = width - filled;
755
756 let color = if count > max * 90 / 100 {
757 RED
758 } else if count > max * 75 / 100 {
759 YELLOW
760 } else {
761 GREEN
762 };
763
764 format!(
765 "{}{}{}{}",
766 color,
767 "\u{2588}".repeat(filled),
768 "\u{2591}".repeat(empty),
769 RESET
770 )
771}
772
773fn memory_status_word(count: usize, max: usize) -> &'static str {
774 if count > max * 90 / 100 {
775 "full"
776 } else if count > max * 75 / 100 {
777 "warning"
778 } else {
779 "ok"
780 }
781}
782
783pub fn format_bytes(bytes: u64) -> String {
784 if bytes < 1024 {
785 format!("{bytes} B")
786 } else if bytes < 1024 * 1024 {
787 format!("{:.1} KB", bytes as f64 / 1024.0)
788 } else {
789 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
790 }
791}
792
793fn format_age(time: std::time::SystemTime) -> String {
794 let elapsed = time.elapsed().unwrap_or_default();
795 let secs = elapsed.as_secs();
796
797 if secs < 60 {
798 "just now".to_string()
799 } else if secs < 3600 {
800 format!("{}m ago", secs / 60)
801 } else if secs < 86400 {
802 format!("{}h ago", secs / 3600)
803 } else {
804 format!("{}d ago", secs / 86400)
805 }
806}
807
808pub fn find_sections(lines: &[&str]) -> Vec<(String, usize, usize)> {
810 let mut sections = Vec::new();
811 let mut current_name = String::new();
812 let mut current_start = 0;
813
814 for (i, line) in lines.iter().enumerate() {
815 if line.starts_with("# ") || line.starts_with("## ") {
816 if !current_name.is_empty() {
817 sections.push((current_name.clone(), current_start, i - current_start));
818 }
819 current_name = line.trim_start_matches('#').trim().to_string();
820 current_start = i;
821 }
822 }
823 if !current_name.is_empty() {
824 sections.push((current_name, current_start, lines.len() - current_start));
825 }
826
827 sections
828}
829
830fn list_conversation_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
831 let mut files: Vec<PathBuf> = fs::read_dir(dir)
832 .map_err(|e| format!("Failed to read conversations dir: {e}"))?
833 .filter_map(|e| e.ok())
834 .map(|e| e.path())
835 .filter(|p| {
836 p.file_name()
837 .unwrap_or_default()
838 .to_string_lossy()
839 .starts_with("conversation-")
840 && p.extension().is_some_and(|ext| ext == "md")
841 })
842 .collect();
843
844 files.sort();
845 Ok(files)
846}
847
848fn analyze_non_section_issues(lines: &[&str]) -> Vec<String> {
849 let mut suggestions = Vec::new();
850
851 let mut seen: HashMap<String, usize> = HashMap::new();
852 let mut dup_count = 0;
853
854 for (i, line) in lines.iter().enumerate() {
855 let normalized: String = line
856 .to_lowercase()
857 .chars()
858 .filter(|c| c.is_alphanumeric() || c.is_whitespace())
859 .collect::<String>()
860 .split_whitespace()
861 .collect::<Vec<&str>>()
862 .join(" ");
863
864 if normalized.len() < 20 {
865 continue;
866 }
867
868 if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(normalized) {
869 e.insert(i);
870 } else {
871 dup_count += 1;
872 }
873 }
874
875 if dup_count > 0 {
876 suggestions.push(format!(
877 "{dup_count} potential duplicate entries found — consider merging"
878 ));
879 }
880
881 suggestions
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887
888 #[test]
889 fn find_sections_basic() {
890 let lines = vec![
891 "# Memory",
892 "",
893 "## Server",
894 "- host: vps",
895 "- os: linux",
896 "",
897 "## Projects",
898 "- project A",
899 ];
900 let sections = find_sections(&lines);
901 assert_eq!(sections.len(), 3);
902 assert_eq!(sections[0].0, "Memory");
903 assert_eq!(sections[1].0, "Server");
904 assert_eq!(sections[2].0, "Projects");
905 }
906
907 #[test]
908 fn memory_bar_colors() {
909 let bar = memory_bar(50, 200);
910 assert!(bar.contains(GREEN));
911
912 let bar = memory_bar(160, 200);
913 assert!(bar.contains(YELLOW));
914
915 let bar = memory_bar(190, 200);
916 assert!(bar.contains(RED));
917 }
918
919 #[test]
920 fn format_bytes_ranges() {
921 assert_eq!(format_bytes(500), "500 B");
922 assert_eq!(format_bytes(2048), "2.0 KB");
923 assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MB");
924 }
925
926 #[test]
927 fn health_healthy_state() {
928 let memory = MemoryStats {
929 line_count: 100,
930 sections: Vec::new(),
931 modified: None,
932 };
933 let archive = ArchiveStats {
934 count: 5,
935 total_bytes: 1000,
936 newest_modified: Some(std::time::SystemTime::now()),
937 };
938 let health = assess_health(&memory, &archive, 200);
939 assert!(matches!(health.level, HealthLevel::Healthy));
940 assert!(health.warnings.is_empty());
941 }
942
943 #[test]
944 fn health_alert_on_full_memory() {
945 let memory = MemoryStats {
946 line_count: 195,
947 sections: Vec::new(),
948 modified: None,
949 };
950 let archive = ArchiveStats {
951 count: 5,
952 total_bytes: 1000,
953 newest_modified: Some(std::time::SystemTime::now()),
954 };
955 let health = assess_health(&memory, &archive, 200);
956 assert!(matches!(health.level, HealthLevel::Alert));
957 }
958
959 #[test]
960 fn health_watch_on_no_archives() {
961 let memory = MemoryStats {
962 line_count: 50,
963 sections: Vec::new(),
964 modified: None,
965 };
966 let archive = ArchiveStats {
967 count: 0,
968 total_bytes: 0,
969 newest_modified: None,
970 };
971 let health = assess_health(&memory, &archive, 200);
972 assert!(matches!(health.level, HealthLevel::Watch));
973 }
974
975 #[test]
976 fn non_section_duplicates() {
977 let lines = vec![
978 "# Memory",
979 "",
980 "The server runs on Ubuntu Linux with SSH access",
981 "Some other content here that is long enough",
982 "The server runs on Ubuntu Linux with SSH access",
983 ];
984 let suggestions = analyze_non_section_issues(&lines);
985 assert_eq!(suggestions.len(), 1);
986 assert!(suggestions[0].contains("duplicate"));
987 }
988}