use std::{fs, path::Path};
use anyhow::{anyhow, bail, Result};
use serde::Deserialize;
use crate::tui::stage::Stage;
use super::engine::{is_planning, next_stage};
use super::state::{self, Approval, Channel, DecisionKind, EngineStatus};
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Approvers {
#[serde(default)]
pub cli: Vec<String>,
#[serde(default)]
pub slack: Vec<String>,
}
impl Approvers {
fn list(&self, channel: Channel) -> &[String] {
match channel {
Channel::Cli => &self.cli,
Channel::Slack => &self.slack,
Channel::Automation => &[],
}
}
}
pub fn authorize(approvers: &Approvers, channel: Channel, author: &str) -> Result<()> {
let list = approvers.list(channel);
if list.is_empty() {
bail!("default-deny: nenhum aprovador configurado para o canal {channel:?}");
}
if !list.iter().any(|a| a == author) {
bail!("autor '{author}' não autorizado no canal {channel:?}");
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DecisionOutcome {
Advanced,
ReadyForExec,
Reopened,
}
#[allow(clippy::too_many_arguments)]
pub fn apply_decision(
root: &Path,
slug: &str,
gate: Stage,
decision: DecisionKind,
author: &str,
channel: Channel,
ts: &str,
reason: Option<String>,
approvers: &Approvers,
) -> Result<DecisionOutcome> {
authorize(approvers, channel, author)?;
apply_decision_recorded(root, slug, gate, decision, author, channel, ts, reason)
}
pub fn apply_automatic_acceptance(
root: &Path,
slug: &str,
gate: Stage,
ts: &str,
reason: Option<String>,
) -> Result<DecisionOutcome> {
apply_decision_recorded(
root,
slug,
gate,
DecisionKind::Approve,
"sdd-auto",
Channel::Automation,
ts,
reason,
)
}
#[allow(clippy::too_many_arguments)]
fn apply_decision_recorded(
root: &Path,
slug: &str,
gate: Stage,
decision: DecisionKind,
author: &str,
channel: Channel,
ts: &str,
reason: Option<String>,
) -> Result<DecisionOutcome> {
let mut st = state::load(root, slug)?.ok_or_else(|| anyhow!("demanda '{slug}' sem estado"))?;
if st.status != EngineStatus::AwaitingApproval {
bail!("demanda '{slug}' não está aguardando aprovação");
}
if st.cursor != gate.key() {
bail!(
"gate '{}' não corresponde ao cursor '{}'",
gate.key(),
st.cursor
);
}
st.approvals.push(Approval {
slug: slug.to_string(),
gate: gate.key().to_string(),
decision,
author: author.to_string(),
channel,
ts: ts.to_string(),
reason,
});
let outcome = match decision {
DecisionKind::Approve => {
let store = crate::worktree_store_dir(root, slug);
let filename = crate::stage_file(gate.key())
.ok_or_else(|| anyhow!("etapa de aprovação desconhecida: {}", gate.key()))?;
let artifact = store.join(filename);
let content = fs::read_to_string(&artifact).map_err(|error| {
anyhow!(
"lendo artefato para aprovação {}: {error}",
artifact.display()
)
})?;
let expected = crate::sha256_hex(content.as_bytes());
crate::persist_generated_stage_if_match(
root,
slug,
gate.key(),
&content,
"approved",
true,
false,
false,
Some(&expected),
)?;
match next_stage(gate) {
Some(next) if is_planning(next) => {
st.cursor = next.key().to_string();
st.status = EngineStatus::Orchestrating;
DecisionOutcome::Advanced
}
_ => {
st.status = EngineStatus::ReadyForExec;
DecisionOutcome::ReadyForExec
}
}
}
DecisionKind::Reject => {
st.status = EngineStatus::Orchestrating;
DecisionOutcome::Reopened
}
};
state::save(root, &st)?;
Ok(outcome)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::orchestrator::demand::{Demand, DemandSource, DemandType};
use crate::domain::orchestrator::engine::{tick, FakeRunner};
use crate::domain::orchestrator::{queue, state};
fn approvers() -> Approvers {
Approvers {
cli: vec!["alan".to_string()],
slack: vec![],
}
}
fn enqueue(root: &Path) -> String {
let d = Demand::new(
"DEM-1",
DemandType::Story,
"Esteira Autônoma",
"desc",
DemandSource::Cli,
None,
"2026-06-08T00:00:00Z",
)
.unwrap();
queue::enqueue(root, &d).unwrap();
d.slug
}
fn drive_to_prd_gate(root: &Path) -> String {
let slug = enqueue(root);
tick(root, &FakeRunner, "tick", "t0").unwrap(); tick(root, &FakeRunner, "tick", "t1").unwrap(); let st = state::load(root, &slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::AwaitingApproval);
slug
}
#[test]
fn authorize_is_default_deny() {
let a = approvers();
assert!(authorize(&a, Channel::Cli, "alan").is_ok());
assert!(authorize(&a, Channel::Cli, "mallory").is_err());
assert!(authorize(&a, Channel::Slack, "alan").is_err());
assert!(authorize(&Approvers::default(), Channel::Cli, "alan").is_err());
}
#[test]
fn engine_never_self_approves() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let slug = drive_to_prd_gate(root);
let st = state::load(root, &slug).unwrap().unwrap();
assert!(
st.approvals.is_empty(),
"o motor não pode criar registros de aprovação"
);
}
#[test]
fn approve_prd_advances_to_techspec() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let slug = drive_to_prd_gate(root);
let outcome = apply_decision(
root,
&slug,
Stage::Prd,
DecisionKind::Approve,
"alan",
Channel::Cli,
"t2",
None,
&approvers(),
)
.unwrap();
assert_eq!(outcome, DecisionOutcome::Advanced);
let st = state::load(root, &slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::Orchestrating);
assert_eq!(st.cursor, "techspec");
assert_eq!(st.approvals.len(), 1);
let map =
std::fs::read_to_string(root.join("docs").join(&slug).join("traceability-map.yaml"))
.unwrap();
let prd_block = map.split("prd:").nth(1).unwrap();
assert!(prd_block.contains("state: approved"));
}
#[test]
fn unauthorized_author_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let slug = drive_to_prd_gate(root);
let err = apply_decision(
root,
&slug,
Stage::Prd,
DecisionKind::Approve,
"mallory",
Channel::Cli,
"t2",
None,
&approvers(),
)
.unwrap_err();
assert!(err.to_string().contains("não autorizado"));
let st = state::load(root, &slug).unwrap().unwrap();
assert!(st.approvals.is_empty());
assert_eq!(st.status, EngineStatus::AwaitingApproval);
}
#[test]
fn approving_when_not_awaiting_fails() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let slug = enqueue(root);
tick(root, &FakeRunner, "tick", "t0").unwrap(); let err = apply_decision(
root,
&slug,
Stage::Prd,
DecisionKind::Approve,
"alan",
Channel::Cli,
"t1",
None,
&approvers(),
)
.unwrap_err();
assert!(err.to_string().contains("aguardando aprovação"));
}
#[test]
fn reject_reopens_gate_for_regeneration() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let slug = drive_to_prd_gate(root);
let outcome = apply_decision(
root,
&slug,
Stage::Prd,
DecisionKind::Reject,
"alan",
Channel::Cli,
"t2",
Some("escopo amplo demais".to_string()),
&approvers(),
)
.unwrap();
assert_eq!(outcome, DecisionOutcome::Reopened);
let st = state::load(root, &slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::Orchestrating);
assert_eq!(st.cursor, "prd", "cursor permanece para regenerar a etapa");
assert_eq!(
st.approvals.last().unwrap().reason.as_deref(),
Some("escopo amplo demais")
);
let report = tick(root, &FakeRunner, "tick", "t3").unwrap();
assert_eq!(report.produced, vec![(slug.clone(), "prd".to_string())]);
}
#[test]
fn full_planning_loop_reaches_ready_for_exec() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let slug = drive_to_prd_gate(root);
let a = approvers();
assert_eq!(
apply_decision(
root,
&slug,
Stage::Prd,
DecisionKind::Approve,
"alan",
Channel::Cli,
"t2",
None,
&a
)
.unwrap(),
DecisionOutcome::Advanced
);
tick(root, &FakeRunner, "tick", "t3").unwrap(); assert_eq!(
apply_decision(
root,
&slug,
Stage::Techspec,
DecisionKind::Approve,
"alan",
Channel::Cli,
"t4",
None,
&a
)
.unwrap(),
DecisionOutcome::Advanced
);
tick(root, &FakeRunner, "tick", "t5").unwrap(); let st = state::load(root, &slug).unwrap().unwrap();
assert_eq!(st.cursor, "refinement");
assert_eq!(st.status, EngineStatus::Orchestrating);
tick(root, &FakeRunner, "tick", "t6").unwrap(); assert_eq!(
apply_decision(
root,
&slug,
Stage::Refinement,
DecisionKind::Approve,
"alan",
Channel::Cli,
"t7",
None,
&a
)
.unwrap(),
DecisionOutcome::ReadyForExec
);
let st = state::load(root, &slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::ReadyForExec);
assert_eq!(st.approvals.len(), 3);
let dest = root.join("docs").join(&slug);
for f in [
"01-idea.md",
"02-prd.md",
"03-techspec.md",
"04-tasks.md",
"05-refinement.md",
] {
assert!(dest.join(f).is_file(), "faltou {f}");
}
}
}