refactor: five architectural deepening improvements
Some checks failed
CI / Check / Test (push) Failing after 43s
Architecture Docs / Generate diagrams (push) Successful in 3m20s

Candidate 1 (NormalizedGraph): qualify→resolve→filter is now a single
named operation returning a distinct type; raw CodeGraph cannot call
module_edges/subgraph_by_module — pipeline order enforced at compile time.

Candidate 2 (Use cases): GenerateDiagram, CheckFreshness, DiffDiagram
extracted to application/src/use_cases/; presentation is now a thin CLI
dispatcher (~100 lines less, three fewer local functions).

Candidate 3 (ExtractionContext): shared accumulator for both Rust and
Python extractors replaces parallel Vec<> + 4-arg passing chains.

Candidate 4 (ModuleAssignment): ModuleName::assign() returns
ModuleAssignment { Explicit | Inferred | Unresolved } instead of Option,
callers can distinguish resolution strategies.

Candidate 5 (SplitRenderer): append_cross_module_deps removed from
DiagramRenderer port; replaced by render_for_module() default impl —
port interface now reflects what all renderers actually share.
This commit is contained in:
2026-06-17 11:24:18 +02:00
parent b159cafc9d
commit fc8ad0ebc0
18 changed files with 614 additions and 511 deletions

View File

@@ -0,0 +1,18 @@
use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};
/// Compares the current rendered output against an on-disk file.
/// Returns `Ok(true)` if up to date, `Ok(false)` if stale.
pub struct CheckFreshness<'a> {
pub graph: &'a NormalizedGraph,
pub renderer: &'a dyn DiagramRenderer,
pub existing_path: &'a std::path::Path,
}
impl<'a> CheckFreshness<'a> {
pub fn execute(&self) -> Result<bool, DomainError> {
let rendered = self.renderer.render(self.graph.as_graph())?;
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
let existing = std::fs::read_to_string(self.existing_path).unwrap_or_default();
Ok(current == existing)
}
}

View File

@@ -0,0 +1,46 @@
use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};
/// The semantic result of comparing two diagram snapshots.
pub struct DiffResult {
pub added: Vec<String>,
pub removed: Vec<String>,
}
impl DiffResult {
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty()
}
}
/// Compares the rendered current graph against an existing diagram file and
/// returns which lines were added or removed.
pub struct DiffDiagram<'a> {
pub graph: &'a NormalizedGraph,
pub renderer: &'a dyn DiagramRenderer,
pub existing_path: &'a std::path::Path,
}
impl<'a> DiffDiagram<'a> {
pub fn execute(&self) -> Result<DiffResult, DomainError> {
let rendered = self.renderer.render(self.graph.as_graph())?;
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
let existing = std::fs::read_to_string(self.existing_path).unwrap_or_default();
let current_lines: std::collections::HashSet<&str> = current.lines().collect();
let existing_lines: std::collections::HashSet<&str> = existing.lines().collect();
let added: Vec<String> = current_lines
.difference(&existing_lines)
.filter(|l| !l.trim().is_empty())
.map(|l| format!("+ {l}"))
.collect();
let removed: Vec<String> = existing_lines
.difference(&current_lines)
.filter(|l| !l.trim().is_empty())
.map(|l| format!("- {l}"))
.collect();
Ok(DiffResult { added, removed })
}
}

View File

@@ -0,0 +1,125 @@
use std::path::PathBuf;
use archlens_domain::{
BoundaryRule, DomainError, NormalizedGraph, RenderedFile, RenderOutput,
check_boundary_rules,
ports::DiagramRenderer,
};
/// Result of running the generate use case — exposed violations and any output
/// that should be written to disk.
pub struct GenerateDiagramResult {
pub violations: Vec<String>,
pub output: RenderOutput,
}
/// Orchestrates diagram generation: renders the graph (split or single),
/// checks boundary rules, and returns the output for the caller to write.
pub struct GenerateDiagram {
pub graph: NormalizedGraph,
pub renderer: Box<dyn DiagramRenderer>,
pub allow_rules: Vec<BoundaryRule>,
pub deny_rules: Vec<BoundaryRule>,
pub split_by_module: bool,
pub format_ext: String,
pub output_dir: Option<PathBuf>,
}
impl GenerateDiagram {
pub fn execute(self) -> Result<(), DomainError> {
// Boundary rule checking
let violations = if !self.allow_rules.is_empty() || !self.deny_rules.is_empty() {
check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules)
} else {
Vec::new()
};
// Render and write
if self.split_by_module {
write_split(
&self.graph,
&*self.renderer,
&self.output_dir,
&self.format_ext,
)?;
} else {
let rendered = self.renderer.render(self.graph.as_graph())?;
write_to_output(rendered, &self.output_dir)?;
}
// Report violations (after writing so the diagram is still produced)
for v in &violations {
eprintln!("RULE VIOLATION: {}", v.message());
}
Ok(())
}
pub fn check_violations_only(&self) -> Vec<String> {
if self.allow_rules.is_empty() && self.deny_rules.is_empty() {
return Vec::new();
}
check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules)
.into_iter()
.map(|v| v.message())
.collect()
}
}
pub fn write_split(
graph: &NormalizedGraph,
renderer: &dyn DiagramRenderer,
output_dir: &Option<PathBuf>,
ext: &str,
) -> Result<(), DomainError> {
let dir = output_dir
.clone()
.unwrap_or_else(|| PathBuf::from("."));
let overview = renderer.render(graph.as_graph())?;
let overview_file = RenderedFile::new(
&format!("overview.{ext}"),
overview.files().first().map(|f| f.content()).unwrap_or(""),
)?;
write_file_to_dir(&dir, overview_file)?;
for module in graph.modules() {
let subgraph = graph.subgraph_by_module(&module);
let cross_deps = graph.cross_module_deps_for(&module);
let module_output = renderer.render_for_module(&subgraph, &module, &cross_deps)?;
let module_file = RenderedFile::new(
&format!("{}.{ext}", module.as_str().to_lowercase()),
module_output.files().first().map(|f| f.content()).unwrap_or(""),
)?;
write_file_to_dir(&dir, module_file)?;
}
Ok(())
}
fn write_file_to_dir(dir: &PathBuf, file: RenderedFile) -> Result<(), DomainError> {
let path = dir.join(file.name());
std::fs::create_dir_all(dir)
.map_err(|e| DomainError::IoError(e.to_string()))?;
std::fs::write(&path, file.content())
.map_err(|e| DomainError::IoError(e.to_string()))?;
Ok(())
}
fn write_to_output(rendered: RenderOutput, output: &Option<PathBuf>) -> Result<(), DomainError> {
let content = rendered.files().first().map(|f| f.content()).unwrap_or("");
match output {
Some(path) => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| DomainError::IoError(e.to_string()))?;
}
std::fs::write(path, content)
.map_err(|e| DomainError::IoError(e.to_string()))
}
None => {
print!("{content}");
Ok(())
}
}
}

View File

@@ -0,0 +1,3 @@
pub mod check_freshness;
pub mod diff_diagram;
pub mod generate_diagram;