sdd-layer 0.12.0

Spec-Driven Development CLI and agent harness
//! Estado durável do motor por demanda em `.sdd/state/<slug>.json` (SDD-OAD-004).
//!
//! Decisões da Tech Spec v2 honradas aqui:
//! - **Fonte primária de verdade** do motor (cursor, status, lock, approvals);
//! - escrita **atômica** (write-temp + rename) e `schema_version` obrigatório;
//! - **Opção A**: os estados ricos (`awaiting_approval`/`ready_for_exec`/`manual:*`)
//!   vivem SÓ aqui; o campo por-artefato do traceability-map nunca os recebe
//!   (ver `artifact_state_for_status`).

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

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};

use super::demand::validate_slug;

/// Versão do schema de estado. Incrementar exige rotina de migração.
pub const STATE_SCHEMA_VERSION: u32 = 1;

/// Status operacional rico do motor (vive só no `.sdd/state`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EngineStatus {
    Queued,
    Orchestrating,
    AwaitingApproval,
    ReadyForExec,
    Paused,
    Error,
    /// Etapa que precisa de preenchimento manual (provider sem headless).
    Manual(String),
}

impl EngineStatus {
    pub fn to_repr(&self) -> String {
        match self {
            Self::Queued => "queued".to_string(),
            Self::Orchestrating => "orchestrating".to_string(),
            Self::AwaitingApproval => "awaiting_approval".to_string(),
            Self::ReadyForExec => "ready_for_exec".to_string(),
            Self::Paused => "paused".to_string(),
            Self::Error => "error".to_string(),
            Self::Manual(stage) => format!("manual:{stage}"),
        }
    }

    pub fn from_repr(value: &str) -> std::result::Result<Self, String> {
        if let Some(stage) = value.strip_prefix("manual:") {
            if stage.is_empty() {
                return Err("status manual sem etapa".to_string());
            }
            return Ok(Self::Manual(stage.to_string()));
        }
        match value {
            "queued" => Ok(Self::Queued),
            "orchestrating" => Ok(Self::Orchestrating),
            "awaiting_approval" => Ok(Self::AwaitingApproval),
            "ready_for_exec" => Ok(Self::ReadyForExec),
            "paused" => Ok(Self::Paused),
            "error" => Ok(Self::Error),
            other => Err(format!("status de motor inválido: {other}")),
        }
    }
}

impl From<EngineStatus> for String {
    fn from(value: EngineStatus) -> Self {
        value.to_repr()
    }
}

impl TryFrom<String> for EngineStatus {
    type Error = String;
    fn try_from(value: String) -> std::result::Result<Self, String> {
        EngineStatus::from_repr(&value)
    }
}

/// Decisão humana sobre um gate (criada por SDD-OAD-008; persistida aqui).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DecisionKind {
    Approve,
    Reject,
}

/// Canal pelo qual a decisão chegou.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Channel {
    Cli,
    Slack,
}

/// Registro de aprovação/reprovação de um gate. Inclui `slug` para correlação.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Approval {
    pub slug: String,
    pub gate: String,
    pub decision: DecisionKind,
    pub author: String,
    pub channel: Channel,
    pub ts: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

/// Lock por demanda — impede avanço concorrente da mesma demanda.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lock {
    pub owner: String,
    pub acquired_at: String,
}

/// Estado durável do motor para uma demanda.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EngineState {
    pub schema_version: u32,
    pub slug: String,
    /// Etapa corrente (stage key, ex.: "idea", "prd").
    pub cursor: String,
    #[serde(with = "engine_status_serde")]
    pub status: EngineStatus,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lock: Option<Lock>,
    #[serde(default)]
    pub approvals: Vec<Approval>,
    #[serde(default)]
    pub attempts: BTreeMap<String, u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_tick: Option<String>,
}

mod engine_status_serde {
    use super::EngineStatus;
    use serde::{Deserialize, Deserializer, Serializer};

    pub fn serialize<S: Serializer>(value: &EngineStatus, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&value.to_repr())
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<EngineStatus, D::Error> {
        let raw = String::deserialize(d)?;
        EngineStatus::from_repr(&raw).map_err(serde::de::Error::custom)
    }
}

impl EngineState {
    /// Cria o estado inicial de uma demanda (cursor = `idea`, status `Queued`).
    pub fn new(slug: impl Into<String>) -> Result<Self> {
        let slug = slug.into();
        validate_slug(&slug)?;
        Ok(Self {
            schema_version: STATE_SCHEMA_VERSION,
            slug,
            cursor: "idea".to_string(),
            status: EngineStatus::Queued,
            lock: None,
            approvals: Vec::new(),
            attempts: BTreeMap::new(),
            last_tick: None,
        })
    }
}

/// Diretório de estado para um root de projeto.
pub fn state_dir(root: &Path) -> PathBuf {
    root.join(".sdd").join("state")
}

fn state_path(root: &Path, slug: &str) -> Result<PathBuf> {
    validate_slug(slug)?;
    Ok(state_dir(root).join(format!("{slug}.json")))
}

/// Persiste o estado de forma atômica.
pub fn save(root: &Path, state: &EngineState) -> Result<()> {
    if state.schema_version != STATE_SCHEMA_VERSION {
        bail!(
            "schema_version de estado não suportado ao salvar: {} (esperado {})",
            state.schema_version,
            STATE_SCHEMA_VERSION
        );
    }
    let path = state_path(root, &state.slug)?;
    let body = serde_json::to_string_pretty(state).context("serializando estado do motor")?;
    super::write_atomic(&path, body.as_bytes())
}

/// Carrega o estado de uma demanda, ou `None` se não existir.
pub fn load(root: &Path, slug: &str) -> Result<Option<EngineState>> {
    let path = state_path(root, slug)?;
    if !path.exists() {
        return Ok(None);
    }
    let text = fs_read(&path)?;
    let state: EngineState = serde_json::from_str(&text)
        .with_context(|| format!("desserializando estado {}", path.display()))?;
    if state.schema_version != STATE_SCHEMA_VERSION {
        bail!(
            "schema_version de estado não suportado: {} (esperado {})",
            state.schema_version,
            STATE_SCHEMA_VERSION
        );
    }
    Ok(Some(state))
}

fn fs_read(path: &Path) -> Result<String> {
    std::fs::read_to_string(path).with_context(|| format!("lendo estado {}", path.display()))
}

/// Tenta adquirir o lock da demanda. Retorna `true` se adquirido (sem lock, ou
/// lock do mesmo `owner`), `false` se já está travado por outro owner.
/// Persiste o estado quando adquire.
pub fn acquire_lock(
    root: &Path,
    state: &mut EngineState,
    owner: impl Into<String>,
    now: impl Into<String>,
) -> Result<bool> {
    let owner = owner.into();
    if let Some(lock) = &state.lock {
        if lock.owner != owner {
            return Ok(false);
        }
    }
    state.lock = Some(Lock {
        owner,
        acquired_at: now.into(),
    });
    save(root, state)?;
    Ok(true)
}

/// Libera o lock e persiste.
pub fn release_lock(root: &Path, state: &mut EngineState) -> Result<()> {
    state.lock = None;
    save(root, state)
}

/// **Opção A**: traduz o status do motor para um valor de `state` por-artefato
/// que `StageStatus::from_yaml` entende. NUNCA retorna um estado rico.
// Guarda de invariante (testada); consumida ao espelhar status no map (SDD-OAD-014).
#[allow(dead_code)]
pub fn artifact_state_for_status(status: &EngineStatus) -> &'static str {
    match status {
        EngineStatus::ReadyForExec => "approved",
        EngineStatus::Error => "recorded",
        _ => "in_progress",
    }
}

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

    fn fresh(root: &Path) -> EngineState {
        let mut st = EngineState::new("dem-1").unwrap();
        st.last_tick = Some("2026-06-08T00:00:00Z".to_string());
        save(root, &st).unwrap();
        st
    }

    #[test]
    fn new_state_defaults() {
        let st = EngineState::new("dem-1").unwrap();
        assert_eq!(st.schema_version, STATE_SCHEMA_VERSION);
        assert_eq!(st.cursor, "idea");
        assert_eq!(st.status, EngineStatus::Queued);
        assert!(st.lock.is_none());
        assert!(EngineState::new("../escape").is_err());
    }

    #[test]
    fn save_and_load_round_trip() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let mut st = fresh(root);
        st.status = EngineStatus::AwaitingApproval;
        st.cursor = "prd".to_string();
        st.approvals.push(Approval {
            slug: "dem-1".to_string(),
            gate: "prd".to_string(),
            decision: DecisionKind::Approve,
            author: "alan@x".to_string(),
            channel: Channel::Cli,
            ts: "2026-06-08T01:00:00Z".to_string(),
            reason: None,
        });
        *st.attempts.entry("techspec".to_string()).or_insert(0) += 1;
        save(root, &st).unwrap();
        let back = load(root, "dem-1").unwrap().unwrap();
        assert_eq!(st, back);
        assert!(state_dir(root).join("dem-1.json").is_file());
    }

    #[test]
    fn load_missing_is_none() {
        let dir = tempfile::tempdir().unwrap();
        assert!(load(dir.path(), "nope").unwrap().is_none());
    }

    #[test]
    fn divergent_schema_version_is_rejected() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        std::fs::create_dir_all(state_dir(root)).unwrap();
        let bad = r#"{ "schema_version": 99, "slug": "dem-1", "cursor": "idea",
            "status": "queued", "approvals": [], "attempts": {} }"#;
        std::fs::write(state_dir(root).join("dem-1.json"), bad).unwrap();
        assert!(load(root, "dem-1")
            .unwrap_err()
            .to_string()
            .contains("schema_version"));
    }

    #[test]
    fn lock_is_exclusive_per_owner() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let mut st = fresh(root);
        assert!(acquire_lock(root, &mut st, "tick-1", "t0").unwrap());
        // Outro owner não consegue enquanto travado.
        let mut other = load(root, "dem-1").unwrap().unwrap();
        assert!(!acquire_lock(root, &mut other, "tick-2", "t1").unwrap());
        // Mesmo owner re-adquire.
        assert!(acquire_lock(root, &mut st, "tick-1", "t2").unwrap());
        // Após liberar, outro consegue.
        release_lock(root, &mut st).unwrap();
        let mut third = load(root, "dem-1").unwrap().unwrap();
        assert!(acquire_lock(root, &mut third, "tick-2", "t3").unwrap());
    }

    #[test]
    fn status_repr_round_trip_including_manual() {
        for s in [
            EngineStatus::Queued,
            EngineStatus::AwaitingApproval,
            EngineStatus::ReadyForExec,
            EngineStatus::Manual("techspec".to_string()),
        ] {
            let repr = s.to_repr();
            assert_eq!(EngineStatus::from_repr(&repr).unwrap(), s);
        }
        assert_eq!(
            EngineStatus::Manual("techspec".to_string()).to_repr(),
            "manual:techspec"
        );
        assert!(EngineStatus::from_repr("bogus").is_err());
    }

    #[test]
    fn option_a_never_emits_rich_artifact_state() {
        let safe = ["recorded", "in_progress", "approved", "skipped"];
        for s in [
            EngineStatus::Queued,
            EngineStatus::Orchestrating,
            EngineStatus::AwaitingApproval,
            EngineStatus::ReadyForExec,
            EngineStatus::Paused,
            EngineStatus::Error,
            EngineStatus::Manual("prd".to_string()),
        ] {
            assert!(
                safe.contains(&artifact_state_for_status(&s)),
                "status {s:?} vazou estado rico para o map"
            );
        }
    }

    #[test]
    fn status_serializes_as_string_in_json() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let mut st = fresh(root);
        st.status = EngineStatus::Manual("techspec".to_string());
        save(root, &st).unwrap();
        let raw = std::fs::read_to_string(state_dir(root).join("dem-1.json")).unwrap();
        assert!(raw.contains("\"status\": \"manual:techspec\""));
    }
}