sdd-layer 0.24.5

Spec-Driven Development CLI and agent harness
use serde::Serialize;
use std::path::Path;
use walkdir::WalkDir;

use crate::{domain::providers::ContextMaterializationConfig, relative_path, slugify};

pub const DEFAULT_MAX_BOUNDARIES: usize = 4;

#[derive(Clone, Debug, Serialize)]
pub struct AgentContextProfile {
    pub id: String,
    pub path: String,
    pub kind: String,
    pub evidence: Vec<String>,
    pub confidence: String,
    pub materialized: bool,
}

#[derive(Clone, Debug, Serialize)]
pub struct AgentContextFileBudget {
    pub max_boundaries: usize,
    pub planned_local_agents: usize,
}

#[derive(Clone, Debug, Serialize)]
pub struct AgentContextStatus {
    pub policy: String,
    pub profile_count: usize,
    pub local_agents_existing: Vec<String>,
    pub local_agents_planned: Vec<String>,
    pub warnings: Vec<String>,
}

pub fn merged_excludes(config: &ContextMaterializationConfig) -> Vec<String> {
    let mut excludes = config.excludes.clone();
    for item in [
        ".agents",
        ".codex",
        ".claude",
        ".cursor",
        ".devin",
        ".kiro",
        ".opencode",
        ".trae",
        ".sdd",
        ".codegraph",
        ".cache",
        ".git",
        "target",
        "node_modules",
        "dist",
        "build",
        ".next",
        "coverage",
        "tmp",
        "temp",
        "vendor",
    ] {
        if !excludes.iter().any(|exclude| exclude == item) {
            excludes.push(item.to_string());
        }
    }
    excludes
}

pub fn path_excluded(path: &str, excludes: &[String]) -> bool {
    path.split('/')
        .any(|part| excludes.iter().any(|exclude| exclude == part))
        || excludes.iter().any(|exclude| exclude == path)
}

pub fn has_context_manifest(path: &Path) -> bool {
    [
        "package.json",
        "Cargo.toml",
        "pyproject.toml",
        "requirements.txt",
        "go.mod",
        "pom.xml",
        "build.gradle",
        "build.gradle.kts",
    ]
    .iter()
    .any(|item| path.join(item).is_file())
}

pub fn policy(config_local_agents: bool, cli_local_agents: bool) -> String {
    if config_local_agents {
        "scoped_config".to_string()
    } else if cli_local_agents {
        "scoped_opt_in".to_string()
    } else {
        "root_only".to_string()
    }
}

pub fn filter_materializable_profiles(
    profiles: &[AgentContextProfile],
    max_boundaries: usize,
) -> Vec<AgentContextProfile> {
    let mut selected = Vec::new();
    let mut candidates = profiles
        .iter()
        .filter(|profile| profile.confidence == "alta")
        .cloned()
        .collect::<Vec<_>>();
    candidates.sort_by(|a, b| {
        path_depth(&b.path)
            .cmp(&path_depth(&a.path))
            .then_with(|| a.path.cmp(&b.path))
    });
    for profile in candidates {
        if selected
            .iter()
            .any(|item: &AgentContextProfile| same_branch(&item.path, &profile.path))
        {
            continue;
        }
        selected.push(profile.clone());
        if selected.len() >= max_boundaries.max(1) {
            break;
        }
    }
    selected
}

pub fn file_budget(max_boundaries: usize, planned_local_agents: usize) -> AgentContextFileBudget {
    AgentContextFileBudget {
        max_boundaries: max_boundaries.max(1),
        planned_local_agents,
    }
}

pub fn status(
    root: &Path,
    policy: String,
    profiles: &[AgentContextProfile],
    planned_local_agents: &[AgentContextProfile],
    max_boundaries: usize,
) -> AgentContextStatus {
    let local_agents_existing = existing_local_agents(root);
    let local_agents_planned = planned_local_agents
        .iter()
        .map(|profile| format!("{}/AGENTS.md", profile.path))
        .collect::<Vec<_>>();
    let mut warnings = Vec::new();
    if policy == "root_only" && !local_agents_planned.is_empty() {
        warnings.push("root_only policy should not plan local AGENTS.md files".to_string());
    }
    if local_agents_planned.len() > max_boundaries.max(1) {
        warnings.push(format!(
            "planned local AGENTS.md files exceed max_boundaries={}",
            max_boundaries.max(1)
        ));
    }
    AgentContextStatus {
        policy,
        profile_count: profiles.len(),
        local_agents_existing,
        local_agents_planned,
        warnings,
    }
}

pub fn existing_local_agents(root: &Path) -> Vec<String> {
    let mut files = Vec::new();
    for entry in WalkDir::new(root)
        .max_depth(4)
        .into_iter()
        .filter_entry(|entry| {
            let rel = relative_path(root, entry.path());
            !path_excluded(
                &rel,
                &merged_excludes(&ContextMaterializationConfig::default()),
            )
        })
        .filter_map(|entry| entry.ok())
        .filter(|entry| entry.file_type().is_file())
    {
        if entry.file_name().to_str() != Some("AGENTS.md") || entry.path() == root.join("AGENTS.md")
        {
            continue;
        }
        files.push(relative_path(root, entry.path()));
    }
    files.sort();
    files
}

pub fn candidate_profile(
    root: &Path,
    path: &str,
    kind: &str,
    mut evidence: Vec<String>,
) -> Option<AgentContextProfile> {
    if !root.join(path).exists() {
        return None;
    }
    if evidence.is_empty() {
        evidence.push(format!("path `{path}` existe"));
    }
    evidence.sort();
    evidence.dedup();
    let confidence = if has_context_manifest(&root.join(path)) || evidence.len() >= 2 {
        "alta"
    } else {
        "média"
    };
    Some(AgentContextProfile {
        id: slugify(path),
        path: path.to_string(),
        kind: kind.to_string(),
        evidence,
        confidence: confidence.to_string(),
        materialized: root.join(path).join("AGENTS.md").is_file(),
    })
}

fn is_ancestor(parent: &str, child: &str) -> bool {
    child
        .strip_prefix(parent)
        .is_some_and(|suffix| suffix.starts_with('/'))
}

fn same_branch(left: &str, right: &str) -> bool {
    left == right || is_ancestor(left, right) || is_ancestor(right, left)
}

fn path_depth(path: &str) -> usize {
    path.split('/').count()
}