//! Architecture layering audit for clean-architecture compliance. //! //! Scans Rust source files in the workspace and reports violations of the //! dependency rule: domain must not import from outer layers, application //! must not import from infrastructure or interfaces, etc. //! //! # Flow //! //! `audit_layering(workspace_dir)` → walk `apps/` → classify crate by path → //! scan `use` statements → match against forbidden crates → collect violations. use anyhow::Result; use ignore::Walk; use std::path::Path; use tracing::instrument; /// A single layering violation found during the audit. #[derive(Debug, Clone)] pub struct Violation { /// Severity level. pub severity: Severity, /// Which crate layer caused the violation. pub layer: &'static str, /// File path relative to workspace root. pub file: String, /// Line number (1-indexed). pub line: usize, /// Human-readable description. pub message: String, } /// How severe a violation is. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Severity { Error, Warning, Info, } impl std::fmt::Display for Severity { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Severity::Error => write!(f, "ERROR"), Severity::Warning => write!(f, "WARN"), Severity::Info => write!(f, "INFO"), } } } /// Result of an architecture audit. #[derive(Debug, Clone)] pub struct AuditReport { /// All violations found, grouped by severity. pub violations: Vec, /// Number of source files scanned. pub files_scanned: usize, } impl AuditReport { /// True if any ERROR-level violations exist. pub fn has_errors(&self) -> bool { self.violations.iter().any(|v| v.severity == Severity::Error) } /// Number of errors. pub fn error_count(&self) -> usize { self.violations.iter().filter(|v| v.severity == Severity::Error).count() } /// Number of warnings. pub fn warning_count(&self) -> usize { self.violations.iter().filter(|v| v.severity == Severity::Warning).count() } } /// Classify a path into a clean-architecture layer name. fn classify_layer(crate_path: &Path) -> Option<&'static str> { let path_str = crate_path.to_string_lossy(); if path_str.contains("/domain") || path_str.ends_with("/domain") { Some("domain") } else if path_str.contains("/application") || path_str.ends_with("/application") { Some("application") } else if path_str.contains("/infrastructure") || path_str.ends_with("/infrastructure") { Some("infrastructure") } else if path_str.contains("/interfaces/") { Some("interfaces") } else if path_str.contains("/gateway") { Some("gateway") } else { None } } /// Forbidden import patterns per layer. /// /// Returns a list of crate prefixes that the given layer must NOT import. fn forbidden_imports(layer: &str) -> &'static [&'static str] { match layer { "domain" => &[ "zesdex_application", "zesdex_infrastructure", "zesdex_tui", "zesdex_api", "zesdex_daemon", "zesdex_ws", "zesdex_grpc", "zesdex_web", "zesdex_gateway", "tokio", "axum", "reqwest", "rusqlite", "ratatui", "crossterm", "tower", "tower_http", "argon2", "jsonwebtoken", "rmcp", "lsp_types", "tiktoken_rs", "syntect", "pulldown_cmark", "serde_yaml_ng", "ignore", "dom_smoothie", "fast_html2md", "scraper", "clap", ], "application" => &[ "zesdex_infrastructure", "zesdex_tui", "zesdex_api", "zesdex_daemon", "zesdex_ws", "zesdex_grpc", "zesdex_web", "zesdex_gateway", ], "infrastructure" => &[ "zesdex_tui", "zesdex_api", "zesdex_daemon", "zesdex_ws", "zesdex_grpc", "zesdex_web", "zesdex_gateway", ], _ => &[], } } /// Scan a single Rust source file for forbidden imports. fn scan_file( file_path: &Path, layer: &'static str, root: &Path, ) -> Vec { let mut violations = Vec::new(); let content = match std::fs::read_to_string(file_path) { Ok(c) => c, Err(_) => return violations, }; let forbidden = forbidden_imports(layer); if forbidden.is_empty() { return violations; } let relative = file_path .strip_prefix(root) .unwrap_or(file_path) .to_string_lossy() .to_string(); for (line_num, line) in content.lines().enumerate() { let trimmed = line.trim(); // Match: `use zesdex_application::...` or `use zesdex_infrastructure::...` if trimmed.starts_with("use ") { for &forbidden in forbidden { let pattern = format!("use {forbidden}"); if trimmed.starts_with(&pattern) || trimmed.starts_with("use crate::") { // `use crate::` in domain could reference domain-only items — skip. continue; } if trimmed.starts_with(&pattern) || trimmed.starts_with(&format!("use {forbidden}::")) { // Skip test code — test modules commonly import outer layers. let is_test = content[..content.len().saturating_sub(1)] .contains("#[cfg(test)]"); if is_test { continue; } violations.push(Violation { severity: Severity::Error, layer, file: relative.clone(), line: line_num + 1, message: format!( "Layer '{layer}' must not depend on '{forbidden}': {trimmed}" ), }); } } } } violations } /// Run a full architecture layering audit on the workspace at `root`. /// /// Walks all `.rs` files under `root/apps/`, classifies each by its parent /// crate, and checks `use` statements against the dependency rule. #[instrument(skip(root))] pub fn audit_layering(root: &Path) -> Result { let apps_dir = root.join("apps"); if !apps_dir.is_dir() { return Ok(AuditReport { violations: vec![Violation { severity: Severity::Warning, layer: "workspace", file: "apps/".to_string(), line: 0, message: format!("apps/ directory not found at {}", apps_dir.display()), }], files_scanned: 0, }); } let mut violations = Vec::new(); let mut files_scanned = 0; for entry in Walk::new(&apps_dir).flatten() { if entry.file_type().is_none_or(|ft| !ft.is_file()) { continue; } let path = entry.path(); if path.extension().is_none_or(|e| e != "rs") { continue; } // Determine which crate this file belongs to by walking up. let layer = path .ancestors() .skip(1) .find_map(|p| classify_layer(p)); if let Some(layer) = layer { files_scanned += 1; violations.extend(scan_file(path, layer, root)); } } Ok(AuditReport { violations, files_scanned, }) } /// Count lines of code and nesting depth in a Rust source file. pub fn check_function_metrics(content: &str) -> Vec { let mut violations = Vec::new(); let mut in_function = false; let mut fn_start = 0; let mut fn_name = String::new(); let mut brace_depth = 0; let mut max_nesting = 0; let mut current_nesting: i32 = 0; for (i, line) in content.lines().enumerate() { let line_num = i + 1; // Track function entry. if line.trim().starts_with("fn ") && line.trim().ends_with('{') { in_function = true; fn_start = line_num; fn_name = line.trim().to_string(); brace_depth = 1; max_nesting = 0; current_nesting = 0; continue; } if in_function { for ch in line.chars() { match ch { '{' => { brace_depth += 1; current_nesting += 1; max_nesting = max_nesting.max(current_nesting); } '}' => { brace_depth -= 1; current_nesting = (current_nesting.saturating_sub(1)).max(0); if brace_depth == 0 { // End of function — check metrics. let fn_lines = line_num - fn_start; if fn_lines > 40 { violations.push(Violation { severity: Severity::Warning, layer: "code", file: String::new(), line: fn_start, message: format!( "Function too long: {} lines (max 40): {}", fn_lines, fn_name ), }); } if max_nesting >= 4 { violations.push(Violation { severity: Severity::Warning, layer: "code", file: String::new(), line: fn_start, message: format!( "Deep nesting (level {}) in: {}", max_nesting, fn_name ), }); } in_function = false; break; } } _ => {} } } } } violations } #[cfg(test)] mod tests { use super::*; #[test] fn domain_layer_forbidden_imports() { let forbidden = forbidden_imports("domain"); assert!(forbidden.contains(&"tokio")); assert!(forbidden.contains(&"zesdex_application")); assert!(forbidden.contains(&"axum")); } #[test] fn application_layer_forbidden_imports() { let forbidden = forbidden_imports("application"); assert!(forbidden.contains(&"zesdex_infrastructure")); assert!(!forbidden.contains(&"tokio")); // tokio is allowed in application } #[test] fn classify_layer_works() { let p = Path::new("/root/apps/domain/src/lib.rs"); assert_eq!(classify_layer(p), Some("domain")); let p = Path::new("/root/apps/application/src/lib.rs"); assert_eq!(classify_layer(p), Some("application")); let p = Path::new("/root/apps/gateway/src/main.rs"); assert_eq!(classify_layer(p), Some("gateway")); } #[test] fn function_metrics_short_function_ok() { let content = "fn ok() {\n let x = 1;\n}\n"; let violations = check_function_metrics(content); let long: Vec<_> = violations.iter().filter(|v| v.message.contains("Function too long")).collect(); assert!(long.is_empty(), "short function should not trigger"); } #[test] fn function_metrics_reports_long_function() { let mut lines = String::from("fn long() {\n"); for _ in 0..45 { lines.push_str(" let _ = 1;\n"); } lines.push_str("}\n"); let violations = check_function_metrics(&lines); let long: Vec<_> = violations.iter().filter(|v| v.message.contains("Function too long")).collect(); assert!(!long.is_empty(), "long function should trigger warning"); } }