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(())
}