use super::next_cmd::Target;
use crate::guide;
use anyhow::Result;
use std::io::{BufRead, IsTerminal, Write};
fn exec(t: &Target, args: &[&str]) -> Result<()> {
let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
super::keel_cmd::run(&t.name, &args)
}
fn subject(decision: &str, slug: &str) -> String {
match decision {
"merge" => format!("merging {slug}"),
other => format!("the {other} for {slug}"),
}
}
fn confirm(reader: &mut impl BufRead, writer: &mut impl Write, question: &str) -> bool {
let _ = write!(writer, "{question} [y/N] ");
let _ = writer.flush();
let mut line = String::new();
if reader.read_line(&mut line).is_err() {
return false;
}
matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
fn pending<'a>(
t: &Target,
report: &'a guide::NextReport,
) -> Option<(&'a guide::NextSpec, &'static str)> {
let active = t.active(report);
let found = active
.as_ref()
.and_then(|a| guide::pending_decision(a.spec).map(|d| (a.spec, d)));
if found.is_none() {
if let Some(a) = &active {
println!(
"Nothing is waiting for your decision: {} is at {}.\n",
a.spec.slug,
guide::step_label(a.spec)
);
}
t.print_guidance(report);
}
found
}
pub fn approve(explicit: Option<String>, yes: bool) -> Result<()> {
let t = Target::resolve(explicit)?;
let report = t.report()?;
let Some((spec, decision)) = pending(&t, &report) else {
return Ok(());
};
if !yes && !std::io::stdin().is_terminal() {
let flag = t
.flag
.as_ref()
.map(|p| format!(" --project {p}"))
.unwrap_or_default();
anyhow::bail!(
"moor approve asks before recording, and there's no terminal to ask at.\n\n Next: moor approve{flag} --yes (approves {} without asking)",
subject(decision, &spec.slug)
);
}
match guide::artifact_to_read(spec) {
Some("report") => merge_review(&t, &spec.slug)?,
Some(artifact) => super::view::run(&t.name, &spec.slug, artifact)?,
None => {}
}
println!();
let question = format!("Approve {}?", subject(decision, &spec.slug));
if !yes
&& !confirm(
&mut std::io::stdin().lock(),
&mut std::io::stdout(),
&question,
)
{
println!("\nNot approved; nothing was recorded.\n");
t.print_guidance(&report);
return Ok(());
}
exec(&t, &["approve", &spec.slug, "--stage", decision])?;
println!();
t.print_guidance(&t.report()?);
Ok(())
}
fn merge_review(t: &Target, slug: &str) -> Result<()> {
let printable = |s: &str| s.chars().filter(|c| !c.is_control()).collect::<String>();
println!("What {slug}'s build changes (the pipeline's own records aside):\n");
let outside_keel = [".", ":(exclude).keel"];
let mut stat = vec!["git", "--no-pager", "diff", "--stat", "HEAD", "--"];
stat.extend(outside_keel);
let (_, out) = t.exec("approve", &stat)?;
for line in out.lines() {
println!(" {}", printable(line));
}
let mut new_files = vec!["git", "ls-files", "--others", "--exclude-standard", "--"];
new_files.extend(outside_keel);
let (_, out) = t.exec("approve", &new_files)?;
for line in out.lines().filter(|l| !l.trim().is_empty()) {
println!(" new file: {}", printable(line));
}
let (_, runs) = t.exec("approve", &["keel", "runs", "--json"])?;
let runs: serde_json::Value = serde_json::from_str(runs.trim()).unwrap_or_default();
if let Some(run) = newest_run(&runs, slug) {
println!("\nChecks on run {run}:");
for gate in ["G2", "G2.5"] {
let path = format!(".keel/runs/{run}/gates/{gate}.json");
let (ok, out) = t.exec("approve", &["cat", &path])?;
if ok {
println!(" {}", gate_summary(gate, &out));
}
}
}
println!("\n Full diff: moor view {slug} diff");
Ok(())
}
fn gate_summary(gate: &str, gate_json: &str) -> String {
let g: serde_json::Value = serde_json::from_str(gate_json.trim()).unwrap_or_default();
let count = |v: &str| {
g["checks"]
.as_array()
.map(|c| c.iter().filter(|c| c["verdict"] == v).count())
.unwrap_or(0)
};
let verdict = g["verdict"]
.as_str()
.filter(|v| matches!(*v, "pass" | "fail" | "blocked"))
.unwrap_or("unknown");
format!(
"{gate} {verdict} — {} passed, {} failed, {} blocked",
count("pass"),
count("fail"),
count("blocked")
)
}
pub fn reject(explicit: Option<String>, why: &str) -> Result<()> {
if why.trim().is_empty() {
anyhow::bail!("say why, so the next attempt can address it: moor reject \"why\"");
}
let t = Target::resolve(explicit)?;
let report = t.report()?;
if let Some(active) = t.active(&report) {
if let Some(decision) = guide::pending_decision(active.spec) {
exec(
&t,
&[
"approve",
&active.spec.slug,
"--stage",
decision,
"--reject",
"--note",
why,
],
)?;
println!();
t.print_guidance(&t.report()?);
return Ok(());
}
if active.spec.stage == "run" {
println!(
"{} is building, not at an approval gate — rejecting its approved plan to pull it back.\n",
active.spec.slug
);
exec(
&t,
&[
"approve",
&active.spec.slug,
"--stage",
"plan",
"--reject",
"--force",
"--note",
why,
],
)?;
println!();
t.print_guidance(&t.report()?);
return Ok(());
}
println!(
"Nothing is waiting for your decision: {} is at {}.\n",
active.spec.slug,
guide::step_label(active.spec)
);
t.print_guidance(&report);
}
Ok(())
}
fn abandon_argv<'a>(slug: &'a str, why: &'a str) -> Vec<&'a str> {
vec!["spec", "abandon", slug, "--reason", why]
}
pub fn drop(explicit: Option<String>, why: &str) -> Result<()> {
if why.trim().is_empty() {
anyhow::bail!("say why this spec is being dropped: moor drop \"why\"");
}
let t = Target::resolve(explicit)?;
let report = t.report()?;
let Some(active) = t.active(&report) else {
println!("Nothing to drop: no spec is in progress.\n");
t.print_guidance(&report);
return Ok(());
};
exec(&t, &abandon_argv(&active.spec.slug, why))?;
println!();
t.print_guidance(&t.report()?);
Ok(())
}
pub fn go(explicit: Option<String>, check: bool) -> Result<()> {
let t = Target::resolve(explicit)?;
let mut report = t.report()?;
let Some(slug) = t.active(&report).map(|a| a.spec.slug.clone()) else {
t.print_guidance(&report);
return Ok(());
};
let mut ran = 0;
let mut last_stage: Option<String> = None;
while let Some(spec) = report.specs.iter().find(|s| s.slug == slug) {
if !guide::is_automatic(spec) {
break;
}
if last_stage.as_deref() == Some(spec.stage.as_str()) {
println!("\n{slug} didn't move on from {}.", guide::step_label(spec));
break;
}
let rechecking = guide::recheck(spec).is_some();
let command = guide::recheck(spec).unwrap_or(&spec.command).to_string();
let Some(("keel", args)) = command.split_once(' ') else {
anyhow::bail!("don't know how to run `{command}`");
};
let mut args: Vec<String> = args.split_whitespace().map(String::from).collect();
let building = args.first().map(String::as_str) == Some("run");
if building && check {
args.push("--no-driver".to_string());
}
println!("==> {slug} · {}", guide::step_label(spec));
last_stage = Some(spec.stage.clone());
ran += 1;
let result = super::keel_cmd::run(&t.name, &args);
report = t.report()?;
if let Err(e) = result {
if building && build_passed(&t, &slug)? {
if rechecking {
println!("\n{RECHECKED}");
break;
}
continue;
}
println!();
if building {
anyhow::bail!("{}", guide::build_failed(&slug, t.flag.as_deref()));
}
t.print_guidance(&report);
return Err(e);
}
if rechecking {
println!("\n{RECHECKED}");
break;
}
}
if ran == 0 {
println!("Nothing to run: the next step needs you.\n");
} else {
println!();
}
t.print_guidance(&report);
Ok(())
}
const RECHECKED: &str =
"It passed its checks again. The earlier decision stays on record until you approve the revision.";
fn build_passed(t: &Target, slug: &str) -> Result<bool> {
let container = t.m.sandbox_container();
let (status, out) =
crate::proc::run_capture("docker", &["exec", &container, "keel", "runs", "--json"])?;
if !status.success() {
return Ok(false);
}
let runs: serde_json::Value = serde_json::from_str(out.trim()).unwrap_or_default();
let Some(id) = newest_run(&runs, slug) else {
return Ok(false);
};
let path = format!(".keel/runs/{id}/gates/G2.json");
let (status, out) = crate::proc::run_capture("docker", &["exec", &container, "cat", &path])?;
Ok(status.success() && gate_passed(&out))
}
pub(crate) fn newest_run(runs: &serde_json::Value, slug: &str) -> Option<String> {
runs["runs"]
.as_array()?
.iter()
.filter(|r| r["spec"] == slug)
.filter_map(|r| r["id"].as_str())
.next_back()
.filter(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit() || b == b'-'))
.map(str::to_string)
}
fn gate_passed(gate_json: &str) -> bool {
serde_json::from_str::<serde_json::Value>(gate_json.trim())
.is_ok_and(|g| g["verdict"] == "pass")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_an_explicit_yes_confirms() {
for (input, expected) in [
("y\n", true),
("YES\n", true),
("n\n", false),
("\n", false),
("sure\n", false),
] {
let mut out = Vec::new();
let got = confirm(&mut std::io::Cursor::new(input), &mut out, "Approve?");
assert_eq!(got, expected, "{input:?}");
assert!(String::from_utf8(out).unwrap().contains("Approve? [y/N]"));
}
}
#[test]
fn the_newest_run_for_the_spec_is_found() {
let runs = serde_json::json!({"runs": [
{"id": "2026-09-29-000", "spec": "greet"},
{"id": "2026-09-29-001", "spec": "other"},
{"id": "2026-09-29-002", "spec": "greet"},
{"id": "../../etc", "spec": "evil"},
]});
assert_eq!(
newest_run(&runs, "greet").as_deref(),
Some("2026-09-29-002")
);
assert_eq!(
newest_run(&runs, "evil"),
None,
"a path-shaped id is refused"
);
assert_eq!(newest_run(&runs, "missing"), None);
}
#[test]
fn only_a_pass_verdict_counts() {
assert!(gate_passed(r#"{"gate":"G2","verdict":"pass"}"#));
assert!(!gate_passed(r#"{"gate":"G2","verdict":"fail"}"#));
assert!(!gate_passed(r#"{"gate":"G2","verdict":"blocked"}"#));
assert!(!gate_passed("not json"));
}
#[test]
fn gate_summary_counts_checks() {
let json = r#"{"gate":"G2","verdict":"pass","checks":[{"verdict":"pass"},{"verdict":"pass"},{"verdict":"blocked"}]}"#;
assert_eq!(
gate_summary("G2", json),
"G2 pass — 2 passed, 0 failed, 1 blocked"
);
assert_eq!(
gate_summary("G2", "junk"),
"G2 unknown — 0 passed, 0 failed, 0 blocked"
);
}
#[test]
fn subject_reads_naturally() {
assert_eq!(subject("plan", "login"), "the plan for login");
assert_eq!(subject("merge", "login"), "merging login");
}
#[test]
fn drop_records_abandonment_with_a_reason() {
let argv = abandon_argv("login", "superseded by oauth");
assert_eq!(
argv,
[
"spec",
"abandon",
"login",
"--reason",
"superseded by oauth"
]
);
}
#[test]
fn drop_refuses_an_empty_reason() {
for blank in ["", " ", "\t\n"] {
let err = drop(None, blank).unwrap_err().to_string();
assert!(
err.contains("say why"),
"blank {blank:?} should be refused: {err}"
);
}
}
#[test]
fn drop_and_reject_send_distinct_keel_commands() {
let argv = abandon_argv("login", "why");
assert_eq!(argv[0..2], ["spec", "abandon"]);
assert!(
!argv.contains(&"approve"),
"drop must not use the approve verb"
);
assert!(
!argv.contains(&"--reject"),
"drop must not use the reject flag"
);
}
#[test]
fn drop_is_accepted_at_every_pre_merge_stage() {
let reference = abandon_argv("s", "r");
for slug in ["spec-stage", "plan-stage", "run-stage"] {
let argv = abandon_argv(slug, "r");
assert_eq!(
argv.len(),
reference.len(),
"stage must not change the command shape"
);
assert_eq!(argv[0..2], ["spec", "abandon"]);
assert!(
!argv.contains(&"--force"),
"drop needs no --force: {argv:?}"
);
}
}
}