Mega Mind v0.5.4

upkeep.rs

Rust · 223 lines · 8.8 KB
//! Keeping entries true: stale, review, check, reindex, log.

use super::{cwd_project, owner_of, synced_index};
use crate::index::Index;
use crate::librarian::{self, Librarian};
use crate::store::{git_out, Mind, Entry};
use crate::{links, ui};
use anyhow::Result;
use std::fs;


pub fn stale(mind: &Mind, project: Option<String>, all: bool) -> Result<()> {
    let project = if all { None } else { project.or_else(|| cwd_project(mind)) };
    let graph = links::Graph::build(mind);
    let rich = ui::rich();
    if rich {
        let scope = project.as_deref().map(|p| format!("project {p}")).unwrap_or_else(|| "all projects".into());
        println!("{}  {}\n", ui::wordmark("stale"), ui::muted(&scope));
    }
    let mut n = 0;
    for id in mind.all_ids()? {
        if graph.is_retired(&id) {
            continue;
        }
        let Ok(e) = mind.load(&id) else { continue };
        if project.as_ref().is_some_and(|p| owner_of(&e.meta) != p) {
            continue;
        }
        let stale = mind.stale_refs(&e.meta);
        if stale.is_empty() {
            continue;
        }
        n += 1;
        if rich {
            let mut lines = vec![ui::bold(&crate::store::strip_controls(&e.meta.title))];
            lines.push(format!("{} {}", ui::kind_badge(&e.meta.kind), ui::project(owner_of(&e.meta))));
            for (r, s) in &stale {
                lines.push(format!("{} {}  {}", ui::mark_warn(), ui::muted(r), ui::soft(s.label().trim_start_matches("⚠ "))));
            }
            if let Some(rv) = Librarian::current_review(mind, &e) {
                lines.push(ui::ai_note(&rv.verdict, &format!("note: `mind get {id} --notes`")));
            }
            ui::card(&lines, &id);
            println!();
            continue;
        }
        println!("{}", ui::bold(&e.meta.title));
        println!("  {} {} {}", ui::kind(&e.meta.kind), ui::dim("·"), ui::dim(&format!("{} · {id}", owner_of(&e.meta))));
        for (r, s) in stale {
            println!("  {} {r}: {}", ui::yellow("⚠"), s.label().trim_start_matches("⚠ "));
        }
        if let Some(rv) = Librarian::current_review(mind, &e) {
            // The verdict only: the note is model output, read deliberately with --notes.
            println!("  {}", ui::ai_note(&rv.verdict, &format!("note: `mind get {id} --notes`")));
        }
        println!();
    }
    if rich {
        match (n, &project) {
            (0, Some(p)) => println!("{}", ui::done(true, &format!("Nothing stale for {p}."))),
            (0, None) => println!("{}", ui::done(true, "Nothing stale.")),
            _ => println!("{}", ui::say(&format!("{n} to check. For each: read the changed files, then `mind update <id>` if the entry is now wrong, or `mind ack <id>` if it's still accurate."))),
        }
        return Ok(());
    }
    match (n, &project) {
        (0, Some(p)) => println!("{} Nothing stale for {p}.", ui::green("✓")),
        (0, None) => println!("{} Nothing stale.", ui::green("✓")),
        _ => println!(
            "For each: read the changed files, then `mind update <id>` if the entry is now wrong, \
             or `mind ack <id>` if it's still accurate."
        ),
    }
    Ok(())
}

pub fn review(mind: &Mind, project: Option<String>, limit: usize, force: bool) -> Result<()> {
    let lib = Librarian::new(mind);
    let graph = links::Graph::build(mind);
    let mut todo: Vec<Entry> = vec![];
    for id in mind.all_ids()? {
        if graph.is_retired(&id) {
            continue;
        }
        let Ok(e) = mind.load(&id) else { continue };
        if project.as_ref().is_some_and(|p| owner_of(&e.meta) != p) {
            continue;
        }
        if mind.stale_refs(&e.meta).is_empty() {
            continue;
        }
        if !force && Librarian::current_review(mind, &e).is_some() {
            continue;
        }
        todo.push(e);
    }
    todo.truncate(limit);
    if todo.is_empty() {
        if ui::rich() {
            println!("{}", ui::done(true, "Nothing to review."));
        } else {
            println!("Nothing to review.");
        }
        return Ok(());
    }
    // Starts LM Studio / loads the model only if needed; restored when `_lm` drops.
    let _lm = librarian::LmSession::start(mind, &lib, &mind.config.librarian.model)?;
    eprintln!("Reviewing {} stale entries with {} …", todo.len(), mind.config.librarian.model);
    for (id, r) in lib.review_all(todo) {
        match r {
            Ok(rv) => println!("{}\n  {}", ui::bold(&id), ui::ai_note(&rv.verdict, &rv.notes)),
            Err(e) => println!("{}\n  {} {e:#}", ui::bold(&id), ui::red("review failed:")),
        }
    }
    Ok(())
}

pub fn check(mind: &Mind) -> Result<()> {
    let rich = ui::rich();
    // One problem line: plain `✗ id: text`; rich ` ✗ id  text` with the id muted.
    let line = |bad: bool, id: &str, text: &str| {
        if rich {
            let mark = if bad { ui::mark_bad() } else { ui::mark_warn() };
            println!(" {mark} {}\n   {}", ui::muted(id), ui::soft(&ui::wrap(text, 3, 3, ui::width())));
        } else {
            let mark = if bad { ui::red("✗") } else { ui::yellow("⚠") };
            println!("{mark} {id}: {text}");
        }
    };
    if rich {
        println!("{}\n", ui::wordmark("check"));
    }
    let mut bad = 0;
    let check_graph = links::Graph::build(mind);
    for id in mind.all_ids()? {
        let res = mind.load(&id).and_then(|e| mind.validate(&e.meta, &e.body).map(|_| e));
        match res {
            Err(err) => {
                bad += 1;
                line(true, &id, &format!("{err:#}").replace('\n', "; "));
            }
            Ok(_) if check_graph.is_retired(&id) => {}
            Ok(e) => {
                for (r, s) in mind.stale_refs(&e.meta) {
                    line(false, &id, &format!("{r}: {}", s.label().trim_start_matches("⚠ ")));
                }
            }
        }
    }
    let graph = links::Graph::build(mind);
    for (from, field, to) in &graph.broken {
        bad += 1;
        line(true, from, &format!("{field} → {to}: no such entry (fix or remove the link)"));
    }
    for id in &graph.cycles {
        bad += 1;
        line(true, id, "part of a replacement loop (supersedes …); remove one `supersedes`");
    }
    // Brings the index up to date (or, where it can't be written, uses it as saved).
    synced_index(mind)?;
    let summary = if bad == 0 {
        "All entries valid.".to_string()
    } else {
        format!("{bad} entr{} need fixing (`mind edit <id>`).", if bad == 1 { "y" } else { "ies" })
    };
    if rich {
        println!("{}{}", if bad > 0 { "\n" } else { "" }, ui::done(bad == 0, &summary));
    } else if bad == 0 {
        println!("{} {summary}", ui::green("✓"));
    } else {
        println!("{} {summary}", ui::red("✗"));
    }
    Ok(())
}

pub fn log(mind: &Mind, n: usize) -> Result<()> {
    // Commits made with git directly (or pulled) can carry terminal escapes in author or subject.
    let out = crate::store::strip_controls(&git_out(&mind.home, &["log", &format!("-{n}"), "--date=format:%Y-%m-%d %H:%M", "--format=%ad  %<(22,trunc)%an  %s"]));
    if !ui::rich() {
        print!("{out}");
        return Ok(());
    }
    // Rich: a timeline. The date and author are fixed-width (git pads them), so split by column.
    println!("{}\n", ui::wordmark("log"));
    let rows: Vec<(&str, &str, &str)> = out
        .lines()
        .map(|l| {
            let (when, rest) = l.split_at(l.char_indices().nth(16).map_or(l.len(), |x| x.0));
            let rest = rest.trim_start();
            let (who, what) = rest.split_at(rest.char_indices().nth(22).map_or(rest.len(), |x| x.0));
            (when, who.trim_end(), what.trim())
        })
        .collect();
    let ww = rows.iter().map(|r| r.1.chars().count()).max().unwrap_or(0);
    let room = ui::width().saturating_sub(ww + 23).max(20);
    for (i, (when, who, what)) in rows.iter().enumerate() {
        let dot = if i + 1 == rows.len() { "◆" } else { "◇" };
        let long = what.chars().count() > room;
        let mut what: String = what.chars().take(room.saturating_sub(long as usize)).collect();
        if long {
            what.push('…');
        }
        println!(" {} {}  {}  {}", ui::rail(dot, i as f32 / rows.len().max(1) as f32), ui::muted(when), ui::project(&format!("{who:<ww$}")), ui::soft(&what));
    }
    Ok(())
}

pub fn reindex(mind: &Mind) -> Result<()> {
    let _ = fs::remove_file(mind.home.join("index.db"));
    let _ = fs::remove_file(mind.home.join("index.db-wal"));
    let _ = fs::remove_file(mind.home.join("index.db-shm"));
    let rebuilt = Index::open(mind).and_then(|mut ix| ix.sync(mind));
    let n = match rebuilt {
        Err(e) if crate::index::is_unwritable(&e) => anyhow::bail!("{}", crate::store::cant_write_help(&mind.home)),
        r => r?,
    };
    if ui::rich() {
        println!("{}", ui::done(true, &format!("{n} entries indexed.")));
    } else {
        println!("{n} entries indexed.");
    }
    Ok(())
}