Files
zesdex/apps/infrastructure/src/best_practice/arch_audit.rs
T
Cyrene (Mem) 6b90c7eb0d fix(rust): resolve all clippy warnings treated as errors in CI
Fix 18 clippy errors across 7 files:
- lib.rs: change doc comment to regular comment (empty line after doc)
- arch_audit.rs: replace format!() with string literal, use is_none_or
- code_quality.rs: collapsible if, map_or → is_none_or
- commit.rs: collapsible match guard
- explore.rs: needless_range_loop → iterator enumerate
- skills.rs: map_or(false,...) → is_some_and
- semantic_search.rs: map_or → is_none_or, sort_by → sort_by_key,
  remove explicit type to avoid type_complexity

CI was failing with 'error: could not compile zesdex-infrastructure
due to 18 previous errors' at clippy step.
2026-07-22 14:16:56 +07:00

390 lines
12 KiB
Rust

//! 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<Violation>,
/// 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<Violation> {
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<AuditReport> {
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<Violation> {
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");
}
}