sdd-layer 0.24.0

Spec-Driven Development CLI and agent harness
//! Fila durável de demandas em `.sdd/queue/` (SDD-OAD-003).
//!
//! Contrato: uma demanda por arquivo `<id>.json`. `enqueue` é idempotente por
//! `id` (dedup — RF-12). Escrita atômica (write-temp + rename). Leitura rejeita
//! `schema_version` divergente (Tech Spec v2, seção Dados).

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

use anyhow::{bail, Context, Result};

use super::demand::{validate_id, Demand, DEMAND_SCHEMA_VERSION};

/// Diretório da fila para um root de projeto.
pub fn queue_dir(root: &Path) -> PathBuf {
    root.join(".sdd").join("queue")
}

/// Resultado de `enqueue`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EnqueueOutcome {
    Enqueued,
    DuplicateIgnored,
}

fn demand_path(root: &Path, id: &str) -> Result<PathBuf> {
    validate_id(id)?;
    Ok(queue_dir(root).join(format!("{id}.json")))
}

/// Enfileira uma demanda. Se já existir arquivo para o `id`, não sobrescreve
/// (dedup idempotente) e retorna `DuplicateIgnored`.
pub fn enqueue(root: &Path, demand: &Demand) -> Result<EnqueueOutcome> {
    if demand.schema_version != DEMAND_SCHEMA_VERSION {
        bail!(
            "schema_version de demanda não suportado ao enfileirar: {} (esperado {})",
            demand.schema_version,
            DEMAND_SCHEMA_VERSION
        );
    }
    let path = demand_path(root, &demand.id)?;
    if path.exists() {
        return Ok(EnqueueOutcome::DuplicateIgnored);
    }
    let body = serde_json::to_string_pretty(demand).context("serializando demanda")?;
    super::write_atomic(&path, body.as_bytes())?;
    Ok(EnqueueOutcome::Enqueued)
}

/// Carrega uma demanda por `id`, ou `None` se não existir.
pub fn load(root: &Path, id: &str) -> Result<Option<Demand>> {
    let path = demand_path(root, id)?;
    if !path.exists() {
        return Ok(None);
    }
    let text =
        fs::read_to_string(&path).with_context(|| format!("lendo demanda {}", path.display()))?;
    Ok(Some(parse_demand(&text).with_context(|| {
        format!("desserializando demanda {}", path.display())
    })?))
}

/// Lista as demandas da fila, ordenadas por nome de arquivo (`id`).
pub fn list(root: &Path) -> Result<Vec<Demand>> {
    let dir = queue_dir(root);
    let mut out = Vec::new();
    let Ok(entries) = fs::read_dir(&dir) else {
        return Ok(out);
    };
    let mut paths: Vec<PathBuf> = entries
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))
        .collect();
    paths.sort();
    for path in paths {
        let text = fs::read_to_string(&path)
            .with_context(|| format!("lendo demanda {}", path.display()))?;
        out.push(
            parse_demand(&text)
                .with_context(|| format!("desserializando demanda {}", path.display()))?,
        );
    }
    Ok(out)
}

fn parse_demand(text: &str) -> Result<Demand> {
    let demand: Demand = serde_json::from_str(text)?;
    if demand.schema_version != DEMAND_SCHEMA_VERSION {
        bail!(
            "schema_version de demanda não suportado: {} (esperado {})",
            demand.schema_version,
            DEMAND_SCHEMA_VERSION
        );
    }
    Ok(demand)
}

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

    fn demand(id: &str) -> Demand {
        Demand::new(
            id,
            DemandType::Story,
            "Título da demanda",
            "desc",
            DemandSource::Cli,
            None,
            "2026-06-08T00:00:00Z",
        )
        .unwrap()
    }

    #[test]
    fn enqueue_then_load_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        assert_eq!(
            enqueue(root, &demand("DEM-1")).unwrap(),
            EnqueueOutcome::Enqueued
        );
        assert!(queue_dir(root).join("DEM-1.json").is_file());
        let loaded = load(root, "DEM-1").unwrap().unwrap();
        assert_eq!(loaded.id, "DEM-1");
        assert_eq!(loaded.slug, "titulo-da-demanda");
    }

    #[test]
    fn enqueue_is_idempotent_by_id() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        enqueue(root, &demand("DEM-1")).unwrap();
        // Segunda tentativa com mesmo id não sobrescreve.
        let again = enqueue(root, &demand("DEM-1")).unwrap();
        assert_eq!(again, EnqueueOutcome::DuplicateIgnored);
        assert_eq!(list(root).unwrap().len(), 1);
    }

    #[test]
    fn list_returns_sorted_demands() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        enqueue(root, &demand("DEM-2")).unwrap();
        enqueue(root, &demand("DEM-1")).unwrap();
        let ids: Vec<String> = list(root).unwrap().into_iter().map(|d| d.id).collect();
        assert_eq!(ids, vec!["DEM-1".to_string(), "DEM-2".to_string()]);
    }

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

    #[test]
    fn list_empty_when_no_queue_dir() {
        let dir = tempfile::tempdir().unwrap();
        assert!(list(dir.path()).unwrap().is_empty());
    }

    #[test]
    fn divergent_schema_version_is_rejected() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        fs::create_dir_all(queue_dir(root)).unwrap();
        // Demanda com schema_version inválido escrita à mão.
        let bad = r#"{ "schema_version": 99, "id": "DEM-9", "type": "task",
            "title": "x", "description": "", "source": "cli", "priority": "normal",
            "created_at": "2026-06-08T00:00:00Z", "slug": "x", "status": "queued" }"#;
        fs::write(queue_dir(root).join("DEM-9.json"), bad).unwrap();
        let err = load(root, "DEM-9").unwrap_err();
        assert!(
            err.to_string().contains("schema_version")
                || format!("{err:#}").contains("schema_version")
        );
    }

    #[test]
    fn enqueue_rejects_unsafe_id_via_validate() {
        let dir = tempfile::tempdir().unwrap();
        // id inseguro nunca chega a escrever fora da fila.
        assert!(load(dir.path(), "../escape").is_err());
    }
}