use normalize_output::diagnostics::{DiagnosticsReport, Issue, Severity, ToolFailure};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct Boundary {
pub from_glob: String,
pub to_glob: String,
pub raw: String,
}
#[derive(serde::Deserialize, Default, Debug)]
pub struct BoundaryViolationsConfig {
#[serde(default)]
pub boundaries: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BoundaryViolationFinding {
pub importer: String,
pub line: u32,
pub imported: String,
pub boundary: String,
}
pub fn parse_boundary(s: &str) -> Option<Boundary> {
let sep = " cannot import ";
let pos = s.find(sep)?;
let from_glob = s[..pos].trim().to_string();
let to_glob = s[pos + sep.len()..].trim().to_string();
if from_glob.is_empty() || to_glob.is_empty() {
return None;
}
Some(Boundary {
from_glob,
to_glob,
raw: s.to_string(),
})
}
fn compile_glob(raw: &str) -> Option<glob::Pattern> {
let expanded = if raw.ends_with('/') && !raw.contains('*') {
format!("{}**", raw)
} else {
raw.to_string()
};
glob::Pattern::new(&expanded).ok()
}
fn matches_glob(pattern: &glob::Pattern, path: &str) -> bool {
pattern.matches(path)
|| pattern.matches_with(
path,
glob::MatchOptions {
case_sensitive: true,
require_literal_separator: false,
require_literal_leading_dot: false,
},
)
}
pub async fn build_boundary_violations_report(
root: &Path,
boundaries: &[Boundary],
) -> DiagnosticsReport {
let mut report = DiagnosticsReport::new();
if boundaries.is_empty() {
return report;
}
let compiled: Vec<(glob::Pattern, glob::Pattern, &Boundary)> = boundaries
.iter()
.filter_map(|b| {
let from_pat = compile_glob(&b.from_glob)?;
let to_pat = compile_glob(&b.to_glob)?;
Some((from_pat, to_pat, b))
})
.collect();
if compiled.is_empty() {
return report;
}
let db_path = crate::check_refs::normalize_dir_for_root(root).join("index.sqlite");
let idx = match normalize_facts::FileIndex::open(&db_path, root).await {
Ok(idx) => idx,
Err(e) => {
report.tool_errors.push(ToolFailure {
tool: "boundary-violations".into(),
message: format!(
"failed to open index at {}: {}. Run `normalize structure rebuild` first.",
db_path.display(),
e
),
});
return report;
}
};
let edges = match idx.all_resolved_imports_with_lines().await {
Ok(edges) => edges,
Err(e) => {
report.tool_errors.push(ToolFailure {
tool: "boundary-violations".into(),
message: format!("failed to query imports table: {e}"),
});
return report;
}
};
for (importer, line, imported) in &edges {
for (from_pat, to_pat, boundary) in &compiled {
if matches_glob(from_pat, importer) && matches_glob(to_pat, imported) {
report.issues.push(Issue {
file: importer.clone(),
line: Some(*line as usize),
column: None,
end_line: None,
end_column: None,
rule_id: "boundary-violations".into(),
message: format!(
"imports `{}` — violates boundary: {}",
imported, boundary.raw
),
severity: Severity::Warning,
source: "boundary-violations".into(),
related: vec![],
suggestion: Some(
"move shared code to a layer both sides may depend on, or revise the boundary".into(),
),
});
}
}
}
report.files_checked = edges
.iter()
.map(|(f, _, _)| f.as_str())
.collect::<std::collections::HashSet<_>>()
.len();
report.sources_run.push("boundary-violations".into());
report
}