refactor: make GenerateDiagram pure — return RenderOutput instead of writing files

This commit is contained in:
2026-06-17 13:13:27 +02:00
parent a700fc6160
commit 692a64a622
3 changed files with 184 additions and 127 deletions

View File

@@ -1,122 +1,68 @@
use std::path::PathBuf;
use archlens_domain::{
BoundaryRule, DomainError, NormalizedGraph, RenderOutput, RenderedFile, check_boundary_rules,
ports::DiagramRenderer,
BoundaryRule, DomainError, NormalizedGraph, RenderOutput, RenderedFile, RuleViolation,
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 violations: Vec<RuleViolation>,
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
pub fn execute(self) -> Result<GenerateDiagramResult, DomainError> {
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,
)?;
let output = if self.split_by_module {
render_split(&self.graph, &*self.renderer)?
} else {
let rendered = self.renderer.render(self.graph.as_graph())?;
write_to_output(rendered, &self.output_dir)?;
}
self.renderer.render(self.graph.as_graph())?
};
// 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()
Ok(GenerateDiagramResult { violations, output })
}
}
pub fn write_split(
fn render_split(
graph: &NormalizedGraph,
renderer: &dyn DiagramRenderer,
output_dir: &Option<PathBuf>,
ext: &str,
) -> Result<(), DomainError> {
let dir = output_dir.clone().unwrap_or_else(|| PathBuf::from("."));
) -> Result<RenderOutput, DomainError> {
let mut files = Vec::new();
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)?;
let ext = overview
.files()
.first()
.and_then(|f| std::path::Path::new(f.name()).extension())
.and_then(|e| e.to_str())
.unwrap_or("txt");
if let Some(f) = overview.files().first() {
files.push(RenderedFile::new(&format!("overview.{ext}"), f.content())?);
}
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(())
if let Some(f) = module_output.files().first() {
files.push(RenderedFile::new(
&format!("{}.{ext}", module.as_str().to_lowercase()),
f.content(),
)?);
}
}
Ok(RenderOutput::new(files))
}

View File

@@ -0,0 +1,113 @@
mod fakes;
use std::path::Path;
use archlens_application::queries::AnalyzeCodebase;
use archlens_application::use_cases::generate_diagram::GenerateDiagram;
use archlens_domain::{
AnalysisConfig, AnalysisResult, BoundaryRule, CodeElement, CodeElementKind, CodeGraph,
FilePath, Language, ModuleName, NormalizedGraph, Relationship, RelationshipKind, SourceFile,
};
use fakes::{FakeDiagramRenderer, FakeFileDiscovery, FakeSourceAnalyzer};
fn empty_graph() -> NormalizedGraph {
NormalizedGraph::from_project(CodeGraph::new())
}
fn graph_with_one_module() -> NormalizedGraph {
let files = vec![SourceFile::new(
FilePath::new("/p/src/orders/order.rs").unwrap(),
Language::Rust,
)];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new().with_result(
"/p/src/orders/order.rs",
AnalysisResult::new(
vec![CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("/p/src/orders/order.rs").unwrap(),
1,
)
.unwrap()],
vec![],
vec![],
),
);
AnalyzeCodebase::new(discovery, analyzer)
.execute(Path::new("/p"), &AnalysisConfig::default())
.unwrap()
.graph()
.clone()
}
fn graph_with_violation() -> NormalizedGraph {
let mut cg = CodeGraph::new();
let a = CodeElement::new("A", CodeElementKind::Struct, FilePath::new("a.rs").unwrap(), 1)
.unwrap()
.with_module(ModuleName::new("Alpha").unwrap());
let b = CodeElement::new("B", CodeElementKind::Struct, FilePath::new("b.rs").unwrap(), 1)
.unwrap()
.with_module(ModuleName::new("Beta").unwrap());
cg.add_element(a);
cg.add_element(b);
cg.add_relationship(Relationship::new("A", "B", RelationshipKind::Composition).unwrap());
NormalizedGraph::from_project(cg)
}
#[test]
fn execute_returns_render_output_without_writing_files() {
let use_case = GenerateDiagram {
graph: empty_graph(),
renderer: Box::new(FakeDiagramRenderer::new()),
allow_rules: vec![],
deny_rules: vec![],
split_by_module: false,
};
let result = use_case.execute().unwrap();
assert!(!result.output.files().is_empty());
}
#[test]
fn execute_returns_empty_violations_when_no_rules_set() {
let use_case = GenerateDiagram {
graph: empty_graph(),
renderer: Box::new(FakeDiagramRenderer::new()),
allow_rules: vec![],
deny_rules: vec![],
split_by_module: false,
};
let result = use_case.execute().unwrap();
assert!(result.violations.is_empty());
}
#[test]
fn execute_returns_violations_as_rule_violation_type() {
let deny = vec![BoundaryRule::parse("Alpha --> Beta").unwrap()];
let use_case = GenerateDiagram {
graph: graph_with_violation(),
renderer: Box::new(FakeDiagramRenderer::new()),
allow_rules: vec![],
deny_rules: deny,
split_by_module: false,
};
let result = use_case.execute().unwrap();
assert_eq!(result.violations.len(), 1);
assert!(result.violations[0].message().contains("Alpha"));
assert!(result.violations[0].message().contains("Beta"));
}
#[test]
fn split_by_module_returns_multiple_rendered_files() {
let use_case = GenerateDiagram {
graph: graph_with_one_module(),
renderer: Box::new(FakeDiagramRenderer::new()),
allow_rules: vec![],
deny_rules: vec![],
split_by_module: true,
};
let result = use_case.execute().unwrap();
// overview + at least one module file
assert!(result.output.files().len() >= 2);
}