Mega Mind v0.5.4

proposals.rs

Rust · 157 lines · 7.6 KB
Symbols
//! 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(())
}