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