refactor: move scattered business logic into domain
Some checks failed
CI / Check / Test (push) Failing after 44s
Architecture Docs / Generate diagrams (push) Successful in 3m21s

- CodeGraph::merge_project_edges() replaces presentation-layer function
- Language::is_test_file() centralises test file detection (was in walkdir)
- AnalysisConfig::is_standard_excluded() centralises default dir exclusions (was in walkdir)
- normalize_cargo_package() / normalize_python_package() in domain replace duplicated normalisers in each adapter
- walkdir, cargo-workspace, python-project updated to call domain methods
This commit is contained in:
2026-06-17 10:58:42 +02:00
parent 428faa957c
commit e26151b4a1
10 changed files with 262 additions and 87 deletions

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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<String, String> = 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<String> =
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:

View File

@@ -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,
};

View File

@@ -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 {

View File

@@ -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"),
}
}
}

View File

@@ -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(['-', '.'], "_")
}

View File

@@ -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");
}

View File

@@ -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<CodeGraph> {
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<String, String> = 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<String> = 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);