Mega Mind v0.5.4

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