use crate::approval::{self, Standing};
use crate::gate::{self, GateResult, Verdict};
use crate::paths::Paths;
use crate::plan::Plan;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Stage {
Spec,
SpecApproval,
Plan,
PlanGate,
PlanApproval,
Run,
MergeApproval,
Complete,
}
impl Stage {
pub fn label(&self) -> &'static str {
match self {
Self::Spec => "spec",
Self::SpecApproval => "approve spec",
Self::Plan => "plan",
Self::PlanGate => "G1",
Self::PlanApproval => "approve plan",
Self::Run => "run",
Self::MergeApproval => "approve merge",
Self::Complete => "done",
}
}
pub fn key(&self) -> &'static str {
match self {
Self::Spec => "spec",
Self::SpecApproval => "spec_approval",
Self::Plan => "plan",
Self::PlanGate => "plan_gate",
Self::PlanApproval => "plan_approval",
Self::Run => "run",
Self::MergeApproval => "merge_approval",
Self::Complete => "complete",
}
}
}
#[derive(Debug, Clone)]
pub struct Position {
pub stage: Stage,
pub g0: Option<GateResult>,
pub g1: Option<GateResult>,
pub g1_stale: bool,
pub spec_approval: Standing,
pub plan_approval: Standing,
pub merge_approval: Standing,
pub passing_run: Option<String>,
}
fn standing_or_absent(paths: &Paths, slug: &str, stage: &str) -> Standing {
approval::standing(paths, slug, stage).unwrap_or(Standing::Absent)
}
fn passed(g: &Option<GateResult>) -> bool {
matches!(g, Some(r) if r.verdict == Verdict::Pass)
}
pub fn position(paths: &Paths, slug: &str) -> Position {
let g0 = gate::previous(paths, slug, "G0");
let spec_approval = standing_or_absent(paths, slug, "spec");
let has_plan = Plan::load(paths, slug).is_ok();
let g1 = gate::previous(paths, slug, "G1");
let g1_stale = match (&g1, &spec_approval) {
(Some(r), Standing::Current { at, .. }) => r.verdict == Verdict::Pass && r.generated_at < *at,
_ => false,
};
let plan_approval = standing_or_absent(paths, slug, "plan");
let merge_approval = standing_or_absent(paths, slug, "merge");
let passing_run = passing_run(paths, slug);
let stage = derive_stage(
&g0,
&spec_approval,
has_plan,
&g1,
g1_stale,
&plan_approval,
&merge_approval,
passing_run.is_some(),
);
Position {
stage,
g0,
g1,
g1_stale,
spec_approval,
plan_approval,
merge_approval,
passing_run,
}
}
pub fn stage(paths: &Paths, slug: &str) -> Stage {
position(paths, slug).stage
}
#[allow(clippy::too_many_arguments)]
fn derive_stage(
g0: &Option<GateResult>,
spec_approval: &Standing,
has_plan: bool,
g1: &Option<GateResult>,
g1_stale: bool,
plan_approval: &Standing,
merge_approval: &Standing,
has_passing_run: bool,
) -> Stage {
if !passed(g0) {
return Stage::Spec;
}
if !matches!(spec_approval, Standing::Current { .. }) {
return Stage::SpecApproval;
}
if !has_plan {
return Stage::Plan;
}
if !passed(g1) || g1_stale {
return Stage::PlanGate;
}
if !matches!(plan_approval, Standing::Current { .. }) {
return Stage::PlanApproval;
}
if !has_passing_run {
return Stage::Run;
}
match merge_approval {
Standing::Superseded { .. } => Stage::Run,
Standing::Current { .. } => Stage::Complete,
_ => Stage::MergeApproval,
}
}
fn passing_run(paths: &Paths, slug: &str) -> Option<String> {
let ids = crate::run::list(paths).ok()?;
ids.into_iter().rev().find(|id| {
let Ok(run) = crate::run::Run::load(paths, id) else { return false };
if run.meta.spec != slug {
return false;
}
run.gate_results()
.map(|rs| rs.iter().any(|r| r.gate == "G2" && r.verdict == Verdict::Pass))
.unwrap_or(false)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn declaration_order_is_pipeline_order() {
let spine = [
Stage::Spec,
Stage::SpecApproval,
Stage::Plan,
Stage::PlanGate,
Stage::PlanApproval,
Stage::Run,
Stage::MergeApproval,
Stage::Complete,
];
for pair in spine.windows(2) {
assert!(pair[0] < pair[1], "{:?} should precede {:?}", pair[0], pair[1]);
}
}
#[test]
fn a_stale_g1_reopens_the_gate_even_though_it_passed() {
let g1 = GateResult {
schema: "keel.gate/1".into(),
gate: "G1".into(),
run: "r".into(),
spec: None,
verdict: Verdict::Pass,
generated_at: "2026-01-01T00:00:00+00:00".into(),
checks: vec![],
};
let g0 = GateResult { gate: "G0".into(), ..g1.clone() };
let approved = Standing::Current { by: "x".into(), at: "2026-06-01T00:00:00+00:00".into() };
let s = derive_stage(
&Some(g0),
&approved,
true,
&Some(g1),
true,
&approved,
&Standing::Absent,
true,
);
assert_eq!(s, Stage::PlanGate);
}
#[test]
fn a_superseded_merge_returns_to_the_work() {
let pass = GateResult {
schema: "keel.gate/1".into(),
gate: "G0".into(),
run: "r".into(),
spec: None,
verdict: Verdict::Pass,
generated_at: "2026-01-01T00:00:00+00:00".into(),
checks: vec![],
};
let approved = Standing::Current { by: "x".into(), at: "2026-01-01T00:00:00+00:00".into() };
let superseded = Standing::Superseded {
approved_hash: "aaa".into(),
current_hash: "bbb".into(),
};
let s = derive_stage(
&Some(pass.clone()),
&approved,
true,
&Some(pass),
false,
&approved,
&superseded,
true,
);
assert_eq!(s, Stage::Run);
}
}