diff --git a/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs b/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs index 7469fac..75aa3d0 100644 --- a/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs +++ b/crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs @@ -5,7 +5,7 @@ use serde::Deserialize; use archlens_domain::{ CodeElement, CodeElementKind, CodeGraph, DomainError, FilePath, ModuleName, Relationship, - RelationshipKind, ports::ProjectAnalyzer, + RelationshipKind, normalize_cargo_package, ports::ProjectAnalyzer, }; pub struct CargoWorkspaceAnalyzer; @@ -97,7 +97,7 @@ impl ProjectAnalyzer for CargoWorkspaceAnalyzer { .map_err(|e| DomainError::ConfigError(e.to_string()))?; for dep_name in member.dependencies.keys() { - let normalized = dep_name.replace('_', "-"); + let normalized = normalize_cargo_package(dep_name); if name_set.contains(&normalized) && let Ok(rel) = Relationship::new(package_name, &normalized, RelationshipKind::Composition) diff --git a/crates/adapters/python-project/src/python_project_analyzer.rs b/crates/adapters/python-project/src/python_project_analyzer.rs index 3d85907..5b25140 100644 --- a/crates/adapters/python-project/src/python_project_analyzer.rs +++ b/crates/adapters/python-project/src/python_project_analyzer.rs @@ -5,7 +5,7 @@ use serde::Deserialize; use archlens_domain::{ CodeElement, CodeElementKind, CodeGraph, DomainError, FilePath, Relationship, RelationshipKind, - ports::ProjectAnalyzer, + normalize_python_package, ports::ProjectAnalyzer, }; pub struct PythonProjectAnalyzer; @@ -59,7 +59,7 @@ fn extract_dep_name(dep: &str) -> &str { } fn normalize(name: &str) -> String { - name.to_lowercase().replace(['-', '.'], "_") + normalize_python_package(name) } impl ProjectAnalyzer for PythonProjectAnalyzer { diff --git a/crates/adapters/walkdir/src/walkdir_discovery.rs b/crates/adapters/walkdir/src/walkdir_discovery.rs index b26700a..afe9a7b 100644 --- a/crates/adapters/walkdir/src/walkdir_discovery.rs +++ b/crates/adapters/walkdir/src/walkdir_discovery.rs @@ -6,20 +6,6 @@ use archlens_domain::{ AnalysisConfig, DomainError, FilePath, Language, SourceFile, ports::FileDiscovery, }; -const DEFAULT_EXCLUDES: &[&str] = &[ - ".venv", - "venv", - "node_modules", - "__pycache__", - ".git", - "target", - "bin", - "obj", - "dist", - ".tox", - ".eggs", -]; - pub struct WalkdirDiscovery; impl Default for WalkdirDiscovery { @@ -42,34 +28,13 @@ impl WalkdirDiscovery { } } - fn is_test_file(path: &Path, language: Language) -> bool { - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or_default(); - let in_tests_dir = path - .parent() - .map(|p| p.components().any(|c| c.as_os_str() == "tests")) - .unwrap_or(false); - - if in_tests_dir { - return true; - } - - match language { - Language::Rust => stem.ends_with("_test") || stem.ends_with("_tests"), - Language::Python => stem.starts_with("test_") || stem.ends_with("_test"), - Language::CSharp => stem.ends_with("Tests") || stem.ends_with("Test"), - } - } - fn is_excluded(path: &Path, root: &Path, excludes: &[String]) -> bool { let relative = path.strip_prefix(root).unwrap_or(path); let relative_str = relative.to_string_lossy(); for component in relative.components() { let name = component.as_os_str().to_string_lossy(); - if DEFAULT_EXCLUDES.iter().any(|e| name == *e) { + if AnalysisConfig::is_standard_excluded(&name) { return true; } } @@ -109,7 +74,7 @@ impl FileDiscovery for WalkdirDiscovery { } if let Some(language) = Self::detect_language(path) { - if !config.include_tests() && Self::is_test_file(path, language) { + if !config.include_tests() && language.is_test_file(path) { continue; } if let Some(changed) = config.changed_files() { diff --git a/crates/domain/src/aggregates/code_graph.rs b/crates/domain/src/aggregates/code_graph.rs index bc1eafe..f013c29 100644 --- a/crates/domain/src/aggregates/code_graph.rs +++ b/crates/domain/src/aggregates/code_graph.rs @@ -279,6 +279,43 @@ impl CodeGraph { } } + /// Merge project-level crate dependencies into module-level edges. + /// + /// Maps each crate in `project_graph` to a module name (using the crate's + /// explicit module if set, otherwise capitalizing its directory name), then + /// adds `Composition` edges between modules whose crates have a dependency — + /// but only when both modules already exist in this graph. + pub fn merge_project_edges(&mut self, project_graph: &CodeGraph) { + let mut crate_to_module: HashMap = HashMap::new(); + for element in project_graph.elements() { + let module = if let Some(m) = element.module() { + m.as_str().to_string() + } else { + let path = element.file_path().as_str(); + let dir = path.split('/').rev().nth(1).unwrap_or(element.name()); + ModuleName::capitalize(dir) + }; + crate_to_module.insert(element.name().to_string(), module); + } + + let graph_modules: HashSet = + self.modules().iter().map(|m| m.as_str().to_string()).collect(); + + for rel in project_graph.relationships() { + let src_module = crate_to_module.get(rel.source()); + let tgt_module = crate_to_module.get(rel.target()); + if let (Some(src), Some(tgt)) = (src_module, tgt_module) + && src != tgt + && graph_modules.contains(src) + && graph_modules.contains(tgt) + && let Ok(edge) = + Relationship::new(src, tgt, RelationshipKind::Composition) + { + self.add_relationship(edge); + } + } + } + /// Compute module-to-module edges with relationship counts. /// /// Handles three cases: diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 13681e2..065581e 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -12,4 +12,6 @@ pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarnin pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility}; pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile}; pub use value_objects::rules::{BoundaryRule, RuleKind, RuleViolation, check_boundary_rules}; -pub use value_objects::source::{FilePath, Language, ModuleName, SourceFile}; +pub use value_objects::source::{ + FilePath, Language, ModuleName, SourceFile, normalize_cargo_package, normalize_python_package, +}; diff --git a/crates/domain/src/value_objects/analysis/analysis_config.rs b/crates/domain/src/value_objects/analysis/analysis_config.rs index a78569a..1d02b97 100644 --- a/crates/domain/src/value_objects/analysis/analysis_config.rs +++ b/crates/domain/src/value_objects/analysis/analysis_config.rs @@ -68,6 +68,26 @@ impl AnalysisConfig { } } +const STANDARD_EXCLUDED_DIRS: &[&str] = &[ + ".venv", + "venv", + "node_modules", + "__pycache__", + ".git", + "target", + "bin", + "obj", + "dist", + ".tox", + ".eggs", +]; + +impl AnalysisConfig { + pub fn is_standard_excluded(name: &str) -> bool { + STANDARD_EXCLUDED_DIRS.contains(&name) + } +} + impl Default for AnalysisConfig { fn default() -> Self { Self { diff --git a/crates/domain/src/value_objects/source/language.rs b/crates/domain/src/value_objects/source/language.rs index 0f06117..58d0de0 100644 --- a/crates/domain/src/value_objects/source/language.rs +++ b/crates/domain/src/value_objects/source/language.rs @@ -1,3 +1,5 @@ +use std::path::Path; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Language { Rust, @@ -13,4 +15,26 @@ impl Language { Self::Python => "Python", } } + + pub fn is_test_file(&self, path: &Path) -> bool { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + + let in_tests_dir = path + .parent() + .map(|p| p.components().any(|c| c.as_os_str() == "tests")) + .unwrap_or(false); + + if in_tests_dir { + return true; + } + + match self { + Self::Rust => stem.ends_with("_test") || stem.ends_with("_tests"), + Self::Python => stem.starts_with("test_") || stem.ends_with("_test"), + Self::CSharp => stem.ends_with("Tests") || stem.ends_with("Test"), + } + } } diff --git a/crates/domain/src/value_objects/source/mod.rs b/crates/domain/src/value_objects/source/mod.rs index 1d35419..ae4c121 100644 --- a/crates/domain/src/value_objects/source/mod.rs +++ b/crates/domain/src/value_objects/source/mod.rs @@ -7,3 +7,11 @@ pub use file_path::FilePath; pub use language::Language; pub use module_name::ModuleName; pub use source_file::SourceFile; + +pub fn normalize_cargo_package(name: &str) -> String { + name.replace('_', "-") +} + +pub fn normalize_python_package(name: &str) -> String { + name.to_lowercase().replace(['-', '.'], "_") +} diff --git a/crates/domain/tests/policy_tests.rs b/crates/domain/tests/policy_tests.rs new file mode 100644 index 0000000..a6f3923 --- /dev/null +++ b/crates/domain/tests/policy_tests.rs @@ -0,0 +1,162 @@ +use std::path::Path; + +use archlens_domain::{ + CodeElement, CodeElementKind, CodeGraph, FilePath, Language, ModuleName, Relationship, + RelationshipKind, normalize_cargo_package, normalize_python_package, +}; + +fn make_element(name: &str, module: Option<&str>) -> CodeElement { + let mut el = CodeElement::new( + name, + CodeElementKind::Class, + FilePath::new(&format!("src/{name}.rs")).unwrap(), + 1, + ) + .unwrap(); + if let Some(m) = module { + el = el.with_module(ModuleName::new(m).unwrap()); + } + el +} + +// ── merge_project_edges ─────────────────────────────────────────────────────── + +#[test] +fn merge_project_edges_adds_module_level_edges_from_project_deps() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("Service", Some("Application"))); + graph.add_element(make_element("Order", Some("Domain"))); + + let mut project_graph = CodeGraph::new(); + project_graph.add_element( + CodeElement::new( + "myapp-application", + CodeElementKind::Project, + FilePath::new("crates/application/Cargo.toml").unwrap(), + 1, + ) + .unwrap(), + ); + project_graph.add_element( + CodeElement::new( + "myapp-domain", + CodeElementKind::Project, + FilePath::new("crates/domain/Cargo.toml").unwrap(), + 1, + ) + .unwrap(), + ); + project_graph.add_relationship( + Relationship::new( + "myapp-application", + "myapp-domain", + RelationshipKind::Composition, + ) + .unwrap(), + ); + + graph.merge_project_edges(&project_graph); + + let module_edges = graph.module_edges(); + assert!( + module_edges.contains_key(&("Application".to_string(), "Domain".to_string())), + "expected Application -> Domain edge, got: {module_edges:?}" + ); +} + +#[test] +fn merge_project_edges_ignores_crates_with_no_matching_module() { + let mut graph = CodeGraph::new(); + graph.add_element(make_element("Order", Some("Domain"))); + + let mut project_graph = CodeGraph::new(); + project_graph.add_element( + CodeElement::new( + "external-lib", + CodeElementKind::Project, + FilePath::new("external/Cargo.toml").unwrap(), + 1, + ) + .unwrap(), + ); + project_graph.add_element( + CodeElement::new( + "myapp-domain", + CodeElementKind::Project, + FilePath::new("crates/domain/Cargo.toml").unwrap(), + 1, + ) + .unwrap(), + ); + project_graph.add_relationship( + Relationship::new("external-lib", "myapp-domain", RelationshipKind::Composition).unwrap(), + ); + + graph.merge_project_edges(&project_graph); + + // "External" (from external-lib) isn't a known module in graph → no edge + assert!(graph.module_edges().is_empty()); +} + +// ── Language::is_test_file ──────────────────────────────────────────────────── + +#[test] +fn language_detects_python_test_prefix_files() { + assert!(Language::Python.is_test_file(Path::new("src/test_orders.py"))); +} + +#[test] +fn language_detects_python_test_suffix_files() { + assert!(Language::Python.is_test_file(Path::new("src/orders_test.py"))); +} + +#[test] +fn language_detects_rust_test_files() { + assert!(Language::Rust.is_test_file(Path::new("src/orders_tests.rs"))); +} + +#[test] +fn language_detects_files_in_tests_dir() { + assert!(Language::Rust.is_test_file(Path::new("tests/integration.rs"))); + assert!(Language::Python.is_test_file(Path::new("tests/helpers.py"))); +} + +#[test] +fn language_does_not_flag_regular_files_as_tests() { + assert!(!Language::Rust.is_test_file(Path::new("src/orders.rs"))); + assert!(!Language::Python.is_test_file(Path::new("src/orders.py"))); +} + +// ── AnalysisConfig::is_standard_excluded ───────────────────────────────────── + +#[test] +fn analysis_config_identifies_standard_excluded_dirs() { + use archlens_domain::AnalysisConfig; + assert!(AnalysisConfig::is_standard_excluded(".venv")); + assert!(AnalysisConfig::is_standard_excluded("node_modules")); + assert!(AnalysisConfig::is_standard_excluded("target")); + assert!(AnalysisConfig::is_standard_excluded("__pycache__")); +} + +#[test] +fn analysis_config_does_not_exclude_source_dirs() { + use archlens_domain::AnalysisConfig; + assert!(!AnalysisConfig::is_standard_excluded("src")); + assert!(!AnalysisConfig::is_standard_excluded("domain")); + assert!(!AnalysisConfig::is_standard_excluded("application")); +} + +// ── Name normalization ──────────────────────────────────────────────────────── + +#[test] +fn normalize_cargo_package_converts_underscores_to_hyphens() { + assert_eq!(normalize_cargo_package("myapp_domain"), "myapp-domain"); + assert_eq!(normalize_cargo_package("myapp-domain"), "myapp-domain"); +} + +#[test] +fn normalize_python_package_lowercases_and_unifies_separators() { + assert_eq!(normalize_python_package("My-Package"), "my_package"); + assert_eq!(normalize_python_package("my.package"), "my_package"); + assert_eq!(normalize_python_package("my_package"), "my_package"); +} diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index 22bd390..4f7e365 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -9,7 +9,7 @@ use archlens_ascii::AsciiRenderer; use archlens_cargo_workspace::CargoWorkspaceAnalyzer; use archlens_d2::D2Renderer; use archlens_domain::{ - BoundaryRule, CodeGraph, DiagramLevel, ModuleName, check_boundary_rules, + BoundaryRule, CodeGraph, DiagramLevel, check_boundary_rules, ports::{ConfigLoader, OutputWriter, ProjectAnalyzer}, }; use archlens_file_writer::FileOutputWriter; @@ -154,7 +154,7 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result { PythonProjectAnalyzer::new().analyze(&args.path).ok() }; if let Some(pg) = project_graph { - merge_project_deps_as_module_edges(&mut graph, &pg); + graph.merge_project_edges(&pg); } } @@ -265,49 +265,6 @@ fn write_single( Ok(()) } -fn merge_project_deps_as_module_edges( - graph: &mut archlens_domain::CodeGraph, - project_graph: &archlens_domain::CodeGraph, -) { - use std::collections::HashMap; - - let mut crate_to_module: HashMap = HashMap::new(); - for element in project_graph.elements() { - let module = if let Some(m) = element.module() { - m.as_str().to_string() - } else { - let path = element.file_path().as_str(); - let dir = path.split('/').rev().nth(1).unwrap_or(element.name()); - ModuleName::capitalize(dir) - }; - crate_to_module.insert(element.name().to_string(), module); - } - - let graph_modules: std::collections::HashSet = graph - .modules() - .iter() - .map(|m| m.as_str().to_string()) - .collect(); - - for rel in project_graph.relationships() { - let src_module = crate_to_module.get(rel.source()); - let tgt_module = crate_to_module.get(rel.target()); - - if let (Some(src_cap), Some(tgt_cap)) = (src_module, tgt_module) - && src_cap != tgt_cap - && graph_modules.contains(src_cap) - && graph_modules.contains(tgt_cap) - && let Ok(edge) = archlens_domain::Relationship::new( - src_cap, - tgt_cap, - archlens_domain::RelationshipKind::Composition, - ) - { - graph.add_relationship(edge); - } - } -} - fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> { init_tracing(args.verbose);