use miette::Result;
use rayon::prelude::*;
use std::path::Path;
use crate::config::{ArchitectureConfig, load_config};
use crate::parser::{ProjectFile, discover_projects};
use crate::report::{CheckReport, Violation};
use crate::rules::{is_ignored, resolve_layer, resolve_layer_by_namespace};
use crate::scanner;
pub fn collect(root: &Path, config: &ArchitectureConfig) -> Result<CheckReport> {
let project_paths = discover_projects(root)?;
tracing::info!("Discovered {} projects", project_paths.len());
let projects: Vec<ProjectFile> = project_paths
.par_iter()
.filter_map(|p| match ProjectFile::parse(p) {
Ok(pf) => Some(pf),
Err(e) => {
tracing::warn!("Skipping {:?}: {e}", p);
None
}
})
.collect();
let mut report = CheckReport::new();
check_dependency_rules(&projects, config, &mut report);
check_package_policies(&projects, config, &mut report);
check_source_rules(root, config, &mut report)?;
Ok(report)
}
pub fn run(root: &str, config_path: &str, strict: bool, no_baseline: bool) -> Result<()> {
let root_path = Path::new(root);
let config = load_config(Path::new(config_path))?;
let mut report = collect(root_path, &config)?;
if !no_baseline {
let baseline_path = root_path.join("ark-baseline.json");
if let Some(baseline) = crate::baseline::try_load(&baseline_path) {
let (filtered_violations, filtered_keys) = apply_baseline(
report.violations,
report.violation_keys,
&baseline,
&mut report.warnings,
);
report.violations = filtered_violations;
report.violation_keys = filtered_keys;
}
}
report.print_summary();
if !report.violations.is_empty() || (strict && !report.warnings.is_empty()) {
for v in report.violations {
eprintln!("{:?}", miette::Report::new_boxed(Box::new(v)));
}
std::process::exit(1);
}
Ok(())
}
fn apply_baseline(
violations: Vec<Violation>,
violation_keys: Vec<crate::baseline::BaselineEntry>,
baseline: &[crate::baseline::BaselineEntry],
warnings: &mut Vec<String>,
) -> (Vec<Violation>, Vec<crate::baseline::BaselineEntry>) {
for entry in baseline {
if !violation_keys.contains(entry) {
warnings.push(format!(
"Stale baseline entry ({} {} → {}) — violation no longer exists",
entry.kind, entry.from, entry.to
));
}
}
violations
.into_iter()
.zip(violation_keys)
.filter(|(_, key)| !baseline.contains(key))
.unzip()
}
#[cfg(test)]
mod baseline_tests {
use super::*;
use crate::baseline::BaselineEntry;
fn entry(kind: &str, from: &str, to: &str) -> BaselineEntry {
BaselineEntry {
kind: kind.to_string(),
from: from.to_string(),
to: to.to_string(),
}
}
fn make_violation() -> Violation {
Violation {
message: "test".to_string(),
src: miette::NamedSource::new("test.csproj", "content".to_string()),
span: (0, 1).into(),
}
}
#[test]
fn filters_matching_violations() {
let baseline = vec![entry("project_ref", "A", "B")];
let violations = vec![make_violation(), make_violation()];
let keys = vec![
entry("project_ref", "A", "B"),
entry("project_ref", "C", "D"),
];
let mut warnings = vec![];
let (remaining, _) = apply_baseline(violations, keys, &baseline, &mut warnings);
assert_eq!(remaining.len(), 1);
assert!(warnings.is_empty());
}
#[test]
fn warns_on_stale_entry() {
let baseline = vec![entry("project_ref", "A", "B")];
let mut warnings = vec![];
let (remaining, _) = apply_baseline(vec![], vec![], &baseline, &mut warnings);
assert!(remaining.is_empty());
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("Stale"));
}
}
fn check_dependency_rules(
projects: &[ProjectFile],
config: &ArchitectureConfig,
report: &mut CheckReport,
) {
for project in projects {
if is_ignored(&project.name, &config.ignore_patterns) {
continue;
}
let Some(from_layer) = resolve_layer(&project.name, &config.layers) else {
report.warnings.push(format!(
"Project '{}' does not match any layer pattern",
project.name
));
continue;
};
for pref in &project.project_refs {
let dep_name = pref
.resolved
.as_ref()
.and_then(|r| r.file_stem())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| pref.include.clone());
let Some(to_layer) = resolve_layer(&dep_name, &config.layers) else {
continue;
};
if from_layer.name == to_layer.name {
continue;
}
let allowed = config
.dependency_rules
.iter()
.find(|r| r.from == from_layer.name && r.to == to_layer.name)
.map(|r| r.allowed)
.unwrap_or(false);
if !allowed {
let src = std::fs::read_to_string(&project.path).unwrap_or_default();
report.violations.push(Violation {
message: format!(
"Layer '{}' ({}) must not depend on layer '{}' ({})",
from_layer.name, project.name, to_layer.name, dep_name
),
src: miette::NamedSource::new(project.path.to_string_lossy(), src),
span: pref.include_span.into(),
});
report.violation_keys.push(crate::baseline::BaselineEntry {
kind: "project_ref".to_string(),
from: project.name.clone(),
to: dep_name.clone(),
});
}
}
}
}
fn check_source_rules(
root: &Path,
config: &ArchitectureConfig,
report: &mut CheckReport,
) -> Result<()> {
if config
.layers
.iter()
.all(|l| l.namespace_patterns.is_empty())
{
tracing::debug!("No namespace_patterns defined — skipping source scan");
return Ok(());
}
let headers = scanner::scan_directory(root)?;
tracing::info!("Source scan: {} .cs files", headers.len());
for header in &headers {
let Some(ns) = &header.namespace else {
continue;
};
let Some(from_layer) = resolve_layer_by_namespace(ns, &config.layers) else {
continue;
};
for using in &header.usings {
let Some(to_layer) = resolve_layer_by_namespace(&using.namespace, &config.layers)
else {
continue;
};
if from_layer.name == to_layer.name {
continue;
}
let allowed = config
.dependency_rules
.iter()
.find(|r| r.from == from_layer.name && r.to == to_layer.name)
.map(|r| r.allowed)
.unwrap_or(false);
if !allowed {
let src = std::fs::read_to_string(&header.path).unwrap_or_default();
report.violations.push(Violation {
message: format!(
"Source: layer '{}' must not use '{}' from layer '{}'",
from_layer.name, using.namespace, to_layer.name,
),
src: miette::NamedSource::new(header.path.to_string_lossy(), src),
span: (using.start_byte, using.end_byte - using.start_byte).into(),
});
report.violation_keys.push(crate::baseline::BaselineEntry {
kind: "source".to_string(),
from: ns.to_string(),
to: using.namespace.clone(),
});
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::model::{ArchitectureConfig, DependencyRule, Layer, PackagePolicy};
use crate::parser::csproj::{PackageRef, ProjectRef};
fn make_config(
layers: &[(&str, &[&str])],
rules: &[(&str, &str, bool)],
policies: &[(&str, &[&str])],
) -> ArchitectureConfig {
ArchitectureConfig {
layers: layers
.iter()
.map(|(name, pats)| Layer {
name: name.to_string(),
patterns: pats.iter().map(|s| s.to_string()).collect(),
namespace_patterns: vec![],
})
.collect(),
dependency_rules: rules
.iter()
.map(|(from, to, allowed)| DependencyRule {
from: from.to_string(),
to: to.to_string(),
allowed: *allowed,
})
.collect(),
package_policies: policies
.iter()
.map(|(layer, pkgs)| PackagePolicy {
layer: layer.to_string(),
forbidden: pkgs.iter().map(|s| s.to_string()).collect(),
})
.collect(),
ignore_patterns: vec![],
}
}
fn make_project(name: &str, refs: &[&str], packages: &[(&str, &str)]) -> ProjectFile {
ProjectFile {
path: std::path::PathBuf::from(format!("{name}.csproj")),
name: name.to_string(),
project_refs: refs
.iter()
.map(|r| ProjectRef::new(r.to_string(), None))
.collect(),
package_refs: packages
.iter()
.map(|(n, v)| PackageRef::new(n.to_string(), v.to_string()))
.collect(),
}
}
#[test]
fn resolve_layer_exact_pattern() {
let layers = vec![Layer {
name: "Api".to_string(),
patterns: vec!["MyApp.Api".to_string()],
namespace_patterns: vec![],
}];
assert_eq!(resolve_layer("MyApp.Api", &layers).unwrap().name, "Api");
}
#[test]
fn resolve_layer_glob_wildcard() {
let layers = vec![Layer {
name: "Domain".to_string(),
patterns: vec!["*.Domain".to_string()],
namespace_patterns: vec![],
}];
assert!(resolve_layer("MyApp.Domain", &layers).is_some());
assert!(resolve_layer("OtherApp.Domain", &layers).is_some());
assert!(resolve_layer("MyApp.Api", &layers).is_none());
}
#[test]
fn resolve_layer_returns_none_when_unmatched() {
let layers = vec![Layer {
name: "Api".to_string(),
patterns: vec!["*.Api".to_string()],
namespace_patterns: vec![],
}];
assert!(resolve_layer("MyApp.Infrastructure", &layers).is_none());
}
#[test]
fn resolve_layer_returns_first_matching_layer() {
let layers = vec![
Layer {
name: "First".to_string(),
patterns: vec!["*.Shared".to_string()],
namespace_patterns: vec![],
},
Layer {
name: "Second".to_string(),
patterns: vec!["*.Shared".to_string()],
namespace_patterns: vec![],
},
];
assert_eq!(
resolve_layer("MyApp.Shared", &layers).unwrap().name,
"First"
);
}
#[test]
fn allowed_dependency_no_violation() {
let config = make_config(
&[("Presentation", &["*.Api"]), ("Domain", &["*.Domain"])],
&[("Presentation", "Domain", true)],
&[],
);
let projects = [make_project("MyApp.Api", &["MyApp.Domain"], &[])];
let mut report = CheckReport::new();
check_dependency_rules(&projects, &config, &mut report);
assert!(report.violations.is_empty());
}
#[test]
fn forbidden_dependency_produces_violation() {
let config = make_config(
&[
("Domain", &["*.Domain"]),
("Infrastructure", &["*.Infrastructure"]),
],
&[("Domain", "Infrastructure", false)],
&[],
);
let projects = [make_project("MyApp.Domain", &["MyApp.Infrastructure"], &[])];
let mut report = CheckReport::new();
check_dependency_rules(&projects, &config, &mut report);
assert_eq!(report.violations.len(), 1);
assert!(report.violations[0].message.contains("Domain"));
assert!(report.violations[0].message.contains("Infrastructure"));
}
#[test]
fn no_matching_rule_defaults_to_forbidden() {
let config = make_config(
&[("Presentation", &["*.Api"]), ("Domain", &["*.Domain"])],
&[], &[],
);
let projects = [make_project("MyApp.Api", &["MyApp.Domain"], &[])];
let mut report = CheckReport::new();
check_dependency_rules(&projects, &config, &mut report);
assert_eq!(report.violations.len(), 1);
}
#[test]
fn unmatched_project_adds_warning_not_violation() {
let config = make_config(&[("Domain", &["*.Domain"])], &[], &[]);
let projects = [make_project("MyApp.Utilities", &[], &[])];
let mut report = CheckReport::new();
check_dependency_rules(&projects, &config, &mut report);
assert!(report.violations.is_empty());
assert_eq!(report.warnings.len(), 1);
assert!(report.warnings[0].contains("MyApp.Utilities"));
}
#[test]
fn multiple_forbidden_refs_all_reported() {
let config = make_config(
&[
("Domain", &["*.Domain"]),
("Infrastructure", &["*.Infrastructure"]),
],
&[("Domain", "Infrastructure", false)],
&[],
);
let projects = [make_project(
"MyApp.Domain",
&["MyApp.Infrastructure", "Other.Infrastructure"],
&[],
)];
let mut report = CheckReport::new();
check_dependency_rules(&projects, &config, &mut report);
assert_eq!(report.violations.len(), 2);
}
#[test]
fn dep_to_unmatched_layer_skipped() {
let config = make_config(&[("Api", &["*.Api"])], &[], &[]);
let projects = [make_project("MyApp.Api", &["Some.ExternalLib"], &[])];
let mut report = CheckReport::new();
check_dependency_rules(&projects, &config, &mut report);
assert!(report.violations.is_empty());
assert!(report.warnings.is_empty());
}
#[test]
fn intra_layer_dependency_never_a_violation() {
let config = make_config(
&[("Domain", &["*.Domain", "*.Domain.*"])],
&[], &[],
);
let projects = [make_project(
"MyCompany.Domain",
&["MyCompany.Domain.Shared"],
&[],
)];
let mut report = CheckReport::new();
check_dependency_rules(&projects, &config, &mut report);
assert!(
report.violations.is_empty(),
"intra-layer ref should not be a violation"
);
}
#[test]
fn forbidden_package_produces_violation() {
let config = make_config(
&[("Domain", &["*.Domain"])],
&[],
&[("Domain", &["Microsoft.EntityFrameworkCore"])],
);
let projects = [make_project(
"MyApp.Domain",
&[],
&[("Microsoft.EntityFrameworkCore", "7.0.0")],
)];
let mut report = CheckReport::new();
check_package_policies(&projects, &config, &mut report);
assert_eq!(report.violations.len(), 1);
assert!(
report.violations[0]
.message
.contains("Microsoft.EntityFrameworkCore")
);
assert!(report.violations[0].message.contains("Domain"));
}
#[test]
fn allowed_package_no_violation() {
let config = make_config(
&[("Domain", &["*.Domain"])],
&[],
&[("Domain", &["Microsoft.EntityFrameworkCore"])],
);
let projects = [make_project(
"MyApp.Domain",
&[],
&[("FluentValidation", "11.0.0")],
)];
let mut report = CheckReport::new();
check_package_policies(&projects, &config, &mut report);
assert!(report.violations.is_empty());
}
#[test]
fn package_policy_match_is_case_insensitive() {
let config = make_config(
&[("Domain", &["*.Domain"])],
&[],
&[("Domain", &["microsoft.entityframeworkcore"])],
);
let projects = [make_project(
"MyApp.Domain",
&[],
&[("Microsoft.EntityFrameworkCore", "7.0.0")],
)];
let mut report = CheckReport::new();
check_package_policies(&projects, &config, &mut report);
assert_eq!(report.violations.len(), 1);
}
#[test]
fn no_policy_for_layer_allows_any_package() {
let config = make_config(&[("Domain", &["*.Domain"])], &[], &[]);
let projects = [make_project("MyApp.Domain", &[], &[("Anything", "1.0.0")])];
let mut report = CheckReport::new();
check_package_policies(&projects, &config, &mut report);
assert!(report.violations.is_empty());
}
#[test]
fn ignored_project_skipped_entirely() {
let mut config = make_config(&[("Domain", &["*.Domain"])], &[], &[]);
config.ignore_patterns = vec!["*.Tests".to_string()];
let projects = [make_project("MyApp.Tests", &["MyApp.Domain"], &[])];
let mut report = CheckReport::new();
check_dependency_rules(&projects, &config, &mut report);
assert!(report.violations.is_empty());
assert!(report.warnings.is_empty());
}
#[test]
fn ignored_project_skipped_in_package_policies() {
let mut config = make_config(
&[("Domain", &["*.Domain"])],
&[],
&[("Domain", &["Microsoft.EntityFrameworkCore"])],
);
config.ignore_patterns = vec!["*.Tests".to_string()];
let projects = [make_project(
"MyApp.Tests",
&[],
&[("Microsoft.EntityFrameworkCore", "7.0.0")],
)];
let mut report = CheckReport::new();
check_package_policies(&projects, &config, &mut report);
assert!(report.violations.is_empty());
}
}
fn check_package_policies(
projects: &[ProjectFile],
config: &ArchitectureConfig,
report: &mut CheckReport,
) {
for project in projects {
if is_ignored(&project.name, &config.ignore_patterns) {
continue;
}
let Some(layer) = resolve_layer(&project.name, &config.layers) else {
continue;
};
let Some(policy) = config
.package_policies
.iter()
.find(|p| p.layer == layer.name)
else {
continue;
};
for pkg in &project.package_refs {
if policy
.forbidden
.iter()
.any(|f| f.eq_ignore_ascii_case(&pkg.name))
{
let src = std::fs::read_to_string(&project.path).unwrap_or_default();
report.violations.push(Violation {
message: format!(
"Package '{}' is forbidden in layer '{}'",
pkg.name, layer.name
),
src: miette::NamedSource::new(project.path.to_string_lossy(), src),
span: pkg.name_span.into(),
});
report.violation_keys.push(crate::baseline::BaselineEntry {
kind: "package".to_string(),
from: project.name.clone(),
to: pkg.name.clone(),
});
}
}
}
}
#[cfg(test)]
mod source_tests {
use super::*;
use crate::config::model::{DependencyRule, Layer};
fn make_ns_config(
layers: &[(&str, &[&str], &[&str])],
rules: &[(&str, &str, bool)],
) -> ArchitectureConfig {
ArchitectureConfig {
layers: layers
.iter()
.map(|(name, pats, ns_pats)| Layer {
name: name.to_string(),
patterns: pats.iter().map(|s| s.to_string()).collect(),
namespace_patterns: ns_pats.iter().map(|s| s.to_string()).collect(),
})
.collect(),
dependency_rules: rules
.iter()
.map(|(from, to, allowed)| DependencyRule {
from: from.to_string(),
to: to.to_string(),
allowed: *allowed,
})
.collect(),
package_policies: vec![],
ignore_patterns: vec![],
}
}
#[test]
fn resolve_ns_matches_wildcard_pattern() {
let layers = vec![Layer {
name: "Domain".to_string(),
patterns: vec![],
namespace_patterns: vec!["MyApp.Domain.*".to_string()],
}];
assert!(resolve_layer_by_namespace("MyApp.Domain.Entities", &layers).is_some());
assert!(resolve_layer_by_namespace("MyApp.Application.Services", &layers).is_none());
}
#[test]
fn resolve_ns_matches_deep_namespace() {
let layers = vec![Layer {
name: "Domain".to_string(),
patterns: vec![],
namespace_patterns: vec!["MyApp.Domain.*".to_string()],
}];
assert!(resolve_layer_by_namespace("MyApp.Domain.Services.Commands", &layers).is_some());
}
#[test]
fn source_forbidden_using_produces_violation() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("OrderService.cs"),
"using MyApp.Infrastructure.Db;\nnamespace MyApp.Domain.Services;\npublic class X {}",
)
.unwrap();
let config = make_ns_config(
&[
("Domain", &["*.Domain"], &["MyApp.Domain.*"]),
(
"Infrastructure",
&["*.Infrastructure"],
&["MyApp.Infrastructure.*"],
),
],
&[("Domain", "Infrastructure", false)],
);
let mut report = CheckReport::new();
check_source_rules(dir.path(), &config, &mut report).unwrap();
assert_eq!(report.violations.len(), 1);
assert!(report.violations[0].message.contains("Infrastructure"));
}
#[test]
fn source_allowed_using_no_violation() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("Handler.cs"),
"using MyApp.Domain.Entities;\nnamespace MyApp.Application.Handlers;\npublic class X {}",
)
.unwrap();
let config = make_ns_config(
&[
("Application", &["*.Application"], &["MyApp.Application.*"]),
("Domain", &["*.Domain"], &["MyApp.Domain.*"]),
],
&[("Application", "Domain", true)],
);
let mut report = CheckReport::new();
check_source_rules(dir.path(), &config, &mut report).unwrap();
assert!(report.violations.is_empty());
}
#[test]
fn source_scan_skipped_when_no_namespace_patterns() {
let dir = tempfile::tempdir().unwrap();
let config = make_ns_config(&[("Domain", &["*.Domain"], &[])], &[]);
let mut report = CheckReport::new();
check_source_rules(dir.path(), &config, &mut report).unwrap();
assert!(report.violations.is_empty());
}
#[test]
fn intra_layer_using_no_violation() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("Svc.cs"),
"using MyApp.Domain.ValueObjects;\nnamespace MyApp.Domain.Services;\npublic class X {}",
)
.unwrap();
let config = make_ns_config(&[("Domain", &["*.Domain"], &["MyApp.Domain.*"])], &[]);
let mut report = CheckReport::new();
check_source_rules(dir.path(), &config, &mut report).unwrap();
assert!(report.violations.is_empty());
}
}