use crate::tui::stage::Stage;
use super::state::{
Approval, CardMeta, Channel, DecisionKind, EngineState, EngineStatus, OrchestrationMode,
};
fn guided_next(stage: Stage, _mode: OrchestrationMode) -> Option<Stage> {
let all: &[Stage] = &Stage::CARD_GUIDED;
let idx = all.iter().position(|s| *s == stage)?;
all.get(idx + 1).copied()
}
pub fn is_interactive_gate(stage: Stage) -> bool {
matches!(
stage,
Stage::Prd | Stage::Techspec | Stage::Refinement | Stage::Review | Stage::Qa
)
}
pub fn stage_entry_column(stage: Stage) -> u8 {
match stage {
Stage::ProjectDiscovery
| Stage::RiskClassification
| Stage::Idea
| Stage::Prd
| Stage::Adr => 1,
Stage::Techspec | Stage::Tasks | Stage::Refinement => 2,
Stage::Execution => 3,
Stage::Review => 4,
Stage::Qa => 5,
Stage::Memory => 6,
}
}
pub fn required_done_evidence(stage: Stage, mode: OrchestrationMode) -> Vec<&'static str> {
if mode != OrchestrationMode::Card {
return Vec::new();
}
match stage {
Stage::Idea => vec!["description", "assignee", "dates"],
_ => Vec::new(),
}
}
pub fn transition_obligations(
stage: Stage,
next: Option<Stage>,
mode: OrchestrationMode,
) -> (Vec<String>, Option<u8>) {
if mode != OrchestrationMode::Card {
return (Vec::new(), None);
}
let mut keys: Vec<&'static str> = Vec::new();
match stage {
Stage::Prd | Stage::Techspec => keys.push("confluence"),
Stage::Memory => {
keys.push("confluence");
keys.push("merge");
}
_ => {}
}
let column = match stage {
Stage::Idea => Some(1),
Stage::Memory => Some(6),
_ => match next {
Some(n) if stage_entry_column(stage) != stage_entry_column(n) => {
Some(stage_entry_column(n))
}
_ => None,
},
};
if column.is_some() {
keys.push("column");
}
(keys.into_iter().map(str::to_string).collect(), column)
}
pub fn stage_subagent(stage: Stage) -> &'static str {
match stage {
Stage::ProjectDiscovery => "project-discovery",
Stage::RiskClassification => "risk-classifier",
Stage::Idea => "idea",
Stage::Prd => "prd",
Stage::Techspec => "techspec",
Stage::Tasks => "tasks",
Stage::Refinement => "refinement",
Stage::Execution => "execution",
Stage::Adr => "adr",
Stage::Review => "review",
Stage::Qa => "qa",
Stage::Memory => "memory",
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DirectiveKind {
Obligations,
Checkpoint,
GenerateStage,
Completed,
TestingFeedback,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Directive {
pub kind: DirectiveKind,
pub mode: OrchestrationMode,
pub slug: String,
pub cursor: String,
pub stage_label: String,
pub subagent: String,
pub is_gate: bool,
pub pending_obligations: Vec<String>,
pub pending_column: Option<u8>,
pub required_evidence: Vec<String>,
}
pub fn next_directive(st: &EngineState) -> Directive {
let stage = Stage::from_key(&st.cursor).unwrap_or(Stage::Idea);
let base = Directive {
kind: DirectiveKind::GenerateStage,
mode: st.mode,
slug: st.slug.clone(),
cursor: st.cursor.clone(),
stage_label: stage.label().to_string(),
subagent: stage_subagent(stage).to_string(),
is_gate: is_interactive_gate(stage),
pending_obligations: st.pending_obligations.clone(),
pending_column: st.pending_column,
required_evidence: required_done_evidence(stage, st.mode)
.into_iter()
.map(str::to_string)
.collect(),
};
if !st.pending_obligations.is_empty() {
return Directive {
kind: DirectiveKind::Obligations,
..base
};
}
match st.status {
EngineStatus::AwaitingApproval => Directive {
kind: DirectiveKind::Checkpoint,
..base
},
EngineStatus::Completed => Directive {
kind: DirectiveKind::Completed,
..base
},
EngineStatus::ReadyForTesting => Directive {
kind: DirectiveKind::TestingFeedback,
..base
},
_ => base,
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum DriverError {
StageMismatch { expected: String, got: String },
WrongStatus(String),
MissingEvidence(Vec<String>),
PendingObligations(Vec<String>),
}
impl std::fmt::Display for DriverError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::StageMismatch { expected, got } => {
write!(f, "etapa '{got}' não é o cursor atual '{expected}'")
}
Self::WrongStatus(s) => write!(f, "operação inválida no status '{s}'"),
Self::MissingEvidence(keys) => write!(
f,
"side-effects obrigatórios não comprovados (refaça e informe via --evidence): {}",
keys.join(", ")
),
Self::PendingObligations(keys) => write!(
f,
"há obrigações pós-aprovação pendentes — cumpra-as e rode `sdd orchestration confirm`: {}",
keys.join(", ")
),
}
}
}
impl std::error::Error for DriverError {}
fn missing_keys(required: &[String], evidence_keys: &[String]) -> Vec<String> {
required
.iter()
.filter(|k| !evidence_keys.iter().any(|e| e == *k))
.cloned()
.collect()
}
pub fn apply_done(
st: &mut EngineState,
stage: Stage,
evidence_keys: &[String],
) -> Result<(), DriverError> {
if !st.pending_obligations.is_empty() {
return Err(DriverError::PendingObligations(
st.pending_obligations.clone(),
));
}
if st.cursor != stage.key() {
return Err(DriverError::StageMismatch {
expected: st.cursor.clone(),
got: stage.key().to_string(),
});
}
if !matches!(
st.status,
EngineStatus::Orchestrating | EngineStatus::Queued
) {
return Err(DriverError::WrongStatus(st.status.to_repr()));
}
let required: Vec<String> = required_done_evidence(stage, st.mode)
.into_iter()
.map(str::to_string)
.collect();
let missing = missing_keys(&required, evidence_keys);
if !missing.is_empty() {
return Err(DriverError::MissingEvidence(missing));
}
if is_interactive_gate(stage) {
st.status = EngineStatus::AwaitingApproval;
} else {
let next = guided_next(stage, st.mode);
let (obl, col) = transition_obligations(stage, next, st.mode);
st.pending_obligations = obl;
st.pending_column = col;
match next {
Some(n) => {
st.cursor = n.key().to_string();
st.status = EngineStatus::Orchestrating;
}
None => {
if st.mode == OrchestrationMode::Card {
st.status = EngineStatus::ReadyForTesting;
} else {
st.status = EngineStatus::Completed;
}
}
}
}
Ok(())
}
pub fn apply_approve(
st: &mut EngineState,
gate: Stage,
author: &str,
ts: &str,
reason: Option<String>,
) -> Result<(), DriverError> {
if st.status != EngineStatus::AwaitingApproval {
return Err(DriverError::WrongStatus(st.status.to_repr()));
}
if st.cursor != gate.key() {
return Err(DriverError::StageMismatch {
expected: st.cursor.clone(),
got: gate.key().to_string(),
});
}
st.approvals.push(Approval {
slug: st.slug.clone(),
gate: gate.key().to_string(),
decision: DecisionKind::Approve,
author: author.to_string(),
channel: Channel::Cli,
ts: ts.to_string(),
reason,
});
let next = guided_next(gate, st.mode);
let (obl, col) = transition_obligations(gate, next, st.mode);
st.pending_obligations = obl;
st.pending_column = col;
match next {
Some(n) => {
st.cursor = n.key().to_string();
st.status = EngineStatus::Orchestrating;
}
None => {
if st.mode == OrchestrationMode::Card {
st.status = EngineStatus::ReadyForTesting;
} else {
st.status = EngineStatus::Completed;
}
}
}
Ok(())
}
pub fn apply_reject(
st: &mut EngineState,
gate: Stage,
author: &str,
ts: &str,
reason: Option<String>,
) -> Result<(), DriverError> {
if st.status != EngineStatus::AwaitingApproval {
return Err(DriverError::WrongStatus(st.status.to_repr()));
}
if st.cursor != gate.key() {
return Err(DriverError::StageMismatch {
expected: st.cursor.clone(),
got: gate.key().to_string(),
});
}
st.approvals.push(Approval {
slug: st.slug.clone(),
gate: gate.key().to_string(),
decision: DecisionKind::Reject,
author: author.to_string(),
channel: Channel::Cli,
ts: ts.to_string(),
reason: reason.clone(),
});
if gate == Stage::Review {
enter_rework(st, reason);
} else {
st.pending_obligations.clear();
st.pending_column = None;
st.status = EngineStatus::Orchestrating;
}
Ok(())
}
fn enter_rework(st: &mut EngineState, reason: Option<String>) {
st.last_error = reason;
st.cursor = Stage::Execution.key().to_string();
st.status = EngineStatus::Orchestrating;
if st.mode == OrchestrationMode::Card {
st.pending_obligations = vec!["doc".to_string(), "column".to_string()];
st.pending_column = Some(3);
} else {
st.pending_obligations.clear();
st.pending_column = None;
}
}
pub fn apply_rework(st: &mut EngineState, reason: Option<String>) -> Result<(), DriverError> {
if st.status == EngineStatus::ReadyForTesting {
enter_rework(st, reason);
return Ok(());
}
let stage = Stage::from_key(&st.cursor)
.ok_or_else(|| DriverError::WrongStatus(format!("cursor inválido: {}", st.cursor)))?;
if stage_entry_column(stage) < 3 {
return Err(DriverError::WrongStatus(format!(
"rework só é válido acima da coluna 2; cursor está em '{}' (col {})",
st.cursor,
stage_entry_column(stage)
)));
}
enter_rework(st, reason);
Ok(())
}
pub fn apply_confirm(st: &mut EngineState, evidence_keys: &[String]) -> Result<(), DriverError> {
let missing = missing_keys(&st.pending_obligations, evidence_keys);
if !missing.is_empty() {
return Err(DriverError::MissingEvidence(missing));
}
st.pending_obligations.clear();
st.pending_column = None;
Ok(())
}
pub fn seed_mode(
st: &mut EngineState,
mode: OrchestrationMode,
branch_raiz: Option<String>,
card_branch: Option<String>,
) {
st.mode = mode;
st.branch_raiz = branch_raiz;
st.card_branch = card_branch;
if mode == OrchestrationMode::Card && st.card.is_none() {
st.card = Some(CardMeta::default());
}
if st.cursor == "idea" {
st.cursor = Stage::ProjectDiscovery.key().to_string();
}
if st.status == EngineStatus::Queued {
st.status = EngineStatus::Orchestrating;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::orchestrator::state::EngineState;
fn new_state(mode: OrchestrationMode) -> EngineState {
let mut st = EngineState::new("dem-1").unwrap();
let card_branch = if mode == OrchestrationMode::Card {
Some("dem-1".to_string())
} else {
None
};
seed_mode(&mut st, mode, Some("main".to_string()), card_branch);
st
}
fn ev(keys: &[&str]) -> Vec<String> {
keys.iter().map(|s| s.to_string()).collect()
}
#[test]
fn free_text_walks_idea_to_memory_pausing_at_gates() {
let mut st = new_state(OrchestrationMode::FreeText);
assert_eq!(st.cursor, "project-discovery");
apply_done(&mut st, Stage::ProjectDiscovery, &[]).unwrap();
assert_eq!(st.cursor, "risk-classification");
apply_done(&mut st, Stage::RiskClassification, &[]).unwrap();
assert_eq!(st.cursor, "idea");
apply_done(&mut st, Stage::Idea, &[]).unwrap();
assert_eq!(st.cursor, "prd");
apply_done(&mut st, Stage::Prd, &[]).unwrap();
assert_eq!(st.status, EngineStatus::AwaitingApproval);
apply_approve(&mut st, Stage::Prd, "cli", "t0", None).unwrap();
assert!(st.pending_obligations.is_empty());
assert_eq!(st.cursor, "techspec");
}
#[test]
fn full_free_text_flow_reaches_completed() {
let mut st = new_state(OrchestrationMode::FreeText);
let gates = ["prd", "techspec", "refinement", "review", "qa"];
loop {
let d = next_directive(&st);
match d.kind {
DirectiveKind::Completed => break,
DirectiveKind::Checkpoint => {
let stage = Stage::from_key(&st.cursor).unwrap();
apply_approve(&mut st, stage, "cli", "t0", None).unwrap();
}
DirectiveKind::GenerateStage => {
let stage = Stage::from_key(&st.cursor).unwrap();
apply_done(&mut st, stage, &[]).unwrap();
}
DirectiveKind::Obligations => unreachable!("texto-livre não tem obrigações"),
DirectiveKind::TestingFeedback => {
unreachable!("texto-livre não tem testing feedback")
}
}
}
assert_eq!(st.status, EngineStatus::Completed);
assert_eq!(st.approvals.len(), gates.len());
}
fn card_through_idea(st: &mut EngineState) {
assert_eq!(st.cursor, "project-discovery");
apply_done(st, Stage::ProjectDiscovery, &[]).unwrap();
assert_eq!(st.cursor, "risk-classification");
apply_done(st, Stage::RiskClassification, &[]).unwrap();
assert_eq!(st.cursor, "idea");
apply_done(st, Stage::Idea, &ev(&["description", "assignee", "dates"])).unwrap();
assert_eq!(st.pending_column, Some(1));
apply_confirm(st, &ev(&["column"])).unwrap();
assert_eq!(st.cursor, "prd");
}
#[test]
fn card_idea_requires_description_assignee_dates() {
let mut st = new_state(OrchestrationMode::Card);
apply_done(&mut st, Stage::ProjectDiscovery, &[]).unwrap();
apply_done(&mut st, Stage::RiskClassification, &[]).unwrap();
assert_eq!(st.cursor, "idea");
let err = apply_done(&mut st, Stage::Idea, &ev(&["description"])).unwrap_err();
assert_eq!(
err,
DriverError::MissingEvidence(ev(&["assignee", "dates"]))
);
apply_done(
&mut st,
Stage::Idea,
&ev(&["description", "assignee", "dates"]),
)
.unwrap();
assert_eq!(st.cursor, "prd");
assert_eq!(st.pending_column, Some(1));
}
#[test]
fn card_columns_follow_stage_map() {
let mut st = new_state(OrchestrationMode::Card);
card_through_idea(&mut st); apply_done(&mut st, Stage::Prd, &[]).unwrap(); apply_approve(&mut st, Stage::Prd, "cli", "t0", None).unwrap();
assert_eq!(st.pending_obligations, ev(&["confluence", "column"]));
assert_eq!(st.pending_column, Some(2));
assert!(matches!(
apply_done(&mut st, Stage::Techspec, &[]).unwrap_err(),
DriverError::PendingObligations(_)
));
apply_confirm(&mut st, &ev(&["confluence", "column"])).unwrap();
assert!(st.pending_column.is_none());
apply_done(&mut st, Stage::Techspec, &[]).unwrap();
apply_approve(&mut st, Stage::Techspec, "cli", "t1", None).unwrap();
assert_eq!(st.pending_obligations, ev(&["confluence"]));
assert!(st.pending_column.is_none());
apply_confirm(&mut st, &ev(&["confluence"])).unwrap();
apply_done(&mut st, Stage::Tasks, &[]).unwrap();
assert!(st.pending_column.is_none());
assert_eq!(st.cursor, "refinement");
apply_done(&mut st, Stage::Refinement, &[]).unwrap();
apply_approve(&mut st, Stage::Refinement, "cli", "t2", None).unwrap();
assert_eq!(st.pending_column, Some(3));
apply_confirm(&mut st, &ev(&["column"])).unwrap();
apply_done(&mut st, Stage::Execution, &[]).unwrap();
assert_eq!(st.pending_column, Some(4));
apply_confirm(&mut st, &ev(&["column"])).unwrap();
apply_done(&mut st, Stage::Review, &[]).unwrap();
apply_approve(&mut st, Stage::Review, "cli", "t3", None).unwrap();
assert_eq!(st.pending_obligations, ev(&["column"]));
assert_eq!(st.pending_column, Some(5));
apply_confirm(&mut st, &ev(&["column"])).unwrap();
assert_eq!(st.cursor, "qa");
apply_done(&mut st, Stage::Qa, &[]).unwrap();
apply_approve(&mut st, Stage::Qa, "cli", "t4", None).unwrap();
assert_eq!(st.pending_obligations, ev(&["column"]));
assert_eq!(st.pending_column, Some(6));
apply_confirm(&mut st, &ev(&["column"])).unwrap();
assert_eq!(st.cursor, "memory");
apply_done(&mut st, Stage::Memory, &[]).unwrap();
assert_eq!(
st.pending_obligations,
ev(&["confluence", "merge", "column"])
);
assert_eq!(st.pending_column, Some(6));
}
#[test]
fn reject_reopens_planning_gate_and_clears_obligations() {
let mut st = new_state(OrchestrationMode::Card);
card_through_idea(&mut st); apply_done(&mut st, Stage::Prd, &[]).unwrap();
apply_reject(
&mut st,
Stage::Prd,
"cli",
"t0",
Some("escopo amplo".into()),
)
.unwrap();
assert_eq!(st.status, EngineStatus::Orchestrating);
assert_eq!(st.cursor, "prd");
assert!(st.pending_obligations.is_empty());
assert!(st.pending_column.is_none());
}
#[test]
fn rework_only_above_column_2() {
let mut st = new_state(OrchestrationMode::Card);
assert!(matches!(
apply_rework(&mut st, Some("erro".into())).unwrap_err(),
DriverError::WrongStatus(_)
));
st.cursor = "execution".to_string();
apply_rework(&mut st, Some("teste falhou".into())).unwrap();
assert_eq!(st.cursor, "execution");
assert_eq!(st.pending_obligations, ev(&["doc", "column"]));
assert_eq!(st.pending_column, Some(3));
assert_eq!(st.last_error.as_deref(), Some("teste falhou"));
}
#[test]
fn reject_review_enters_rework_at_execution() {
let mut st = new_state(OrchestrationMode::Card);
st.cursor = "review".to_string();
st.status = EngineStatus::AwaitingApproval;
apply_reject(
&mut st,
Stage::Review,
"cli",
"t0",
Some("bug na borda".into()),
)
.unwrap();
assert_eq!(st.cursor, "execution");
assert_eq!(st.status, EngineStatus::Orchestrating);
assert_eq!(st.pending_obligations, ev(&["doc", "column"]));
assert_eq!(st.pending_column, Some(3));
}
#[test]
fn done_rejects_wrong_stage() {
let mut st = new_state(OrchestrationMode::FreeText);
let err = apply_done(&mut st, Stage::Prd, &[]).unwrap_err();
assert!(matches!(err, DriverError::StageMismatch { .. }));
}
#[test]
fn approve_requires_awaiting_status() {
let mut st = new_state(OrchestrationMode::FreeText);
let err = apply_approve(&mut st, Stage::Idea, "cli", "t0", None).unwrap_err();
assert!(matches!(err, DriverError::WrongStatus(_)));
}
}