sdd-layer 0.12.0

Spec-Driven Development CLI and agent harness
//! Aprovação humana de gates (SDD-OAD-008).
//!
//! Regras inegociáveis (Tech Spec v2 / RN-02):
//! - **default-deny**: allowlist vazia nega; autor precisa estar na allowlist do
//!   canal;
//! - **autoria separada**: o MOTOR (`engine::tick`) nunca chama este módulo —
//!   só a CLI (`sdd demand approve/reject`, SDD-OAD-012) e o callback Slack
//!   verificado (SDD-OAD-014) criam uma `Decision`.

use std::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};

/// Allowlist de aprovadores por canal (carregada de `sdd.config.yaml` na CLI).
#[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,
        }
    }
}

/// Autoriza uma decisão (default-deny). Allowlist vazia ⇒ nega.
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(())
}

/// Efeito de uma decisão aplicada.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DecisionOutcome {
    /// Aprovado e avançou para a próxima etapa de planejamento.
    Advanced,
    /// Aprovado o último gate (Refinamento) — pronto para handoff de execução.
    ReadyForExec,
    /// Reprovado — etapa do gate reaberta para regeneração.
    Reopened,
}

/// Aplica uma decisão humana a um gate. Valida autorização e o estado atual,
/// registra a `Approval` e atualiza cursor/status + o traceability-map.
#[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)?;

    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 index = root.join("docs").join(slug).join("traceability-map.yaml");

    let outcome = match decision {
        DecisionKind::Approve => {
            crate::update_stage_state(&index, gate.key(), "approved")?;
            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 => {
            // Reabre a etapa do gate (cursor permanece) para regeneração.
            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
    }

    /// Avança o motor até pausar no gate do PRD.
    fn drive_to_prd_gate(root: &Path) -> String {
        let slug = enqueue(root);
        tick(root, &FakeRunner, "tick", "t0").unwrap(); // idea
        tick(root, &FakeRunner, "tick", "t1").unwrap(); // prd → gate
        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());
        // Canal sem aprovadores configurados nega tudo.
        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"));
        // Nenhuma aprovação registrada.
        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(); // idea → Orchestrating (não awaiting)
        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")
        );
        // Próximo tick re-produz o PRD.
        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();

        // Aprova PRD → tick TechSpec (gate) → aprova → tick Tasks (não gate) →
        // tick Refinamento (gate) → aprova → ReadyForExec.
        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(); // techspec → gate
        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(); // tasks (não gate) → cursor refinement
        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(); // refinement → gate
        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}");
        }
    }
}