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, ¬e));
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, ¬e));
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(())
}