sdd-layer 0.20.0

Spec-Driven Development CLI and agent harness
//! Máquina de estados das etapas SDD e regras de navegação (T-10).
//! Funções puras, sem efeitos colaterais, testáveis sem terminal.

/// Etapas executáveis pela TUI.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Stage {
    /// Etapa exclusiva do modo card: Project Discovery antes da Idea.
    ProjectDiscovery,
    /// Etapa exclusiva do modo card: Risk Classification antes da Idea.
    RiskClassification,
    Idea,
    Prd,
    Techspec,
    Tasks,
    Refinement,
    Execution,
    Adr,
    Review,
    Memory,
}

impl Stage {
    /// Ordem canônica completa das etapas executáveis (inclui card-only).
    pub const ALL: [Stage; 11] = [
        Stage::ProjectDiscovery,
        Stage::RiskClassification,
        Stage::Idea,
        Stage::Prd,
        Stage::Techspec,
        Stage::Tasks,
        Stage::Refinement,
        Stage::Execution,
        Stage::Adr,
        Stage::Review,
        Stage::Memory,
    ];

    /// Guia do modo texto-livre: 8 etapas, sem ADR, sem preflight.
    pub const GUIDED: [Stage; 8] = [
        Stage::Idea,
        Stage::Prd,
        Stage::Techspec,
        Stage::Tasks,
        Stage::Refinement,
        Stage::Execution,
        Stage::Review,
        Stage::Memory,
    ];

    /// Guia do modo card: 10 etapas — Discovery e Risk obrigatórios antes de Idea.
    pub const CARD_GUIDED: [Stage; 10] = [
        Stage::ProjectDiscovery,
        Stage::RiskClassification,
        Stage::Idea,
        Stage::Prd,
        Stage::Techspec,
        Stage::Tasks,
        Stage::Refinement,
        Stage::Execution,
        Stage::Review,
        Stage::Memory,
    ];

    /// Chave usada como subcomando `sdd <key>` e no traceability-map.
    pub fn key(self) -> &'static str {
        match self {
            Stage::ProjectDiscovery => "project-discovery",
            Stage::RiskClassification => "risk-classification",
            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::Memory => "memory",
        }
    }

    /// Nome de arquivo canônico do artefato (espelha `schemas/sdd-contract.yaml`).
    pub fn filename(self) -> &'static str {
        match self {
            Stage::ProjectDiscovery => "00-project-discovery.md",
            Stage::RiskClassification => "00-risk-classification.md",
            Stage::Idea => "01-idea.md",
            Stage::Prd => "02-prd.md",
            Stage::Techspec => "03-techspec.md",
            Stage::Tasks => "04-tasks.md",
            Stage::Refinement => "05-refinement.md",
            Stage::Execution => "06-execution.md",
            Stage::Adr => "06-adr.md",
            Stage::Review => "07-review.md",
            Stage::Memory => "08-memory.md",
        }
    }

    /// Rótulo legível para a UI.
    pub fn label(self) -> &'static str {
        match self {
            Stage::ProjectDiscovery => "Project Discovery",
            Stage::RiskClassification => "Risk Classification",
            Stage::Idea => "Idea",
            Stage::Prd => "PRD",
            Stage::Techspec => "Tech Spec",
            Stage::Tasks => "Tasks",
            Stage::Refinement => "Refinement",
            Stage::Execution => "Execution",
            Stage::Adr => "ADR",
            Stage::Review => "Review",
            Stage::Memory => "Memory",
        }
    }

    /// Refinement é a única etapa opcional do fluxo.
    pub fn is_optional(self) -> bool {
        matches!(self, Stage::Refinement)
    }

    /// Etapas exclusivas do modo card (não aparecem em texto-livre).
    /// Mantido como parte da API do contrato de etapas (ainda sem consumidor).
    #[allow(dead_code)]
    pub fn is_card_only(self) -> bool {
        matches!(self, Stage::ProjectDiscovery | Stage::RiskClassification)
    }

    pub fn from_key(key: &str) -> Option<Stage> {
        Stage::ALL.into_iter().find(|s| s.key() == key)
    }

    fn order(self) -> usize {
        Stage::ALL.iter().position(|s| *s == self).unwrap_or(0)
    }
}

/// Estado de uma etapa, derivado do traceability-map.yaml.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum StageStatus {
    Pending,
    Optional,
    Recorded,
    InProgress,
    Approved,
    Skipped,
    Unknown,
}

impl StageStatus {
    pub fn from_yaml(value: &str) -> StageStatus {
        match value.trim() {
            "approved" => StageStatus::Approved,
            "skipped" => StageStatus::Skipped,
            "optional" => StageStatus::Optional,
            "recorded" => StageStatus::Recorded,
            "in_progress" | "in-progress" => StageStatus::InProgress,
            "pending" | "draft" => StageStatus::Pending,
            "" => StageStatus::Unknown,
            _ => StageStatus::Unknown,
        }
    }

    /// Conta como "cumprida" para fins de liberar a próxima etapa.
    pub fn is_cleared(self) -> bool {
        matches!(self, StageStatus::Approved | StageStatus::Skipped)
    }

    pub fn icon(self) -> &'static str {
        match self {
            StageStatus::Pending => "",
            StageStatus::Optional => "",
            StageStatus::Recorded => "",
            StageStatus::InProgress => "",
            StageStatus::Approved => "",
            StageStatus::Skipped => "",
            StageStatus::Unknown => "?",
        }
    }
}

/// Estado completo de uma etapa numa orquestração.
#[derive(Clone, Debug)]
pub struct StageState {
    pub stage: Stage,
    pub file: String,
    pub status: StageStatus,
    pub approved_at: Option<String>,
}

/// Verifica se a navegação para `target` é permitida dado o estado atual.
///
/// Regras (RF-04, CA-RF08, RN-04, RN-08):
/// - navegar para uma etapa já cumprida (Approved/Skipped) ou anterior é sempre livre;
/// - navegar para uma etapa futura exige que TODAS as obrigatórias anteriores estejam cumpridas;
/// - Refinement é opcional: não bloqueia as posteriores quando Skipped.
pub fn can_navigate(stages: &[StageState], target: Stage) -> Result<(), String> {
    let target_order = target.order();

    // Etapa já cumprida: releitura sempre permitida.
    if let Some(st) = stages.iter().find(|s| s.stage == target) {
        if st.status.is_cleared() {
            return Ok(());
        }
    }

    // Bloqueia se alguma etapa obrigatória ANTERIOR não estiver cumprida.
    for st in stages.iter() {
        if st.stage.order() >= target_order {
            continue;
        }
        if st.stage.is_optional() {
            continue;
        }
        if !st.status.is_cleared() {
            return Err(format!(
                "Aprovação do {} obrigatória antes de avançar",
                st.stage.label()
            ));
        }
    }
    Ok(())
}

/// Primeira etapa não cumprida — usada como "etapa corrente".
pub fn current_stage(stages: &[StageState]) -> Option<Stage> {
    for st in stages {
        if st.stage.is_optional() {
            // Refinement só é "corrente" se estiver explicitamente pending (não optional/skipped).
            if matches!(st.status, StageStatus::Pending | StageStatus::InProgress) {
                return Some(st.stage);
            }
            continue;
        }
        if !st.status.is_cleared() {
            return Some(st.stage);
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    fn state(stage: Stage, status: StageStatus) -> StageState {
        StageState {
            stage,
            file: format!("{}.md", stage.key()),
            status,
            approved_at: None,
        }
    }

    fn all_with(overrides: &[(Stage, StageStatus)]) -> Vec<StageState> {
        Stage::GUIDED
            .into_iter()
            .map(|s| {
                let status = overrides
                    .iter()
                    .find(|(st, _)| *st == s)
                    .map(|(_, status)| *status)
                    .unwrap_or(if s.is_optional() {
                        StageStatus::Optional
                    } else {
                        StageStatus::Pending
                    });
                state(s, status)
            })
            .collect()
    }

    #[test]
    fn blocks_when_required_prev_pending() {
        let stages = all_with(&[(Stage::Idea, StageStatus::Approved)]);
        let err = can_navigate(&stages, Stage::Techspec).unwrap_err();
        assert!(
            err.contains("PRD"),
            "esperava bloqueio citando PRD, veio: {err}"
        );
    }

    #[test]
    fn allows_when_required_prev_approved() {
        let stages = all_with(&[
            (Stage::Idea, StageStatus::Approved),
            (Stage::Prd, StageStatus::Approved),
        ]);
        assert!(can_navigate(&stages, Stage::Techspec).is_ok());
    }

    #[test]
    fn refinement_skipped_allows_execution() {
        let stages = all_with(&[
            (Stage::Idea, StageStatus::Approved),
            (Stage::Prd, StageStatus::Approved),
            (Stage::Techspec, StageStatus::Approved),
            (Stage::Tasks, StageStatus::Approved),
            (Stage::Refinement, StageStatus::Skipped),
        ]);
        assert!(can_navigate(&stages, Stage::Execution).is_ok());
    }

    #[test]
    fn refinement_optional_does_not_block_execution() {
        let stages = all_with(&[
            (Stage::Idea, StageStatus::Approved),
            (Stage::Prd, StageStatus::Approved),
            (Stage::Techspec, StageStatus::Approved),
            (Stage::Tasks, StageStatus::Approved),
            // Refinement fica Optional (não cumprida, mas opcional)
        ]);
        assert!(can_navigate(&stages, Stage::Execution).is_ok());
    }

    #[test]
    fn review_no_longer_depends_on_recorded_adr_lane() {
        let stages = all_with(&[
            (Stage::Idea, StageStatus::Approved),
            (Stage::Prd, StageStatus::Approved),
            (Stage::Techspec, StageStatus::Approved),
            (Stage::Tasks, StageStatus::Approved),
            (Stage::Refinement, StageStatus::Skipped),
            (Stage::Execution, StageStatus::Approved),
        ]);
        assert!(can_navigate(&stages, Stage::Review).is_ok());
    }

    #[test]
    fn navigate_to_approved_stage_is_free() {
        let stages = all_with(&[(Stage::Idea, StageStatus::Approved)]);
        assert!(can_navigate(&stages, Stage::Idea).is_ok());
    }

    #[test]
    fn current_stage_is_first_uncleared() {
        let stages = all_with(&[(Stage::Idea, StageStatus::Approved)]);
        assert_eq!(current_stage(&stages), Some(Stage::Prd));
    }

    #[test]
    fn from_key_roundtrip() {
        for s in Stage::ALL {
            assert_eq!(Stage::from_key(s.key()), Some(s));
        }
    }

    #[test]
    fn recorded_status_has_distinct_icon() {
        assert_eq!(StageStatus::from_yaml("recorded"), StageStatus::Recorded);
        assert_eq!(StageStatus::Recorded.icon(), "");
        assert!(!StageStatus::Recorded.is_cleared());
    }
}