sdd-layer 0.20.0

Spec-Driven Development CLI and agent harness
//! Escrita atômica (T-17) e persistência de aprovação via subcomando (T-18).

use anyhow::{Context, Result};
use std::path::Path;
use std::process::Command;

/// Escreve `content` em `path` de forma atômica: grava num arquivo temporário no
/// mesmo diretório e renomeia por cima. `rename` no mesmo filesystem é atômico,
/// então uma interrupção durante a escrita preserva o conteúdo anterior (RNF-06).
pub fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
    let parent = path
        .parent()
        .filter(|p| !p.as_os_str().is_empty())
        .map(Path::to_path_buf)
        .unwrap_or_else(|| Path::new(".").to_path_buf());
    std::fs::create_dir_all(&parent)
        .with_context(|| format!("criando diretório {}", parent.display()))?;

    // Nome de tmp determinístico por arquivo-alvo, no MESMO diretório.
    let file_name = path
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_else(|| "artifact".to_string());
    let tmp = parent.join(format!(".{file_name}.sdd-tmp"));

    std::fs::write(&tmp, content)
        .with_context(|| format!("escrevendo arquivo temporário {}", tmp.display()))?;
    std::fs::rename(&tmp, path)
        .with_context(|| format!("renomeando {} -> {}", tmp.display(), path.display()))?;
    Ok(())
}

/// Invoca `sdd artifact save` como processo filho para registrar o estado de uma
/// etapa no traceability-map, reusando o único ponto de mutação do harness (RF-09).
/// `state` deve ser "approved" ou "skipped". Nunca usa shell (sem injeção).
pub fn save_stage_state(
    exe: &Path,
    root: &Path,
    name: &str,
    stage: &str,
    file: &Path,
    state: &str,
) -> Result<()> {
    let output = build_save_stage_state_command(exe, root, name, stage, file, state)
        .output()
        .with_context(|| format!("invocando artifact save para {stage}"))?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        anyhow::bail!("artifact save falhou ({stage}): {}", stderr.trim());
    }
    Ok(())
}

fn build_save_stage_state_command(
    exe: &Path,
    root: &Path,
    name: &str,
    stage: &str,
    file: &Path,
    state: &str,
) -> Command {
    let mut cmd = Command::new(exe);
    cmd.arg("artifact")
        .arg("--root")
        .arg(root)
        .arg("save")
        .arg(name)
        .arg(stage)
        .arg("--file")
        .arg(file)
        .arg("--state")
        .arg(state)
        .arg("--force");
    cmd
}

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

    #[test]
    fn writes_content() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("a.md");
        write_atomic(&target, b"hello").unwrap();
        assert_eq!(fs::read_to_string(&target).unwrap(), "hello");
    }

    #[test]
    fn overwrites_existing() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("a.md");
        write_atomic(&target, b"v1").unwrap();
        write_atomic(&target, b"v2").unwrap();
        assert_eq!(fs::read_to_string(&target).unwrap(), "v2");
    }

    #[test]
    fn stale_tmp_does_not_corrupt_target() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("a.md");
        write_atomic(&target, b"good").unwrap();
        // Simula um .tmp órfão de uma execução interrompida anterior.
        let tmp = dir.path().join(".a.md.sdd-tmp");
        fs::write(&tmp, b"garbage-interrupted").unwrap();
        // Nova escrita deve sobrescrever o tmp e renomear sem corromper.
        write_atomic(&target, b"good2").unwrap();
        assert_eq!(fs::read_to_string(&target).unwrap(), "good2");
        assert!(!tmp.exists(), "tmp deve ter sido consumido pelo rename");
    }

    #[test]
    fn creates_missing_parent() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("nested/deep/a.md");
        write_atomic(&target, b"x").unwrap();
        assert!(target.exists());
    }

    #[test]
    fn save_stage_state_places_root_before_save_subcommand() {
        let cmd = build_save_stage_state_command(
            Path::new("/usr/bin/sdd"),
            Path::new("/tmp/project"),
            "Minha Feature",
            "idea",
            Path::new("/tmp/project/docs/minha-feature/01-idea.md"),
            "approved",
        );
        let args: Vec<String> = cmd
            .get_args()
            .map(|arg| arg.to_string_lossy().into_owned())
            .collect();
        assert_eq!(args[0], "artifact");
        assert_eq!(args[1], "--root");
        assert_eq!(args[2], "/tmp/project");
        assert_eq!(args[3], "save");
        assert_eq!(args[4], "Minha Feature");
        assert_eq!(args[5], "idea");
        assert!(args.contains(&"--force".to_string()));
    }
}