sdd-layer 0.12.0

Spec-Driven Development CLI and agent harness
//! Motor durável do orquestrador — `tick()` (SDD-OAD-005 ingestão + 006 avanço).
//!
//! `tick` é determinístico e idempotente e produz **uma etapa por demanda por
//! chamada** (`sdd auto tick` = um passo): cria a orquestração na primeira vez,
//! gera a etapa apontada pelo `cursor` e **pausa ao concluir um gate** (PRD,
//! Tech Spec, Refinamento), deixando a aprovação para a SDD-OAD-008.
//!
//! Reconciliação: `tui::stage::can_navigate` é baseado em **aprovação** e não
//! serve ao avanço autônomo (que produz etapas como `recorded`, não `approved`);
//! o motor usa `cursor` + detecção de gate. A geração é abstraída por
//! `StageRunner` (testável com `FakeRunner`); o runner real é a extração de
//! `tui::runner` (SDD-OAD-002), plugada na SDD-OAD-009.

use std::path::{Path, PathBuf};

use anyhow::Result;

use crate::tui::stage::Stage;

use super::demand::Demand;
use super::state::{self, EngineState, EngineStatus};
use super::{queue, write_atomic};

/// Pedido de geração de uma etapa. Campos de provider/timeout entram na
/// SDD-OAD-009 (quando o runner real é plugado).
// slug/description/artifact_dir são lidos pelo runner real (SDD-OAD-009);
// o FakeRunner usa apenas stage/title.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub struct StageRequest {
    pub slug: String,
    pub stage: Stage,
    pub title: String,
    pub description: String,
    pub artifact_dir: PathBuf,
}

/// Artefato Markdown produzido por um `StageRunner`.
#[derive(Clone, Debug)]
pub struct StageArtifact {
    // `stage` é parte do contrato; conferido pelo runner real (SDD-OAD-009).
    #[allow(dead_code)]
    pub stage: Stage,
    pub markdown: String,
}

/// Falha de geração de etapa.
// `Failed` é construído pelo runner real (SDD-OAD-009); o FakeRunner só retorna Ok.
#[allow(dead_code)]
#[derive(Debug)]
pub enum RunnerError {
    /// Provider sem modo headless confiável → etapa vira `manual:<stage>`.
    NotHeadless,
    /// Falha de execução com mensagem.
    Failed(String),
}

impl std::fmt::Display for RunnerError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotHeadless => write!(f, "provider sem modo headless"),
            Self::Failed(msg) => write!(f, "falha na geração: {msg}"),
        }
    }
}

impl std::error::Error for RunnerError {}

/// Abstração do único ponto que fala com o modelo. A impl real (SDD-OAD-002/009)
/// é um wrapper sobre `runtime::stage_runner` (extraído de `tui::runner`).
pub trait StageRunner {
    fn run(&self, req: &StageRequest) -> std::result::Result<StageArtifact, RunnerError>;
}

/// Runner determinístico para testar o motor sem provider real.
pub struct FakeRunner;

impl StageRunner for FakeRunner {
    fn run(&self, req: &StageRequest) -> std::result::Result<StageArtifact, RunnerError> {
        let markdown = format!(
            "# {} - {}\n\n(conteúdo gerado por FakeRunner para a demanda)\n",
            req.stage.label(),
            req.title
        );
        Ok(StageArtifact {
            stage: req.stage,
            markdown,
        })
    }
}

/// Resultado observável de um `tick` (para CLI/testes).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TickReport {
    /// (slug, stage) de etapas produzidas neste tick.
    pub produced: Vec<(String, String)>,
    /// Demandas que pausaram num gate (awaiting approval) neste tick.
    pub paused: Vec<String>,
    /// Demandas que alcançaram `ready_for_exec` neste tick.
    pub ready: Vec<String>,
    /// Demandas sem ação (awaiting/manual/error/ready já persistidos).
    pub skipped: Vec<String>,
    /// Erros por demanda.
    pub errors: Vec<String>,
}

/// Etapas que exigem checkpoint humano.
fn is_gate(stage: Stage) -> bool {
    matches!(stage, Stage::Prd | Stage::Techspec | Stage::Refinement)
}

/// Etapas do planejamento autônomo (a execução é fronteira de handoff).
pub(crate) fn is_planning(stage: Stage) -> bool {
    matches!(
        stage,
        Stage::Idea | Stage::Prd | Stage::Techspec | Stage::Tasks | Stage::Refinement
    )
}

/// Próxima etapa na ordem canônica, se houver.
pub(crate) fn next_stage(stage: Stage) -> Option<Stage> {
    let all = Stage::ALL;
    let idx = all.iter().position(|s| *s == stage)?;
    all.get(idx + 1).copied()
}

/// Um passo do motor: produz no máximo uma etapa por demanda.
pub fn tick(root: &Path, runner: &dyn StageRunner, owner: &str, now: &str) -> Result<TickReport> {
    let mut report = TickReport::default();
    for demand in queue::list(root)? {
        let existing = state::load(root, &demand.slug)?;
        let active = match &existing {
            None => true,
            Some(st) => st.status == EngineStatus::Orchestrating,
        };
        if !active {
            report.skipped.push(demand.slug.clone());
            continue;
        }
        if let Err(err) = step(root, runner, &demand, existing, owner, now, &mut report) {
            report.errors.push(format!("{}: {err:#}", demand.slug));
        }
    }
    Ok(report)
}

/// Roda `tick` repetidamente até nenhuma demanda progredir (todas pausadas em
/// gate, prontas, manuais ou em erro) ou atingir `max_iterations`. Retorna o
/// relatório de cada iteração — base do `sdd auto run` (SDD-OAD-013).
pub fn run_until_idle(
    root: &Path,
    runner: &dyn StageRunner,
    owner: &str,
    now: &str,
    max_iterations: u32,
) -> Result<Vec<TickReport>> {
    let mut reports = Vec::new();
    for _ in 0..max_iterations {
        let report = tick(root, runner, owner, now)?;
        let progressed = !report.produced.is_empty();
        reports.push(report);
        if !progressed {
            break;
        }
    }
    Ok(reports)
}

/// Cria a orquestração (se nova), produz a etapa do `cursor` e atualiza o estado.
fn step(
    root: &Path,
    runner: &dyn StageRunner,
    demand: &Demand,
    existing: Option<EngineState>,
    owner: &str,
    now: &str,
    report: &mut TickReport,
) -> Result<()> {
    let mut st = match existing {
        Some(st) => st,
        None => {
            let mut s = EngineState::new(&demand.slug)?;
            s.status = EngineStatus::Orchestrating;
            s
        }
    };
    st.last_tick = Some(now.to_string());

    // Lock por demanda — se outro owner já segura, deixa para o próximo tick.
    if !state::acquire_lock(root, &mut st, owner, now)? {
        report.skipped.push(demand.slug.clone());
        return Ok(());
    }

    // Cria/garante docs/<slug>/ + traceability-map (reuso in-process, sem stdout).
    let dest = crate::artifact_dir(root, &demand.title);
    std::fs::create_dir_all(&dest)?;
    let index = crate::ensure_index(&dest, &demand.title)?;

    let Some(stage) = Stage::from_key(&st.cursor) else {
        st.status = EngineStatus::Error;
        state::release_lock(root, &mut st)?;
        report
            .errors
            .push(format!("{}: cursor inválido '{}'", demand.slug, st.cursor));
        return Ok(());
    };

    let request = StageRequest {
        slug: demand.slug.clone(),
        stage,
        title: demand.title.clone(),
        description: demand.description.clone(),
        artifact_dir: dest.clone(),
    };

    match runner.run(&request) {
        Ok(artifact) => {
            let file = crate::stage_file(stage.key()).expect("stage canônica tem arquivo");
            write_atomic(&dest.join(file), artifact.markdown.as_bytes())?;
            crate::update_stage_state(&index, stage.key(), "recorded")?;
            report
                .produced
                .push((demand.slug.clone(), stage.key().to_string()));
            if is_gate(stage) {
                // Pausa: cursor permanece no gate; aprovação (008) avança.
                st.status = EngineStatus::AwaitingApproval;
                report.paused.push(demand.slug.clone());
            } else {
                match next_stage(stage) {
                    Some(next) if is_planning(next) => {
                        st.cursor = next.key().to_string();
                        st.status = EngineStatus::Orchestrating;
                    }
                    _ => {
                        st.status = EngineStatus::ReadyForExec;
                        report.ready.push(demand.slug.clone());
                    }
                }
            }
        }
        Err(RunnerError::NotHeadless) => {
            st.status = EngineStatus::Manual(stage.key().to_string());
            report.skipped.push(demand.slug.clone());
        }
        Err(RunnerError::Failed(msg)) => {
            st.status = EngineStatus::Error;
            report.errors.push(format!("{}: {msg}", demand.slug));
        }
    }

    // release_lock persiste o estado final (cursor/status atualizados).
    state::release_lock(root, &mut st)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::orchestrator::demand::{Demand, DemandSource, DemandType};

    struct FailingRunner;
    impl StageRunner for FailingRunner {
        fn run(&self, _req: &StageRequest) -> std::result::Result<StageArtifact, RunnerError> {
            Err(RunnerError::NotHeadless)
        }
    }

    fn enqueue_demand(root: &Path, id: &str, title: &str) -> Demand {
        let d = Demand::new(
            id,
            DemandType::Story,
            title,
            "desc",
            DemandSource::Cli,
            None,
            "2026-06-08T00:00:00Z",
        )
        .unwrap();
        queue::enqueue(root, &d).unwrap();
        d
    }

    #[test]
    fn first_tick_ingests_and_produces_idea() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");

        let report = tick(root, &FakeRunner, "tick-1", "2026-06-08T00:00:00Z").unwrap();
        assert_eq!(report.produced, vec![(d.slug.clone(), "idea".to_string())]);
        assert!(report.paused.is_empty());

        let dest = root.join("docs").join(&d.slug);
        assert!(dest.join("traceability-map.yaml").is_file());
        assert!(dest.join("01-idea.md").is_file());
        assert!(!dest.join("02-prd.md").exists());

        let st = state::load(root, &d.slug).unwrap().unwrap();
        assert_eq!(st.status, EngineStatus::Orchestrating);
        assert_eq!(st.cursor, "prd");
        assert!(st.lock.is_none(), "lock deve ser liberado ao fim do tick");

        let map = std::fs::read_to_string(dest.join("traceability-map.yaml")).unwrap();
        let idea_block = map.split("idea:").nth(1).unwrap();
        assert!(idea_block.contains("state: recorded"));
    }

    #[test]
    fn second_tick_advances_to_prd_and_pauses_at_gate() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
        tick(root, &FakeRunner, "tick-1", "t0").unwrap();

        let report = tick(root, &FakeRunner, "tick-1", "t1").unwrap();
        assert_eq!(report.produced, vec![(d.slug.clone(), "prd".to_string())]);
        assert_eq!(report.paused, vec![d.slug.clone()]);

        let dest = root.join("docs").join(&d.slug);
        assert!(dest.join("02-prd.md").is_file());
        assert!(
            !dest.join("03-techspec.md").exists(),
            "não deve passar do gate"
        );

        let st = state::load(root, &d.slug).unwrap().unwrap();
        assert_eq!(st.status, EngineStatus::AwaitingApproval);
        assert_eq!(st.cursor, "prd", "cursor permanece no gate até aprovação");
    }

    #[test]
    fn awaiting_demand_is_skipped() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
        tick(root, &FakeRunner, "tick-1", "t0").unwrap(); // idea
        tick(root, &FakeRunner, "tick-1", "t1").unwrap(); // prd → awaiting

        let report = tick(root, &FakeRunner, "tick-1", "t2").unwrap();
        assert_eq!(report.skipped, vec![d.slug.clone()]);
        assert!(report.produced.is_empty());
        // Sem nova etapa produzida.
        assert!(!root
            .join("docs")
            .join(&d.slug)
            .join("03-techspec.md")
            .exists());
    }

    #[test]
    fn runner_not_headless_marks_manual() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
        let report = tick(root, &FailingRunner, "tick-1", "t0").unwrap();
        assert_eq!(report.skipped, vec![d.slug.clone()]);
        let st = state::load(root, &d.slug).unwrap().unwrap();
        assert_eq!(st.status, EngineStatus::Manual("idea".to_string()));
        assert!(!root.join("docs").join(&d.slug).join("01-idea.md").exists());
    }

    #[test]
    fn empty_queue_is_noop() {
        let dir = tempfile::tempdir().unwrap();
        let report = tick(dir.path(), &FakeRunner, "tick-1", "t0").unwrap();
        assert_eq!(report, TickReport::default());
    }

    #[test]
    fn run_until_idle_drives_to_first_gate() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let d = enqueue_demand(root, "DEM-1", "Esteira Autônoma");
        let reports = run_until_idle(root, &FakeRunner, "tick", "t0", 50).unwrap();
        let produced: Vec<String> = reports
            .iter()
            .flat_map(|r| r.produced.iter().map(|(_, s)| s.clone()))
            .collect();
        assert_eq!(produced, vec!["idea".to_string(), "prd".to_string()]);
        let st = state::load(root, &d.slug).unwrap().unwrap();
        assert_eq!(st.status, EngineStatus::AwaitingApproval);
        assert_eq!(st.cursor, "prd");
    }
}