1use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::error::RecallError;
10use crate::RecallEcho;
11
12const GREEN: &str = "\x1b[32m";
15const YELLOW: &str = "\x1b[33m";
16const RED: &str = "\x1b[31m";
17const CYAN: &str = "\x1b[36m";
18const DIM: &str = "\x1b[2m";
19const BOLD: &str = "\x1b[1m";
20const RESET: &str = "\x1b[0m";
21
22const LOGO: &str = r#"
23╦═╗╔═╗╔═╗╔═╗╦ ╦
24╠╦╝║╣ ║ ╠═╣║ ║
25╩╚═╚═╝╚═╝╩ ╩╩═╝╩═╝"#;
26
27const SEPARATOR: &str = " ──────────────────────────────────────────────────────────────";
28
29pub enum HealthLevel {
33 Healthy,
34 Watch,
35 Alert,
36}
37
38pub struct HealthAssessment {
39 pub level: HealthLevel,
40 pub warnings: Vec<String>,
41}
42
43impl HealthAssessment {
44 #[must_use]
45 pub fn display(&self) -> String {
46 match self.level {
47 HealthLevel::Healthy => format!("{GREEN}HEALTHY{RESET}"),
48 HealthLevel::Watch => format!("{YELLOW}WATCH{RESET}"),
49 HealthLevel::Alert => format!("{RED}ALERT{RESET}"),
50 }
51 }
52}
53
54pub struct MemoryStats {
56 pub line_count: usize,
57 pub sections: Vec<(String, usize)>,
58 pub modified: Option<std::time::SystemTime>,
59}
60
61impl MemoryStats {
62 #[must_use]
63 pub fn collect(recall: &RecallEcho) -> Self {
64 let memory_path = recall.memory_file();
65 if !memory_path.exists() {
66 return Self {
67 line_count: 0,
68 sections: Vec::new(),
69 modified: None,
70 };
71 }
72
73 let content = fs::read_to_string(&memory_path).unwrap_or_default();
74 let lines: Vec<&str> = content.lines().collect();
75 let line_count = lines.len();
76
77 let sections: Vec<(String, usize)> = find_sections(&lines)
78 .into_iter()
79 .map(|(name, _, size)| (name, size))
80 .collect();
81
82 let modified = fs::metadata(&memory_path)
83 .ok()
84 .and_then(|m| m.modified().ok());
85
86 Self {
87 line_count,
88 sections,
89 modified,
90 }
91 }
92
93 #[must_use]
94 pub fn freshness_display(&self) -> String {
95 match self.modified {
96 Some(time) => format_age(time),
97 None => "unknown".to_string(),
98 }
99 }
100}
101
102pub struct EphemeralEntry {
104 pub log_num: String,
105 pub age_display: String,
106 pub duration: String,
107 pub message_count: String,
108 pub topics: String,
109}
110
111pub struct ArchiveStats {
113 pub count: usize,
114 pub total_bytes: u64,
115 pub newest_modified: Option<std::time::SystemTime>,
116}
117
118impl ArchiveStats {
119 #[must_use]
120 pub fn collect(recall: &RecallEcho) -> Self {
121 let conv_dir = recall.conversations_dir();
122 if !conv_dir.exists() {
123 return Self {
124 count: 0,
125 total_bytes: 0,
126 newest_modified: None,
127 };
128 }
129
130 let entries: Vec<_> = fs::read_dir(&conv_dir)
131 .into_iter()
132 .flatten()
133 .filter_map(|e| e.ok())
134 .filter(|e| e.file_name().to_string_lossy().starts_with("conversation-"))
135 .collect();
136
137 let count = entries.len();
138 let mut total_bytes = 0u64;
139 let mut newest: Option<std::time::SystemTime> = None;
140
141 for entry in &entries {
142 if let Ok(meta) = entry.metadata() {
143 total_bytes += meta.len();
144 if let Ok(modified) = meta.modified() {
145 newest = Some(match newest {
146 Some(prev) if modified > prev => modified,
147 Some(prev) => prev,
148 None => modified,
149 });
150 }
151 }
152 }
153
154 Self {
155 count,
156 total_bytes,
157 newest_modified: newest,
158 }
159 }
160
161 #[must_use]
162 pub fn freshness_display(&self) -> String {
163 match self.newest_modified {
164 Some(time) => format_age(time),
165 None => "no archives".to_string(),
166 }
167 }
168}
169
170pub fn render(recall: &RecallEcho, entity_name: &str, version: &str, max_memory_lines: usize) {
174 let memory_stats = MemoryStats::collect(recall);
175 let ephemeral_entries = parse_ephemeral_entries(recall);
176 let archive_stats = ArchiveStats::collect(recall);
177 let health = assess_health(&memory_stats, &archive_stats, max_memory_lines);
178
179 let logo_lines: Vec<&str> = LOGO.lines().skip(1).collect();
181 let meta_lines = [
182 format!("entity {CYAN}{entity_name}{RESET}"),
183 format!(
184 "memory {}/{} {} {}",
185 memory_stats.line_count,
186 max_memory_lines,
187 memory_bar(memory_stats.line_count, max_memory_lines),
188 memory_status_word(memory_stats.line_count, max_memory_lines),
189 ),
190 format!("sessions {}/5 entries", ephemeral_entries.len()),
191 format!(
192 "archive {} conversations ({})",
193 archive_stats.count,
194 format_bytes(archive_stats.total_bytes),
195 ),
196 format!("freshness {}", archive_stats.freshness_display()),
197 ];
198
199 println!();
200 let logo_width = 26;
201 for (i, logo_line) in logo_lines.iter().enumerate() {
202 if i < meta_lines.len() {
203 println!(
204 " {GREEN}{:<width$}{RESET} {}",
205 logo_line,
206 meta_lines[i],
207 width = logo_width,
208 );
209 } else {
210 println!(" {GREEN}{logo_line}{RESET}");
211 }
212 }
213
214 for meta_line in meta_lines.iter().skip(logo_lines.len()) {
216 println!(" {:<width$} {}", "", meta_line, width = logo_width);
217 }
218
219 println!(" v{version}");
220 println!("{SEPARATOR}");
221
222 println!();
224 println!(
225 " {BOLD}Memory Health{RESET} {}",
226 health.display()
227 );
228 println!();
229
230 println!(
231 " {:<14} {} {:<8} {}",
232 "curated",
233 memory_bar(memory_stats.line_count, max_memory_lines),
234 format!("{}/{}", memory_stats.line_count, max_memory_lines),
235 memory_status_word(memory_stats.line_count, max_memory_lines),
236 );
237 println!(
238 " {:<14} {} {:<8} ok",
239 "ephemeral",
240 memory_bar(ephemeral_entries.len(), 5),
241 format!("{}/5", ephemeral_entries.len()),
242 );
243 println!(
244 " {:<14} {} conversations {}",
245 "archive",
246 archive_stats.count,
247 format_bytes(archive_stats.total_bytes),
248 );
249
250 for warning in &health.warnings {
252 println!(" {YELLOW}!{RESET} {warning}");
253 }
254
255 if !ephemeral_entries.is_empty() {
257 println!();
258 println!(" {BOLD}Recent Sessions{RESET}");
259 println!();
260
261 for entry in ephemeral_entries.iter().rev() {
262 println!(
263 " {DIM}#{:<4}{RESET} {DIM}{:<8}{RESET} {:<5} {:<8} {}",
264 entry.log_num,
265 entry.age_display,
266 entry.duration,
267 format!("{} msgs", entry.message_count),
268 entry.topics,
269 );
270 }
271 }
272
273 if !memory_stats.sections.is_empty() {
275 println!();
276 println!(" {BOLD}Memory Sections{RESET}");
277 println!();
278 println!(
279 " {} sections · {} lines · last updated {}",
280 memory_stats.sections.len(),
281 memory_stats.line_count,
282 memory_stats.freshness_display(),
283 );
284
285 let mut sorted: Vec<_> = memory_stats.sections.iter().collect();
286 sorted.sort_by_key(|entry| std::cmp::Reverse(entry.1));
287 let top: Vec<String> = sorted
288 .iter()
289 .take(3)
290 .map(|(name, size)| format!("{name} ({size} lines)"))
291 .collect();
292 if !top.is_empty() {
293 println!(" {DIM}largest: {}{RESET}", top.join(", "));
294 }
295 }
296
297 println!();
298}
299
300pub fn search_lines(recall: &RecallEcho, query: &str) -> Result<(), RecallError> {
304 let conv_dir = recall.conversations_dir();
305 if !conv_dir.exists() {
306 println!(" No conversation archives found.");
307 return Ok(());
308 }
309
310 let files = list_conversation_files(&conv_dir)?;
311 if files.is_empty() {
312 println!(" No conversation archives found.");
313 return Ok(());
314 }
315
316 let query_lower = query.to_lowercase();
317 let mut total_matches = 0;
318
319 for file in &files {
320 let content = fs::read_to_string(file)?;
321 let filename = file.file_name().unwrap_or_default().to_string_lossy();
322 let mut file_matches = Vec::new();
323
324 for (i, line) in content.lines().enumerate() {
325 if line.to_lowercase().contains(&query_lower) {
326 file_matches.push((i + 1, line.to_string()));
327 }
328 }
329
330 if !file_matches.is_empty() {
331 println!("\n {CYAN}{filename}{RESET}");
332 for (line_num, line) in file_matches.iter().take(5) {
333 let display = if line.len() > 100 {
334 format!("{}...", &line[..97])
335 } else {
336 line.to_string()
337 };
338 println!(" {DIM}{line_num:>4}{RESET} {display}");
339 }
340 if file_matches.len() > 5 {
341 println!(
342 " {DIM} ...and {} more matches{RESET}",
343 file_matches.len() - 5
344 );
345 }
346 total_matches += file_matches.len();
347 }
348 }
349
350 if total_matches == 0 {
351 println!(" No matches for \"{query}\"");
352 } else {
353 println!(
354 "\n {DIM}{total_matches} matches across {} files{RESET}",
355 files.len()
356 );
357 }
358
359 Ok(())
360}
361
362pub fn search_ranked(recall: &RecallEcho, query: &str) -> Result<(), RecallError> {
364 let conv_dir = recall.conversations_dir();
365 if !conv_dir.exists() {
366 println!(" No conversation archives found.");
367 return Ok(());
368 }
369
370 let files = list_conversation_files(&conv_dir)?;
371 if files.is_empty() {
372 println!(" No conversation archives found.");
373 return Ok(());
374 }
375
376 let query_lower = query.to_lowercase();
377 let query_words: Vec<&str> = query_lower.split_whitespace().collect();
378 let mut scored: Vec<(f64, &PathBuf, Vec<String>)> = Vec::new();
379
380 for (idx, file) in files.iter().enumerate() {
381 let content = fs::read_to_string(file)?;
382 let content_lower = content.to_lowercase();
383
384 let match_count = content_lower.matches(&query_lower).count();
385 if match_count == 0 {
386 continue;
387 }
388
389 let words_found = query_words
390 .iter()
391 .filter(|w| content_lower.contains(**w))
392 .count();
393 let word_ratio = words_found as f64 / query_words.len().max(1) as f64;
394
395 let recency = (idx as f64 + 1.0) / files.len() as f64;
396
397 let content_boost = if content_lower.contains(&format!("### user\n\n{query_lower}")) {
398 1.5
399 } else {
400 1.0
401 };
402
403 let score = (match_count as f64 * word_ratio + recency) * content_boost;
404
405 let previews: Vec<String> = content
406 .lines()
407 .filter(|l| {
408 let lower = l.to_lowercase();
409 lower.contains(&query_lower) && !l.starts_with('#') && !l.starts_with("---")
410 })
411 .take(3)
412 .map(|l| {
413 if l.len() > 90 {
414 format!("{}...", &l[..87])
415 } else {
416 l.to_string()
417 }
418 })
419 .collect();
420
421 scored.push((score, file, previews));
422 }
423
424 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
425
426 if scored.is_empty() {
427 println!(" No matches for \"{query}\"");
428 return Ok(());
429 }
430
431 println!();
432 println!(
433 " {BOLD}Search Results{RESET} ({} files matched)\n",
434 scored.len()
435 );
436
437 for (score, file, previews) in scored.iter().take(10) {
438 let filename = file.file_name().unwrap_or_default().to_string_lossy();
439 println!(" {CYAN}{filename}{RESET} {DIM}(score: {score:.1}){RESET}");
440 for preview in previews {
441 println!(" {DIM}{preview}{RESET}");
442 }
443 }
444
445 println!();
446 Ok(())
447}
448
449pub fn auto_distill(recall: &RecallEcho, max_lines: usize) -> Result<(), RecallError> {
453 let memory_path = recall.memory_file();
454 let memory_dir = recall.memory_dir();
455
456 if !memory_path.exists() {
457 println!(" MEMORY.md not found. Nothing to distill.");
458 return Ok(());
459 }
460
461 let content = fs::read_to_string(&memory_path)?;
462 let lines: Vec<&str> = content.lines().collect();
463 let line_count = lines.len();
464
465 println!();
466 if line_count > (max_lines * 85 / 100) {
467 println!(
468 " {YELLOW}!{RESET} MEMORY.md at {line_count}/{max_lines} lines ({}%) — cleanup recommended",
469 line_count * 100 / max_lines,
470 );
471 } else {
472 println!(
473 " MEMORY.md at {line_count}/{max_lines} lines ({}%) — {GREEN}healthy{RESET}",
474 line_count * 100 / max_lines,
475 );
476 println!();
477 return Ok(());
478 }
479
480 let sections = find_sections(&lines);
482 let mut extractions: Vec<(String, usize, PathBuf)> = Vec::new();
483
484 for (name, start, size) in §ions {
485 if *size <= 30 {
486 continue;
487 }
488
489 let slug: String = name
490 .to_lowercase()
491 .chars()
492 .map(|c| if c.is_alphanumeric() { c } else { '-' })
493 .collect();
494 let slug = slug.trim_matches('-').to_string();
495 let topic_path = memory_dir.join(format!("{slug}.md"));
496
497 let section_lines: Vec<&str> = lines[*start..*start + *size].to_vec();
498 let section_content = section_lines.join("\n");
499
500 fs::write(&topic_path, format!("{section_content}\n"))?;
501
502 extractions.push((name.clone(), *size, topic_path));
503 }
504
505 if extractions.is_empty() {
506 let suggestions = analyze_non_section_issues(&lines);
507 if suggestions.is_empty() {
508 println!(" {DIM}No large sections to extract. Consider manual review.{RESET}");
509 } else {
510 println!();
511 println!(" {BOLD}Suggestions{RESET}");
512 println!();
513 for (i, s) in suggestions.iter().enumerate() {
514 println!(" {}. {s}", i + 1);
515 }
516 }
517 println!();
518 return Ok(());
519 }
520
521 let mut new_lines: Vec<String> = Vec::new();
523 let mut skip_until_next_section = false;
524
525 for (i, line) in lines.iter().enumerate() {
526 let is_extracted = extractions.iter().find(|(name, _, _)| {
527 sections
528 .iter()
529 .any(|(sname, start, _)| sname == name && *start == i)
530 });
531
532 if let Some(extraction) = is_extracted {
533 new_lines.push(line.to_string());
534 let rel_path = extraction
535 .2
536 .file_name()
537 .unwrap_or_default()
538 .to_string_lossy();
539 new_lines.push(format!("See memory/{rel_path} for details."));
540 new_lines.push(String::new());
541 skip_until_next_section = true;
542 continue;
543 }
544
545 if skip_until_next_section {
546 if (line.starts_with("# ") || line.starts_with("## ")) && i > 0 {
547 skip_until_next_section = false;
548 new_lines.push(line.to_string());
549 }
550 continue;
551 }
552
553 new_lines.push(line.to_string());
554 }
555
556 let new_content = new_lines.join("\n");
557 fs::write(&memory_path, format!("{new_content}\n"))?;
558
559 println!();
561 println!(" {BOLD}Extracted{RESET}");
562 println!();
563 for (name, size, path) in &extractions {
564 let rel = path.file_name().unwrap_or_default().to_string_lossy();
565 println!(" {GREEN}→{RESET} {name} ({size} lines) → memory/{rel}");
566 }
567
568 let new_line_count = new_content.lines().count();
569 println!();
570 println!(
571 " MEMORY.md: {line_count} → {new_line_count} lines ({}%)",
572 new_line_count * 100 / max_lines,
573 );
574 println!();
575
576 Ok(())
577}
578
579#[must_use]
582pub fn assess_health(
583 memory: &MemoryStats,
584 archive: &ArchiveStats,
585 max_memory_lines: usize,
586) -> HealthAssessment {
587 let mut warnings = Vec::new();
588 let mut level = HealthLevel::Healthy;
589
590 if memory.line_count > max_memory_lines * 90 / 100 {
591 warnings.push(format!(
592 "MEMORY.md at {}% — run distill",
593 memory.line_count * 100 / max_memory_lines,
594 ));
595 level = HealthLevel::Alert;
596 } else if memory.line_count > max_memory_lines * 75 / 100 {
597 warnings.push(format!(
598 "MEMORY.md approaching limit ({}%)",
599 memory.line_count * 100 / max_memory_lines,
600 ));
601 level = HealthLevel::Watch;
602 }
603
604 if archive.count == 0 {
605 warnings.push("No conversation archives yet".to_string());
606 if !matches!(level, HealthLevel::Alert) {
607 level = HealthLevel::Watch;
608 }
609 }
610
611 if let Some(newest) = archive.newest_modified {
612 if let Ok(elapsed) = newest.elapsed() {
613 if elapsed.as_secs() > 7 * 86400 {
614 warnings.push("Last archive is over 7 days old".to_string());
615 if !matches!(level, HealthLevel::Alert) {
616 level = HealthLevel::Watch;
617 }
618 }
619 }
620 }
621
622 HealthAssessment { level, warnings }
623}
624
625#[must_use]
628pub fn parse_ephemeral_entries(recall: &RecallEcho) -> Vec<EphemeralEntry> {
629 let ephemeral_path = recall.ephemeral_file();
630 if !ephemeral_path.exists() {
631 return Vec::new();
632 }
633
634 let content = match fs::read_to_string(&ephemeral_path) {
635 Ok(c) => c,
636 Err(_) => return Vec::new(),
637 };
638
639 let raw_entries: Vec<&str> = content
640 .split("\n---\n")
641 .map(|e| e.trim())
642 .filter(|e| !e.is_empty())
643 .collect();
644
645 raw_entries
646 .iter()
647 .enumerate()
648 .map(|(i, entry)| {
649 let first_line = entry.lines().next().unwrap_or("");
650
651 let date_str = first_line
653 .split('—')
654 .nth(1)
655 .or_else(|| first_line.split(" - ").nth(1))
656 .unwrap_or("")
657 .trim();
658
659 let duration = entry
661 .lines()
662 .find(|l| l.contains("**Duration**"))
663 .and_then(|l| {
664 l.split("~")
665 .nth(1)
666 .and_then(|s| s.split('|').next().map(|d| d.trim().to_string()))
667 })
668 .unwrap_or_else(|| "\u{2014}".to_string());
669
670 let msg_count = entry
672 .lines()
673 .find(|l| l.contains("**Messages**"))
674 .and_then(|l| {
675 l.split("**Messages**:").nth(1).and_then(|s| {
676 s.trim()
677 .split(|c: char| !c.is_ascii_digit())
678 .next()
679 .and_then(|n| n.parse::<u32>().ok())
680 })
681 })
682 .or_else(|| {
683 entry
684 .lines()
685 .find(|l| l.contains("messages"))
686 .and_then(|l| {
687 l.split('(')
688 .nth(1)
689 .and_then(|s| s.split_whitespace().next())
690 .and_then(|n| n.parse::<u32>().ok())
691 })
692 })
693 .unwrap_or(0);
694
695 let summary = entry
697 .lines()
698 .find(|l| l.contains("**Summary**"))
699 .and_then(|l| l.split("**Summary**:").nth(1))
700 .map(|s| {
701 let trimmed = s.trim();
702 if trimmed.len() > 50 {
703 format!("{}...", &trimmed[..47])
704 } else {
705 trimmed.to_string()
706 }
707 })
708 .unwrap_or_else(|| {
709 let topics: Vec<&str> = entry
711 .lines()
712 .filter(|l| l.starts_with("- ") && !l.contains("...and"))
713 .take(3)
714 .map(|l| l.trim_start_matches("- "))
715 .collect();
716
717 if topics.is_empty() {
718 "\u{2014}".to_string()
719 } else {
720 let joined: String = topics
721 .iter()
722 .map(|t| {
723 if t.len() > 30 {
724 format!("{}...", &t[..27])
725 } else {
726 t.to_string()
727 }
728 })
729 .collect::<Vec<_>>()
730 .join(", ");
731 if joined.len() > 60 {
732 format!("{}...", &joined[..57])
733 } else {
734 joined
735 }
736 }
737 });
738
739 EphemeralEntry {
740 log_num: format!("{}", i + 1),
741 age_display: if date_str.is_empty() {
742 "\u{2014}".to_string()
743 } else {
744 date_str.chars().take(16).collect()
745 },
746 duration,
747 message_count: msg_count.to_string(),
748 topics: summary,
749 }
750 })
751 .collect()
752}
753
754fn memory_bar(count: usize, max: usize) -> String {
755 let width = 10;
756 let filled = (count * width).checked_div(max).map_or(0, |f| f.min(width));
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}