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_key(|entry| std::cmp::Reverse(entry.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 = (count * width).checked_div(max).map_or(0, |f| f.min(width));
753 let empty = width - filled;
754
755 let color = if count > max * 90 / 100 {
756 RED
757 } else if count > max * 75 / 100 {
758 YELLOW
759 } else {
760 GREEN
761 };
762
763 format!(
764 "{}{}{}{}",
765 color,
766 "\u{2588}".repeat(filled),
767 "\u{2591}".repeat(empty),
768 RESET
769 )
770}
771
772fn memory_status_word(count: usize, max: usize) -> &'static str {
773 if count > max * 90 / 100 {
774 "full"
775 } else if count > max * 75 / 100 {
776 "warning"
777 } else {
778 "ok"
779 }
780}
781
782#[must_use]
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
808#[must_use]
810pub fn find_sections(lines: &[&str]) -> Vec<(String, usize, usize)> {
811 let mut sections = Vec::new();
812 let mut current_name = String::new();
813 let mut current_start = 0;
814
815 for (i, line) in lines.iter().enumerate() {
816 if line.starts_with("# ") || line.starts_with("## ") {
817 if !current_name.is_empty() {
818 sections.push((current_name.clone(), current_start, i - current_start));
819 }
820 current_name = line.trim_start_matches('#').trim().to_string();
821 current_start = i;
822 }
823 }
824 if !current_name.is_empty() {
825 sections.push((current_name, current_start, lines.len() - current_start));
826 }
827
828 sections
829}
830
831fn list_conversation_files(dir: &Path) -> Result<Vec<PathBuf>, RecallError> {
832 let mut files: Vec<PathBuf> = fs::read_dir(dir)?
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}