sdd-layer 0.12.0

Spec-Driven Development CLI and agent harness
//! Modelo da demanda que entra na fila do orquestrador (SDD-OAD-003).
//!
//! `Demand` é o contrato de dados persistido em `.sdd/queue/<id>.json`.
//! Decisões da Tech Spec v2 honradas aqui:
//! - `schema_version` obrigatório (rejeição de versão divergente fica na fila);
//! - `slug` derivado do título e **validado** contra path traversal;
//! - `id` validado para uso seguro como nome de arquivo.

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

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

/// Limites de tamanho (entrada possivelmente não confiável — SDD-OAD-011).
pub const MAX_TITLE_LEN: usize = 200;
pub const MAX_DESCRIPTION_LEN: usize = 8000;

/// Tipo da demanda — espelha os tipos de item de trabalho (Jira-like).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DemandType {
    Epic,
    Story,
    Task,
    Bug,
}

impl DemandType {
    pub fn parse(value: &str) -> Result<Self> {
        match value.trim().to_lowercase().as_str() {
            "epic" | "epico" | "épico" => Ok(Self::Epic),
            "story" | "historia" | "história" => Ok(Self::Story),
            "task" | "tarefa" => Ok(Self::Task),
            "bug" => Ok(Self::Bug),
            other => bail!("tipo de demanda inválido: {other} (use epic|story|task|bug)"),
        }
    }

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Epic => "epic",
            Self::Story => "story",
            Self::Task => "task",
            Self::Bug => "bug",
        }
    }
}

/// Origem da demanda. `cli` é o único caminho confiável na Fase 1;
/// `jira`/`webhook` são fontes externas (dado NÃO confiável — ver SDD-OAD-011).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DemandSource {
    Cli,
    Jira,
    Webhook,
}

impl DemandSource {
    pub fn parse(value: &str) -> Result<Self> {
        match value.trim().to_lowercase().as_str() {
            "cli" | "" => Ok(Self::Cli),
            "jira" => Ok(Self::Jira),
            "webhook" => Ok(Self::Webhook),
            other => bail!("origem de demanda inválida: {other} (use cli|jira|webhook)"),
        }
    }

    /// Fontes externas devem ser tratadas como dado não confiável no prompt.
    // Consumida ao compor o prompt como dado delimitado (restante da SDD-OAD-011/009).
    #[allow(dead_code)]
    pub fn is_untrusted(self) -> bool {
        matches!(self, Self::Jira | Self::Webhook)
    }
}

/// Status operacional da demanda no motor. Os estados ricos vivem no
/// `.sdd/state/<slug>.json` (SDD-OAD-004), NÃO no campo por-artefato do
/// traceability-map (Opção A da Tech Spec).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DemandStatus {
    #[default]
    Queued,
    Orchestrating,
    AwaitingApproval,
    ReadyForExec,
    Paused,
    Error,
}

/// Demanda enfileirada. Persistida como JSON em `.sdd/queue/<id>.json`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Demand {
    pub schema_version: u32,
    pub id: String,
    #[serde(rename = "type")]
    pub kind: DemandType,
    pub title: String,
    #[serde(default)]
    pub description: String,
    pub source: DemandSource,
    #[serde(default = "default_priority")]
    pub priority: String,
    pub created_at: String,
    pub slug: String,
    #[serde(default)]
    pub status: DemandStatus,
}

fn default_priority() -> String {
    "normal".to_string()
}

impl Demand {
    /// Constrói uma demanda nova (status `Queued`), validando `id`, `title` e
    /// derivando o `slug`. `created_at` é injetado pelo chamador (ex.:
    /// `crate::now_public()`) para manter o domínio testável e determinístico.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        id: impl Into<String>,
        kind: DemandType,
        title: impl Into<String>,
        description: impl Into<String>,
        source: DemandSource,
        priority: Option<String>,
        created_at: impl Into<String>,
    ) -> Result<Self> {
        let id = id.into();
        validate_id(&id)?;
        let title = title.into();
        if title.trim().is_empty() {
            bail!("título da demanda vazio");
        }
        if title.chars().count() > MAX_TITLE_LEN {
            bail!("título da demanda excede {MAX_TITLE_LEN} caracteres");
        }
        let description = description.into();
        if description.chars().count() > MAX_DESCRIPTION_LEN {
            bail!("descrição da demanda excede {MAX_DESCRIPTION_LEN} caracteres");
        }
        let slug = derive_slug(&title)?;
        let priority = priority
            .map(|p| p.trim().to_string())
            .filter(|p| !p.is_empty())
            .unwrap_or_else(default_priority);
        Ok(Self {
            schema_version: DEMAND_SCHEMA_VERSION,
            id,
            kind,
            title,
            description,
            source,
            priority,
            created_at: created_at.into(),
            slug,
            status: DemandStatus::Queued,
        })
    }
}

/// `id` precisa ser seguro como nome de arquivo (`<id>.json`): ASCII
/// alfanumérico, `-` e `_`, não vazio, sem `..`, limite de comprimento.
pub fn validate_id(id: &str) -> Result<()> {
    if id.is_empty() {
        bail!("id de demanda vazio");
    }
    if id.len() > 64 {
        bail!("id de demanda muito longo (máx. 64): {id}");
    }
    if !id
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
    {
        bail!("id de demanda inválido: {id} (use apenas A-Z, a-z, 0-9, '-' e '_')");
    }
    Ok(())
}

/// Deriva o slug do título via `crate::slugify` e valida contra path traversal.
pub fn derive_slug(title: &str) -> Result<String> {
    let slug = crate::slugify(title);
    validate_slug(&slug)?;
    Ok(slug)
}

/// Defesa-em-profundidade: garante que um slug (mesmo desserializado de fonte
/// externa) é seguro para compor caminhos de diretório.
pub fn validate_slug(slug: &str) -> Result<()> {
    if slug.is_empty() {
        bail!("slug vazio");
    }
    if slug.len() > 96 {
        bail!("slug muito longo (máx. 96): {slug}");
    }
    if slug.contains("..") || slug.contains('/') || slug.contains('\\') {
        bail!("slug inseguro (path traversal): {slug}");
    }
    if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
        bail!("slug com caracteres inválidos: {slug}");
    }
    Ok(())
}

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

    fn sample() -> Demand {
        Demand::new(
            "DEM-1",
            DemandType::Story,
            "Implementar a Esteira Autônoma",
            "descrição da demanda",
            DemandSource::Cli,
            None,
            "2026-06-08T00:00:00Z",
        )
        .unwrap()
    }

    #[test]
    fn new_demand_has_defaults_and_derived_slug() {
        let d = sample();
        assert_eq!(d.schema_version, DEMAND_SCHEMA_VERSION);
        assert_eq!(d.status, DemandStatus::Queued);
        assert_eq!(d.priority, "normal");
        assert_eq!(d.slug, "implementar-a-esteira-autonoma");
    }

    #[test]
    fn empty_title_is_rejected() {
        let err = Demand::new(
            "DEM-2",
            DemandType::Task,
            "   ",
            "",
            DemandSource::Cli,
            None,
            "2026-06-08T00:00:00Z",
        )
        .unwrap_err();
        assert!(err.to_string().contains("título"));
    }

    #[test]
    fn oversized_fields_are_rejected() {
        let long_title = "a".repeat(MAX_TITLE_LEN + 1);
        assert!(Demand::new(
            "DEM-X",
            DemandType::Task,
            long_title,
            "",
            DemandSource::Cli,
            None,
            "t"
        )
        .is_err());
        let long_desc = "b".repeat(MAX_DESCRIPTION_LEN + 1);
        assert!(Demand::new(
            "DEM-X",
            DemandType::Task,
            "ok",
            long_desc,
            DemandSource::Cli,
            None,
            "t"
        )
        .is_err());
    }

    #[test]
    fn unsafe_id_is_rejected() {
        for bad in ["../escape", "a/b", "with space", ""] {
            assert!(validate_id(bad).is_err(), "esperava erro para id {bad:?}");
        }
        assert!(validate_id("DEM-123_x").is_ok());
    }

    #[test]
    fn slug_strips_accents_and_blocks_traversal() {
        assert_eq!(derive_slug("Ação & Café").unwrap(), "acao-cafe");
        assert!(validate_slug("..").is_err());
        assert!(validate_slug("a/b").is_err());
        assert!(validate_slug("ok-slug-1").is_ok());
    }

    #[test]
    fn priority_falls_back_to_normal_when_blank() {
        let d = Demand::new(
            "DEM-3",
            DemandType::Bug,
            "Corrigir X",
            "",
            DemandSource::Jira,
            Some("   ".to_string()),
            "2026-06-08T00:00:00Z",
        )
        .unwrap();
        assert_eq!(d.priority, "normal");
        assert!(d.source.is_untrusted());
    }

    #[test]
    fn type_and_source_parsing() {
        assert_eq!(DemandType::parse("Story").unwrap(), DemandType::Story);
        assert_eq!(DemandType::parse("história").unwrap(), DemandType::Story);
        assert!(DemandType::parse("feature").is_err());
        assert_eq!(DemandSource::parse("").unwrap(), DemandSource::Cli);
        assert_eq!(
            DemandSource::parse("webhook").unwrap(),
            DemandSource::Webhook
        );
        assert!(DemandSource::parse("email").is_err());
    }

    #[test]
    fn serde_round_trip_preserves_fields() {
        let d = sample();
        let json = serde_json::to_string_pretty(&d).unwrap();
        assert!(json.contains("\"type\": \"story\""));
        assert!(json.contains("\"status\": \"queued\""));
        let back: Demand = serde_json::from_str(&json).unwrap();
        assert_eq!(d, back);
    }
}