Mega Mind v0.5.4

write.rs

Rust · 259 lines · 11.0 KB
//! Changing the mind: record, update, edit, ack, rm, retire.

use super::{check_links, check_links_from, cwd_project, new_id, owner_of, propose, read_input, save_entry, synced_index};
use crate::index::Index;
use crate::{links, ui};
use crate::store::{render, split_frontmatter, today, Mind, Entry, Meta};
use anyhow::{anyhow, bail, Result};
use std::fs;
use std::path::PathBuf;


/// Near-identical entries of the same kind (a decision and the system it shaped are not duplicates).
fn find_duplicates(mind: &Mind, ix: &mut Index, meta: &Meta, body: &str, exclude: &str) -> Result<Vec<(String, String, String, f32)>> {
    let graph = links::Graph::build(mind);
    let v = match ix.embed(&[Index::doc_text(&meta.title, &meta.tags.join(", "), body)]) {
        Ok(mut v) => v.remove(0),
        Err(e) => {
            eprintln!("warning: embeddings unavailable ({e:#}); skipping the duplicate check");
            return Ok(vec![]);
        }
    };
    Ok(ix
        .nearest(&v, exclude, usize::MAX)?
        .into_iter()
        .filter(|x| x.3 >= mind.config.embeddings.thresholds().0)
        .filter(|x| !graph.is_retired(&x.0) && mind.load(&x.0).is_ok_and(|e| e.meta.kind == meta.kind))
        .take(3)
        .collect())
}

fn update(mind: &Mind, old: &Entry, text: &str, me: &str) -> Result<()> {
    let (mut meta, body) = split_frontmatter(text)?;
    // Edited from `mind get --raw`: refuse if the entry changed since that copy was taken.
    if let Some(rev) = meta.extra.remove("revision") {
        let rev = rev.as_str().map(str::to_string).unwrap_or_else(|| format!("{rev:?}"));
        if rev.trim() != crate::store::revision(&old.raw) {
            bail!(
                "{} changed since you copied it (another agent updated it). Get it again (`mind get {} --raw`), \
                 redo your edit on that, then update",
                old.id,
                old.id
            );
        }
    }
    meta.kind = meta.kind.trim().to_lowercase();
    mind.validate(&meta, &body)?;
    if let Err(e) = mind.stamp_refs(&mut meta) {
        // Retiring an entry whose files are gone must still work: keep its last fingerprints.
        if meta.status.trim().eq_ignore_ascii_case("retired") {
            meta.ref_state = old.meta.ref_state.clone();
        } else {
            return Err(e);
        }
    }
    meta.created = old.meta.created.clone();
    meta.author = old.meta.author.clone();
    meta.updated = today();
    let _lock = mind.lock()?;
    check_links_from(mind, &meta, &old.id, &old.meta.supersedes)?;
    // Someone else saved this entry since it was read: don't silently overwrite their change.
    if mind.load(&old.id).map(|cur| cur.raw != old.raw).unwrap_or(false) {
        bail!("{} changed while you were editing it; read it again (`mind get {} --raw`) and reapply your change", old.id, old.id);
    }
    if (old.meta.kind == "runbook" || meta.kind == "runbook") && mind.config.identity.review_runbooks {
        // The version this change was made against: accepting it later must not erase newer changes.
        let mut meta = meta.clone();
        meta.extra.insert("base_revision".into(), serde_yaml::Value::String(crate::store::revision(&old.raw)));
        let pid = propose(mind, &old.id, &render(&meta, &body), me, "runbook change")?;
        let note = format!(
            "Saved as proposal {pid}. Runbook changes are reviewed before they go live; \
             {} will accept or reject it.",
            mind.config.identity.reviewer
        );
        println!("{}", ui::status(true, &note));
        return Ok(());
    }
    let mine = cwd_project(mind);
    let cross = mine.as_deref().is_some_and(|p| p != owner_of(&old.meta));
    let msg = if cross {
        format!("update {} [cross-project edit by {me}]", old.id)
    } else {
        format!("update {}", old.id)
    };
    save_entry(mind, &old.id, &meta, &body, &msg, me)?;
    synced_index(mind)?;
    if cross {
        println!("{}", ui::status(true, &format!("Updated {} (owned by {}; your edit is marked cross-project in the log).", old.id, owner_of(&old.meta))));
    } else {
        println!("{}", ui::status(true, &format!("Updated {}", old.id)));
    }
    Ok(())
}

pub fn record(mind: &Mind, me: &str, file: Option<PathBuf>, new: bool) -> Result<()> {
    let text = read_input(&file)?;
    let (mut meta, body) = split_frontmatter(&text)?;
    meta.kind = meta.kind.trim().to_lowercase();
    if meta.project.is_empty() {
        meta.project = cwd_project(mind).unwrap_or_default();
    }
    mind.validate(&meta, &body)?;
    mind.stamp_refs(&mut meta)?;
    let _lock = mind.lock()?;
    check_links(mind, &meta, "")?;
    let mut ix = synced_index(mind)?;
    if !new {
        let dups = find_duplicates(mind, &mut ix, &meta, &body, "")?;
        if !dups.is_empty() {
            let list: Vec<String> = dups
                .iter()
                .map(|(id, t, p, s)| format!("  {:.0}% similar  {t}\n               {p} · {id}", s * 100.0))
                .collect();
            bail!(
                "this looks like an existing entry:\n{}\nUpdate it instead (`mind get <id> --raw`, edit, \
                 `mind update <id>`). If yours is really about something different, re-run with --new.",
                list.join("\n")
            );
        }
    }
    meta.author = me.to_string();
    meta.created = today();
    meta.updated = today();
    let path = new_id(mind, &meta.title, "entries");
    let id = path.file_stem().unwrap().to_string_lossy().to_string();
    if meta.kind == "runbook" && mind.config.identity.review_runbooks {
        let pid = propose(mind, &id, &render(&meta, &body), me, "new runbook")?;
        let note = format!(
            "Saved as proposal {pid}. Runbooks are reviewed before they go live; \
             {} will accept or reject it.",
            mind.config.identity.reviewer
        );
        println!("{}", ui::status(true, &note));
        return Ok(());
    }
    save_entry(mind, &id, &meta, &body, &format!("{}: {}", meta.project, meta.title), me)?;
    ix.sync(mind)?;
    if ui::rich() {
        println!(" {} {}  {}", ui::rgb(ui::GOLD, "33", "◆"), ui::gradient("Recorded", ui::PURPLE, ui::GOLD), ui::muted(&id));
    } else {
        println!("Recorded as {id}");
    }
    Ok(())
}

pub fn update_cmd(mind: &Mind, me: &str, id: String, file: Option<PathBuf>) -> Result<()> {
    let old = mind.load(&id)?;
    let text = read_input(&file)?;
    update(mind, &old, &text, me)?;
    Ok(())
}

pub fn edit(mind: &Mind, agent: &Option<String>, id: String) -> Result<()> {
    let old = mind.load(&id)?;
    // A private scratch folder in the mind (git-ignored), not a guessable name in shared /tmp.
    let dir = mind.home.join(".edit");
    fs::create_dir_all(&dir)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
    }
    let tmp = dir.join(format!("{id}-{}.md", std::process::id()));
    fs::write(&tmp, &old.raw)?;
    let editor = std::env::var("VISUAL")
        .or_else(|_| std::env::var("EDITOR"))
        .ok()
        .filter(|e| !e.trim().is_empty())
        .unwrap_or_else(|| {
            // No $EDITOR: notepad on Windows; nano, else vi (minimal Linux images have no nano).
            if cfg!(windows) {
                "notepad".into()
            } else if crate::setup::which_path("nano").is_some() || crate::setup::which_path("vi").is_none() {
                "nano".into()
            } else {
                "vi".into()
            }
        });
    // $EDITOR may carry arguments (`code --wait`), so run it through the shell like git does.
    let status = if cfg!(windows) {
        std::process::Command::new("cmd").arg("/C").arg(format!("{editor} \"{}\"", tmp.display())).status()?
    } else {
        std::process::Command::new("sh").arg("-c").arg(format!("{editor} \"$1\"")).arg("sh").arg(&tmp).status()?
    };
    if !status.success() {
        let _ = fs::remove_file(&tmp);
        bail!("{editor} exited with an error (or isn't installed); nothing saved. Set $EDITOR to the editor you use");
    }
    let text = fs::read_to_string(&tmp)?;
    if text == old.raw {
        let _ = fs::remove_file(&tmp);
        println!("{}", ui::status(true, "No changes."));
        return Ok(());
    }
    let who = agent.clone().unwrap_or_else(|| mind.config.identity.user.to_lowercase());
    // Your edited text stays in the scratch file until it is saved: a typo in the frontmatter or a
    // conflicting update must not throw it away.
    if let Err(e) = update(mind, &old, &text, &who) {
        return Err(e.context(format!(
            "not saved; your edited text is kept in {} (fix it, then `mind update {id} --file <that file>`)",
            tmp.display()
        )));
    }
    let _ = fs::remove_file(&tmp);
    Ok(())
}

pub fn ack(mind: &Mind, me: &str, id: String) -> Result<()> {
    let _lock = mind.lock()?;
    // Loaded under the lock: a copy read before it could undo an `update` that saved meanwhile.
    let mut e = mind.load(&id)?;
    mind.stamp_refs(&mut e.meta)?;
    e.meta.extra.insert("checked".into(), serde_yaml::Value::String(today()));
    save_entry(mind, &id, &e.meta, &e.body, &format!("ack (checked, still accurate): {id}"), me)?;
    let _ = fs::remove_file(mind.home.join("reviews").join(format!("{id}.json")));
    println!("{}", ui::status(true, &format!("Marked {id} as checked against the current files.")));
    Ok(())
}

pub fn rm(mind: &Mind, me: &str, id: String, reason: String, yes: bool) -> Result<()> {
    let e = mind.load(&id)?;
    if e.meta.kind == "runbook" && !yes {
        bail!("{id} is a runbook; deleting one needs --yes (and should be agreed with {})", mind.config.identity.user);
    }
    let _lock = mind.lock()?;
    let graph = links::Graph::build(mind);
    if let Some(b) = graph.backlinks.get(&id).filter(|b| !b.is_empty() && !yes) {
        bail!(
            "{} other entr{} to {id}:\n{}\nConsider `mind retire {id} --why …` instead (it stays readable); \
             to delete anyway, add --yes",
            b.len(),
            if b.len() == 1 { "y links" } else { "ies link" },
            b.iter().map(|x| format!("  {x}")).collect::<Vec<_>>().join("\n")
        );
    }
    let path = mind.entry_path(&id)?;
    let before = fs::read(&path).ok();
    fs::remove_file(&path)?;
    // If the commit fails the entry is put back, rather than gone from the tool but not from git.
    super::commit_or_restore(mind, &path, before, &format!("delete {id}: {reason}"), me)?;
    synced_index(mind)?;
    println!("{}", ui::status(true, &format!("Deleted {id} (still in git history: `mind log`).")));
    Ok(())
}

pub fn retire(mind: &Mind, me: &str, id: String, why: String, by: Option<String>) -> Result<()> {
    let old = mind.load(&id)?;
    if why.trim().is_empty() {
        bail!("say why it no longer applies (--why)");
    }
    let mut meta = old.meta.clone();
    meta.status = "retired".into();
    meta.retired_why = why.trim().to_string();
    if let Some(b) = by {
        mind.load(&b).map_err(|_| anyhow!("no entry {b:?} to point to (--by)"))?;
        meta.replaced_by = b;
    }
    update(mind, &old, &render(&meta, &old.body), me)?;
    Ok(())
}