use std::fs;
use std::io::Write as _;
use std::path::Path;
use std::process::Command;
use anyhow::{Context as _, Result, bail, ensure};
use similar::{Algorithm, udiff::unified_diff};
use crate::context::read_policy;
use crate::exit::Code;
use crate::home::{Home, write_private};
use crate::integrity::{self, HookPins};
use crate::render::Deferred;
const FALLBACK_EDITOR: &str = if cfg!(windows) { "notepad" } else { "vi" };
pub fn run() -> Result<Code> {
crate::terminal::refuse_agent("moat edit")?;
if !crate::terminal::interactive() {
bail!("`moat edit` must be run by a person in a terminal, not from a hook or script");
}
let home = Home::locate()?;
let path = home.policy_path();
if !path.is_file() {
bail!("no policy at {}; run `moat init`", path.display());
}
integrity::refuse_drift(&home, "edit the policy")?;
let before = read_policy(&path)?;
let draft = home.root().join("policy.edit.yaml");
write_private(&draft, before.as_bytes())?;
let result = edit(&home, &draft, &before);
let _ = fs::remove_file(&draft);
result
}
fn edit(home: &Home, draft: &Path, before: &str) -> Result<Code> {
let mut out = Deferred::default();
loop {
open_editor(draft)?;
let after = read_policy(draft)?;
if after == before {
writeln!(out, "no changes; the policy was not touched")?;
break;
}
let policy = match home.load_policy_at(draft) {
Ok(policy) => policy,
Err(error) => {
writeln!(out, "the edited policy does not lint:\n {error:#}")?;
if confirm(&mut out, "Edit again? [y/N] ")? {
continue;
}
bail!("the policy was not changed");
}
};
for warning in openmoat_core::lint::warnings(&policy) {
writeln!(out, "warning: {warning}")?;
}
let diff = unified_diff(
Algorithm::Myers,
before,
&after,
3,
Some(("policy.yaml (installed)", "policy.yaml (edited)")),
);
write!(out, "{diff}")?;
if !confirm(&mut out, "Apply? [y/N] ")? {
writeln!(out, "the policy was not changed")?;
break;
}
apply(home, before, &after, &mut out)?;
break;
}
out.finish()?;
Ok(Code::Ok)
}
pub(super) fn apply(home: &Home, before: &str, after: &str, out: &mut Deferred) -> Result<()> {
integrity::refuse_drift(home, "apply the edit")?;
let path = home.policy_path();
let backup = home.root().join("policy.yaml.bak");
write_private(&backup, before.as_bytes())?;
write_private(&path, after.as_bytes())?;
let binary = crate::install::hook_binary()?;
let lock = integrity::repin(home, &binary, HookPins::Keep)?;
writeln!(out, "✔ {} updated", path.display())?;
writeln!(out, "✔ lock re-pinned ({} files)", lock.entries.len())?;
writeln!(
out,
" undo: the previous policy is in {}; copy it back and run `moat doctor --accept`",
backup.display()
)?;
Ok(())
}
fn open_editor(draft: &Path) -> Result<()> {
let editor = ["VISUAL", "EDITOR"]
.into_iter()
.find_map(|var| std::env::var(var).ok().filter(|v| !v.trim().is_empty()))
.unwrap_or_else(|| FALLBACK_EDITOR.to_owned());
let mut words = editor.split_whitespace();
let program = words.next().context("the editor command is empty")?;
let status = Command::new(program)
.args(words)
.arg(draft)
.status()
.with_context(|| format!("starting the editor `{editor}`; set $VISUAL or $EDITOR"))?;
ensure!(
status.success(),
"the editor `{editor}` exited with {status}; the policy was not changed"
);
Ok(())
}
pub(super) fn confirm(out: &mut Deferred, question: &str) -> Result<bool> {
Ok(matches!(answer(out, question)?.as_str(), "y" | "yes"))
}
pub(super) fn answer(out: &mut Deferred, question: &str) -> Result<String> {
write!(out, "{question}")?;
out.flush()?;
let answer = crate::terminal::read_answer().context("reading the answer")?;
Ok(answer.trim().to_ascii_lowercase())
}