Skip to main content

recall_echo/
dashboard.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::error::RecallError;
6use crate::RecallEcho;
7
8// ── ANSI colors ─────────────────────────────────────────────────────────
9
10const 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
25// ── Public data types ───────────────────────────────────────────────────
26
27/// Memory health assessment.
28pub 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
50/// Stats about MEMORY.md.
51pub 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
98/// A parsed ephemeral session entry for display.
99pub 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
107/// Stats about the conversation archive.
108pub 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
166// ── Dashboard rendering ─────────────────────────────────────────────────
167
168/// Render the neofetch-style memory dashboard to stdout.
169pub 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    // Logo + metadata side by side
176    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    // Print remaining metadata if logo ran out of lines
211    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    // Memory Health
219    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    // Warnings
247    for warning in &health.warnings {
248        println!("  {YELLOW}!{RESET} {warning}");
249    }
250
251    // Recent Sessions
252    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    // Memory Sections
270    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
296// ── Search ──────────────────────────────────────────────────────────────
297
298/// Line-level search across conversation archives.
299pub 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
358/// Ranked file-level search across conversation archives.
359pub 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
445// ── Auto-distill ────────────────────────────────────────────────────────
446
447/// Analyze MEMORY.md and auto-extract heavy sections into topic files.
448pub 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    // Find heavy sections
477    let sections = find_sections(&lines);
478    let mut extractions: Vec<(String, usize, PathBuf)> = Vec::new();
479
480    for (name, start, size) in &sections {
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    // Rewrite MEMORY.md with references
518    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    // Report
556    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// ── Health assessment ───────────────────────────────────────────────────
576
577#[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// ── Helpers ─────────────────────────────────────────────────────────────
622
623#[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            // Extract date from "## Session <id> — <date>"
648            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            // Extract duration from "**Duration**: ~<dur>"
656            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            // Extract message count from "**Messages**: <n>" or "(N messages)" format
667            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            // Extract summary from "**Summary**: <text>" or topic bullet points
692            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                    // Fall back to topic bullet points
706                    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/// Find sections: returns (name, start_line, line_count)
809#[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}