proposals.rs
Rust · 157 lines · 7.6 KB
//! Runbook proposals: list, show, accept, reject.
use super::{check_links, synced_index};
use crate::cli::ProposalCmd;
use crate::store::{self, git_out, render, split_frontmatter, Mind};
use crate::ui;
use anyhow::{anyhow, bail, Result};
use std::fs;
use std::path::PathBuf;
pub fn run(mind: &Mind, cmd: Option<ProposalCmd>, me: &str) -> Result<()> {
let dir = mind.home.join("proposals");
let path_of = |pid: &str| -> Result<(PathBuf, String)> {
let (target, _) = pid.rsplit_once("--").ok_or_else(|| anyhow!("bad proposal id {pid:?}"))?;
if !store::valid_id(target) || !pid.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
bail!("bad proposal id {pid:?}");
}
let p = dir.join(format!("{pid}.md"));
if !p.exists() {
bail!("no proposal {pid:?}");
}
if fs::symlink_metadata(&p).is_ok_and(|m| m.file_type().is_symlink()) {
bail!("proposal {pid:?} is a symbolic link; proposals must be plain files in the mind");
}
Ok((p, target.to_string()))
};
match cmd {
None => {
let mut any = false;
for e in fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = e.path();
if p.extension().is_some_and(|x| x == "md") {
let pid = p.file_stem().unwrap().to_string_lossy().to_string();
let title = fs::read_to_string(&p)
.ok()
.and_then(|t| split_frontmatter(&t).ok())
.map(|(m, _)| m.title)
.unwrap_or_default();
let author = git_out(&mind.home, &["log", "-1", "--format=%an", "--", &format!("proposals/{pid}.md")]);
let (pid, title, author) = (store::strip_controls(&pid), store::strip_controls(&title), store::strip_controls(author.trim()));
if ui::rich() {
if !any {
println!("{}\n", ui::wordmark("proposals"));
}
ui::card(&[ui::bold(&title), format!("{} {}", ui::dim("by"), ui::project(&author))], &pid);
println!();
} else {
println!("{pid}\n {title} (by {author})");
}
any = true;
}
}
if !any {
println!("{}", ui::status(true, "No pending proposals."));
} else if ui::rich() {
println!("{}", ui::say("Review with `mind proposals show <id>`, then accept (--yes) or reject."));
}
}
Some(ProposalCmd::Show { id }) => {
let (p, target) = path_of(&id)?;
let cur = mind.entries_dir().join(format!("{target}.md"));
// Never through a link: a pulled `entries/<id>.md` symlink would print a file from outside.
if fs::symlink_metadata(&cur).is_ok_and(|m| m.file_type().is_symlink()) {
bail!("entry {target:?} is a symbolic link; entries must be plain files in the mind");
}
let empty = if cfg!(windows) { "NUL" } else { "/dev/null" };
let base = if cur.exists() { cur.display().to_string() } else { empty.into() };
let out = std::process::Command::new("git")
.args(["-c", "core.fsmonitor=false", "-c", "diff.external=", "-c", "core.pager=cat"])
.args(["diff", "--no-index", "--no-color", "--no-ext-diff", "--no-textconv", &base])
.arg(&p)
.output()?;
let diff = store::strip_controls(&String::from_utf8_lossy(&out.stdout));
if !ui::rich() {
print!("{diff}");
} else {
println!("{} {}\n", ui::wordmark("proposal"), ui::muted(&id));
// File headers (`diff`, `index`, `---`, `+++`) come only before a file's first hunk;
// inside a hunk, `---…` is a removed line (a frontmatter `---`, say) and shows red.
let mut header = true;
for l in diff.lines() {
if l.starts_with("diff ") {
header = true;
} else if l.starts_with("@@") {
header = false;
}
let shown = if header {
ui::muted(l)
} else if l.starts_with("@@") {
ui::rgb(ui::PURPLE, "35", l)
} else if l.starts_with('+') {
ui::rgb((127, 214, 164), "32", l)
} else if l.starts_with('-') {
ui::rgb((255, 128, 112), "31", l)
} else {
ui::soft(l)
};
println!(" {shown}");
}
}
}
Some(ProposalCmd::Accept { id, yes }) => {
let (p, target) = path_of(&id)?;
if !yes {
bail!("review it first with `mind proposals show {id}`, then accept with --yes");
}
let text = fs::read_to_string(&p)?;
let (mut meta, body) = split_frontmatter(&text)?;
let base = meta.extra.remove("base_revision").and_then(|b| b.as_str().map(str::to_string));
mind.validate(&meta, &body)?;
if let Err(e) = mind.stamp_refs(&mut meta) {
// A retirement must go through even if the procedure's files are gone.
if !meta.status.trim().eq_ignore_ascii_case("retired") {
return Err(e);
}
}
let _lock = mind.lock()?;
check_links(mind, &meta, &target)?;
let dest = mind.entry_path(&target)?;
// Under the lock: if the entry exists, the proposal must say which version it changes and
// that must still be the live one (a new-runbook proposal says none, so it never replaces
// an entry that took its id; an older change never erases a newer one).
if dest.exists() {
let cur = fs::read_to_string(&dest).unwrap_or_default();
if base.as_deref() != Some(store::revision(&cur).as_str()) {
bail!(
"{target} changed since this proposal was made (or it was made for a new entry), so accepting it would \
replace what is live now. Reject it and ask for it again against the current version \
(`mind proposals show {id}` shows the difference)"
);
}
}
let before = fs::read(&dest).ok();
store::write_atomic(&dest, render(&meta, &body))?;
if let Err(e) = mind.commit(&[&dest], &format!("accept proposal {id}"), me) {
let _ = match before {
Some(b) => store::write_atomic(&dest, b),
None => fs::remove_file(&dest),
};
return Err(e.context("not accepted (the commit failed; the entry was put back as it was)"));
}
fs::remove_file(&p)?;
mind.commit(&[&p], &format!("accept proposal {id}: remove the proposal"), me)?;
synced_index(mind)?;
println!("{}", ui::status(true, &format!("Accepted: {target} is live.")));
}
Some(ProposalCmd::Reject { id, reason }) => {
let (p, _) = path_of(&id)?;
let _lock = mind.lock()?;
fs::remove_file(&p)?;
mind.commit(&[&p], &format!("reject proposal {id}: {reason}"), me)?;
println!("{}", ui::status(true, &format!("Rejected {id}.")));
}
}
Ok(())
}