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()
}