Skip to main content

recall_echo/
dashboard.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::RecallEcho;
6
7// ── ANSI colors ─────────────────────────────────────────────────────────
8
9const 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
24// ── Public data types ───────────────────────────────────────────────────
25
26/// Memory health assessment.
27pub 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
48/// Stats about MEMORY.md.
49pub 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
94/// A parsed ephemeral session entry for display.
95pub 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
103/// Stats about the conversation archive.
104pub 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
160// ── Dashboard rendering ─────────────────────────────────────────────────
161
162/// Render the neofetch-style memory dashboard to stdout.
163pub 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    // Logo + metadata side by side
170    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    // Print remaining metadata if logo ran out of lines
205    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    // Memory Health
213    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    // Warnings
241    for warning in &health.warnings {
242        println!("  {YELLOW}!{RESET} {warning}");
243    }
244
245    // Recent Sessions
246    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    // Memory Sections
264    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
290// ── Search ──────────────────────────────────────────────────────────────
291
292/// Line-level search across conversation archives.
293pub 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
353/// Ranked file-level search across conversation archives.
354pub 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
441// ── Auto-distill ────────────────────────────────────────────────────────
442
443/// Analyze MEMORY.md and auto-extract heavy sections into topic files.
444pub 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    // Find heavy sections
474    let sections = find_sections(&lines);
475    let mut extractions: Vec<(String, usize, PathBuf)> = Vec::new();
476
477    for (name, start, size) in &sections {
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    // Rewrite MEMORY.md with references
516    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    // Report
555    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
574// ── Health assessment ───────────────────────────────────────────────────
575
576pub 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
619// ── Helpers ─────────────────────────────────────────────────────────────
620
621pub 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            // Extract date from "## Session <id> — <date>"
645            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            // Extract duration from "**Duration**: ~<dur>"
653            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            // Extract message count from "**Messages**: <n>" or "(N messages)" format
664            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            // Extract summary from "**Summary**: <text>" or topic bullet points
689            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                    // Fall back to topic bullet points
703                    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
808/// Find sections: returns (name, start_line, line_count)
809pub 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}