use std::path::{Path, PathBuf};
pub fn structural_violations(
code_paths: &[PathBuf],
forbidden: &str,
file_glob: &str,
) -> Vec<String> {
let mut violations = Vec::new();
for base in code_paths {
let mut files = Vec::new();
collect_files(base, &mut files);
for f in &files {
let rel = f
.strip_prefix(base)
.unwrap_or(f)
.to_string_lossy()
.replace('\\', "/");
if !glob_matches(file_glob, &rel) {
continue;
}
if let Ok(content) = std::fs::read_to_string(f)
&& content.contains(forbidden)
{
violations.push(rel);
}
}
}
violations.sort();
violations.dedup();
violations
}
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) {
if dir.is_file() {
out.push(dir.to_path_buf());
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "target" || name == ".git" {
continue;
}
collect_files(&path, out);
} else {
out.push(path);
}
}
}
fn glob_matches(glob: &str, path: &str) -> bool {
if glob == "**" || glob == "**/*" {
return true;
}
if let Some(prefix) = glob.strip_suffix("/**") {
return path == prefix || path.starts_with(&format!("{prefix}/"));
}
if let Some(prefix) = glob.strip_suffix("**") {
return path.starts_with(prefix);
}
if let Some(prefix) = glob.strip_suffix('*') {
return path.starts_with(prefix);
}
glob == path
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_tree(files: &[(&str, &str)]) -> PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("agent_spec_struct_{stamp}"));
for (rel, content) in files {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, content).unwrap();
}
root
}
#[test]
fn test_structural_flags_forbidden_reference() {
let root = temp_tree(&[("clients/a.rs", "use crate::services::X;\nfn f() {}\n")]);
let v = structural_violations(std::slice::from_ref(&root), "crate::services", "clients/**");
assert!(v.iter().any(|p| p == "clients/a.rs"), "got {v:?}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn test_structural_no_violation_returns_empty() {
let root = temp_tree(&[("clients/a.rs", "fn clean() {}\n")]);
let v = structural_violations(std::slice::from_ref(&root), "crate::services", "clients/**");
assert!(v.is_empty(), "got {v:?}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn test_structural_respects_glob_scope() {
let root = temp_tree(&[("services/b.rs", "use crate::services::X;\n")]);
let v = structural_violations(std::slice::from_ref(&root), "crate::services", "clients/**");
assert!(!v.iter().any(|p| p == "services/b.rs"), "got {v:?}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn test_structural_skips_target_dir() {
let root = temp_tree(&[("target/x.rs", "crate::services\n")]);
let v = structural_violations(std::slice::from_ref(&root), "crate::services", "**");
assert!(!v.iter().any(|p| p.contains("target")), "got {v:?}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn test_check_structure_reports_violations() {
let root = temp_tree(&[("clients/a.rs", "use crate::services::X;\n")]);
let v = structural_violations(std::slice::from_ref(&root), "crate::services", "clients/**");
assert!(!v.is_empty());
let _ = std::fs::remove_dir_all(&root);
}
}