Skip to main content

recall_echo/
dashboard.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::error::RecallError;
10use crate::RecallEcho;
11
12// ── ANSI colors ─────────────────────────────────────────────────────────
13
14const 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
29// ── Public data types ───────────────────────────────────────────────────
30
31/// Memory health assessment.
32pub 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
54/// Stats about MEMORY.md.
55pub 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
102/// A parsed ephemeral session entry for display.
103pub 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
111/// Stats about the conversation archive.
112pub 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
170// ── Dashboard rendering ─────────────────────────────────────────────────
171
172/// Render the neofetch-style memory dashboard to stdout.
173pub 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    // Logo + metadata side by side
180    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    // Print remaining metadata if logo ran out of lines
215    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    // Memory Health
223    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    // Warnings
251    for warning in &health.warnings {
252        println!("  {YELLOW}!{RESET} {warning}");
253    }
254
255    // Recent Sessions
256    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    // Memory Sections
274    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
300// ── Search ──────────────────────────────────────────────────────────────
301
302/// Line-level search across conversation archives.
303pub 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
362/// Ranked file-level search across conversation archives.
363pub 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
449// ── Auto-distill ────────────────────────────────────────────────────────
450
451/// Analyze MEMORY.md and auto-extract heavy sections into topic files.
452pub 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    // Find heavy sections
481    let sections = find_sections(&lines);
482    let mut extractions: Vec<(String, usize, PathBuf)> = Vec::new();
483
484    for (name, start, size) in &sections {
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    // Rewrite MEMORY.md with references
522    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    // Report
560    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// ── Health assessment ───────────────────────────────────────────────────
580
581#[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// ── Helpers ─────────────────────────────────────────────────────────────
626
627#[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            // Extract date from "## Session <id> — <date>"
652            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            // Extract duration from "**Duration**: ~<dur>"
660            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            // Extract message count from "**Messages**: <n>" or "(N messages)" format
671            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            // Extract summary from "**Summary**: <text>" or topic bullet points
696            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                    // Fall back to topic bullet points
710                    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/// Find sections: returns (name, start_line, line_count)
813#[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}