sdd-layer 0.24.0

Spec-Driven Development CLI and agent harness
use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

const INCLUDE_ROOTS: &[&str] = &[
    "AGENTS.md",
    "CLAUDE.md",
    ".agents",
    ".claude",
    ".codex",
    ".cursor",
    ".devin",
    ".kiro",
    ".opencode",
    ".trae",
    "bot",
    "plugins",
    "schemas",
    "templates",
    "docs",
    "clients",
    "presets",
    "adapters",
    "tests",
    "examples",
    ".github/workflows/sdd.yml",
    "VERSION",
    "CHANGELOG.md",
    "sdd.config.example.yaml",
];

/// Componentes de caminho dentro de `bot/` que devem ser excluídos do embedding.
/// Nunca embutir artefatos gerados como node_modules ou dist.
const BOT_EMBED_EXCLUDES: &[&str] = &["node_modules", "dist"];

fn main() -> io::Result<()> {
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
    let generated = out_dir.join("embedded.rs");
    let mut files = Vec::new();

    println!("cargo:rerun-if-changed=build.rs");
    for rel in INCLUDE_ROOTS {
        collect_files(&manifest_dir, Path::new(rel), &mut files)?;
    }

    files.sort();
    let mut output = String::from("pub const EMBEDDED_FILES: &[(&str, &[u8])] = &[\n");
    for rel in files {
        let abs = manifest_dir.join(&rel);
        output.push_str(&format!(
            "    ({rel:?}, include_bytes!({abs:?})),\n",
            rel = rel.to_string_lossy().replace('\\', "/"),
            abs = abs.to_string_lossy()
        ));
    }
    output.push_str("];\n");

    let mut file = fs::File::create(generated)?;
    file.write_all(output.as_bytes())?;
    Ok(())
}

fn collect_files(root: &Path, rel: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
    let path = root.join(rel);
    if !path.exists() {
        return Ok(());
    }
    if ignored(rel) {
        return Ok(());
    }
    println!("cargo:rerun-if-changed={}", path.display());
    if is_generated_docs_dir(rel, &path) {
        return Ok(());
    }
    if path.is_file() {
        files.push(rel.to_path_buf());
        return Ok(());
    }
    for entry in fs::read_dir(path)? {
        let entry = entry?;
        collect_files(root, &rel.join(entry.file_name()), files)?;
    }
    Ok(())
}

fn ignored(rel: &Path) -> bool {
    // Configs MCP carregam o path absoluto da máquina que rodou install/update;
    // nunca embutir (são gerados por projeto em runtime via render_mcp_json).
    let normalized = rel.to_string_lossy().replace('\\', "/");
    if normalized == ".mcp.json" || normalized == ".cursor/mcp.json" {
        return true;
    }
    if is_agent_worktree_state(rel) {
        return true;
    }
    if is_bot_generated(rel) {
        return true;
    }
    rel.components().any(|component| {
        let name = component.as_os_str().to_string_lossy();
        matches!(name.as_ref(), ".DS_Store" | ".git" | ".sdd" | "target")
    })
}

/// Exclui artefatos gerados dentro de bot/ (node_modules, dist) do embedding.
fn is_bot_generated(rel: &Path) -> bool {
    let mut components = rel
        .components()
        .map(|c| c.as_os_str().to_string_lossy().into_owned());
    if components.next().as_deref() != Some("bot") {
        return false;
    }
    if let Some(second) = components.next() {
        return BOT_EMBED_EXCLUDES.contains(&second.as_str());
    }
    false
}

fn is_agent_worktree_state(rel: &Path) -> bool {
    let mut components = rel
        .components()
        .map(|component| component.as_os_str().to_string_lossy().into_owned());
    matches!(
        (components.next().as_deref(), components.next().as_deref()),
        (Some(".claude"), Some("worktrees"))
    )
}

fn is_generated_docs_dir(rel: &Path, path: &Path) -> bool {
    if !path.is_dir() {
        return false;
    }
    let components: Vec<_> = rel.components().collect();
    components.len() == 2 && components[0].as_os_str() == "docs"
}