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.
390 lines
12 KiB
Rust
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");
|
|
}
|
|
}
|