mod.rs
Rust · 230 lines · 9.7 KB
//! What each subcommand does, one file per group; `run` dispatches.
mod find;
mod projects;
mod proposals;
mod upkeep;
mod write;
use crate::cli::{Cli, Cmd, ProjectCmd};
use crate::index::Index;
use crate::store::{render, slugify, today, Mind, Meta};
use crate::{links, maintain, report, setup, upgrade};
use anyhow::{bail, Context, Result};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
fn read_input(file: &Option<PathBuf>) -> Result<String> {
match file {
Some(p) => fs::read_to_string(p).with_context(|| format!("reading {}", p.display())),
None => {
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
if s.trim().is_empty() {
bail!("no entry on stdin (pipe markdown in, or use --file). See `mind template <kind>`.");
}
Ok(s)
}
}
}
fn cwd_project(mind: &Mind) -> Option<String> {
mind.project_for_dir(&std::env::current_dir().ok()?)
}
fn who_am_i(mind: &Mind, flag: &Option<String>) -> String {
flag.clone()
.or_else(|| crate::store::env_setting(&["MIND_AGENT"]).map(|s| s.to_string_lossy().into_owned()))
.or_else(|| cwd_project(mind).map(|p| format!("{p}-agent")))
.unwrap_or_else(|| "unknown".into())
}
pub(crate) fn synced_index(mind: &Mind) -> Result<Index> {
match synced_saved_index(mind) {
// Search must still work where the index can't be written (Codex's sandbox blocks writes
// outside the project): the saved index read-only, else a keyword index built in memory.
Err(e) if crate::index::is_unwritable(&e) => {
if let Some(ix) = Index::open_readonly(mind) {
eprintln!(
"note: the search index can't be updated here (a sandbox or read-only folder?), so this uses it \
as last saved; the newest changes may be missing"
);
return Ok(ix);
}
let mut ix = Index::open_memory(mind)?;
ix.sync(mind)?;
eprintln!("note: the search index can't be written here (a sandbox or read-only folder?), so this searches by keyword only");
Ok(ix)
}
r => r,
}
}
fn synced_saved_index(mind: &Mind) -> Result<Index> {
let mut ix = Index::open(mind)?;
match ix.sync(mind) {
// Damage that only shows once pages are read: the index is a cache, so rebuild it once.
Err(e) if crate::index::is_corrupt(&e) => {
drop(ix);
eprintln!("warning: the search index was damaged ({e:#}); rebuilding it from the entries");
for f in ["index.db", "index.db-wal", "index.db-shm"] {
let _ = fs::remove_file(mind.home.join(f));
}
let mut ix = Index::open(mind)?;
ix.sync(mind)?;
Ok(ix)
}
Err(e) => Err(e),
Ok(_) => Ok(ix),
}
}
fn owner_of(m: &Meta) -> &str {
if m.owner.is_empty() { &m.project } else { &m.owner }
}
/// A fresh id for a new entry: not taken by an entry, nor by a pending proposal for a new runbook
/// (`proposals/<id>--<stamp>.md`), which would otherwise replace this entry when accepted.
pub(crate) fn new_id(mind: &Mind, title: &str, dir: &str) -> PathBuf {
let base = format!("{}-{}", today(), slugify(title));
let proposed: Vec<String> = fs::read_dir(mind.home.join("proposals"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| e.file_name().to_string_lossy().rsplit_once("--").map(|(id, _)| id.to_string()))
.collect();
let taken = |p: &Path| {
let id = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
p.exists() || mind.home.join("entries").join(p.file_name().unwrap_or_default()).exists() || proposed.contains(&id)
};
let mut p = mind.home.join(dir).join(format!("{base}.md"));
let mut n = 2;
while taken(&p) {
p = mind.home.join(dir).join(format!("{base}-{n}.md"));
n += 1;
}
p
}
fn propose(mind: &Mind, target: &str, text: &str, author: &str, what: &str) -> Result<String> {
let stamp = chrono::Local::now().format("%Y%m%d%H%M%S");
fs::create_dir_all(mind.home.join("proposals"))?;
// A new file every time: two proposals for one runbook in the same second get `-2`, `-3`, …
// instead of the second replacing the first.
let mut n = 1;
let (pid, path) = loop {
let pid = if n == 1 { format!("{target}--{stamp}") } else { format!("{target}--{stamp}-{n}") };
let path = mind.home.join("proposals").join(format!("{pid}.md"));
match fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut f) => {
use std::io::Write;
f.write_all(text.as_bytes())?;
break (pid, path);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => n += 1,
Err(e) => return Err(e.into()),
}
};
commit_or_restore(mind, &path, None, &format!("proposal ({what}): {target}"), author)?;
Ok(pid)
}
fn save_entry(mind: &Mind, id: &str, meta: &Meta, body: &str, msg: &str, author: &str) -> Result<()> {
let path = mind.entry_path(id)?;
let before = fs::read(&path).ok();
crate::store::write_atomic(&path, render(meta, body))?;
commit_or_restore(mind, &path, before, msg, author)
}
/// Commit `path`; if the commit fails, put the file back as it was (`before`, or no file), so the
/// files never get ahead of the history (a later `git reset` would silently drop them).
pub(crate) fn commit_or_restore(mind: &Mind, path: &Path, before: Option<Vec<u8>>, msg: &str, author: &str) -> Result<()> {
let Err(e) = mind.commit(&[path], msg, author) else { return Ok(()) };
let restored = match &before {
Some(b) => crate::store::write_atomic(path, b),
None => fs::remove_file(path).or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { Ok(()) } else { Err(e) }),
};
Err(match restored {
Ok(()) => e.context("not saved (the commit failed, so the file was put back as it was)"),
Err(r) => e.context(format!("the commit failed and {} couldn't be put back ({r})", path.display())),
})
}
pub fn run(cli: Cli) -> Result<()> {
let cmd = cli.cmd.expect("checked in main");
if let Cmd::Init = cmd {
let home = Mind::init()?;
println!("{}", crate::ui::status(true, &format!("Mega Mind ready at {}", home.display())));
return Ok(());
}
if let Cmd::Setup(args) = cmd {
return setup::run(args);
}
if let Cmd::Upgrade(args) = cmd {
return upgrade::run(args);
}
let mind = &Mind::open()?;
let me = &who_am_i(mind, &cli.agent);
match cmd {
Cmd::Init | Cmd::Setup(_) | Cmd::Upgrade(_) | Cmd::Hook { .. } => unreachable!(),
Cmd::Doctor => report::doctor(mind),
Cmd::Stats => report::stats(mind),
Cmd::Digest { since } => report::digest(mind, &since),
Cmd::Search { query, f, limit, deep, brief, json } => find::search(mind, query, f, limit, deep, brief, json),
Cmd::Get { id, raw, notes } => find::get(mind, id, raw, notes),
Cmd::Related { id } => find::related(mind, id),
Cmd::List { f, limit, json } => find::list(mind, f, limit, json),
Cmd::Record { file, new } => write::record(mind, me, file, new),
Cmd::Update { id, file } => write::update_cmd(mind, me, id, file),
Cmd::Edit { id } => write::edit(mind, &cli.agent, id),
Cmd::Ack { id } => write::ack(mind, me, id),
Cmd::Rm { id, reason, yes } => write::rm(mind, me, id, reason, yes),
Cmd::Retire { id, why, by } => write::retire(mind, me, id, why, by),
Cmd::Maintain { limit, no_librarian } => maintain::run(mind, limit, no_librarian),
Cmd::Stale { project, all } => upkeep::stale(mind, project, all),
Cmd::Review { project, limit, force } => upkeep::review(mind, project, limit, force),
Cmd::Check => upkeep::check(mind),
Cmd::Log { n } => upkeep::log(mind, n),
Cmd::Reindex => upkeep::reindex(mind),
Cmd::Who { project } => projects::who(mind, project),
Cmd::Uses { term } => projects::uses(mind, term),
Cmd::Projects => projects::projects(mind),
Cmd::Project { cmd: ProjectCmd::Add { name, path, description, stack, session } } => {
projects::project_add(mind, me, name, path, description, stack, session)
}
Cmd::Template { kind } => projects::template(mind, kind),
Cmd::Kinds => projects::kinds(mind),
Cmd::Proposals { cmd } => proposals::run(mind, cmd, me),
}
}
/// Links in a new or changed entry must point at entries that exist (and not at itself).
fn check_links(mind: &Mind, meta: &Meta, own_id: &str) -> Result<()> {
check_links_from(mind, meta, own_id, "")
}
/// As `check_links`; `prev_supersedes` is the entry's current value, which isn't re-checked
/// for loops when unchanged (so a loop can always be broken by retiring or editing either side).
fn check_links_from(mind: &Mind, meta: &Meta, own_id: &str, prev_supersedes: &str) -> Result<()> {
let graph = links::Graph::build(mind);
let missing = graph.missing_targets(meta);
if !missing.is_empty() {
bail!("these linked entries don't exist: {} (ids are as in `mind list`)", missing.join(", "));
}
if !own_id.is_empty() && links::outgoing(meta).iter().any(|(_, t)| t == own_id) {
bail!("an entry can't link to itself");
}
let sup = meta.supersedes.trim();
if !own_id.is_empty() && !sup.is_empty() && sup != prev_supersedes.trim() && graph.would_loop(own_id, sup) {
bail!("{own_id} superseding {sup} would make a loop ({sup} already replaces {own_id}, directly or through others)");
}
Ok(())
}