feat: add BuildCodeGraph use case, sink orchestration out of presentation

This commit is contained in:
2026-06-17 13:21:09 +02:00
parent 8f68714977
commit 7487cea0e2
6 changed files with 266 additions and 30 deletions

View File

@@ -0,0 +1,67 @@
use std::path::Path;
use archlens_domain::{
AnalysisConfig, AnalysisWarning, DiagramLevel, DomainError, NormalizedGraph,
ports::{FileDiscovery, ProjectAnalyzer, SourceAnalyzer},
};
use crate::queries::AnalyzeCodebase;
#[derive(Debug)]
pub struct BuildCodeGraphResult {
pub graph: NormalizedGraph,
pub warnings: Vec<AnalysisWarning>,
}
pub struct BuildCodeGraph<F, S>
where
F: FileDiscovery + Send + Sync,
S: SourceAnalyzer,
{
pub discovery: F,
pub source_analyzer: S,
pub project_analyzer: Option<Box<dyn ProjectAnalyzer>>,
}
impl<F, S> BuildCodeGraph<F, S>
where
F: FileDiscovery + Send + Sync,
S: SourceAnalyzer,
{
pub fn execute(
self,
root: &Path,
config: &AnalysisConfig,
level: DiagramLevel,
) -> Result<BuildCodeGraphResult, DomainError> {
match level {
DiagramLevel::Project => {
let pa = self.project_analyzer.ok_or_else(|| {
DomainError::AnalysisError(
"no project analyzer available for Project level".into(),
)
})?;
let cg = pa.analyze(root)?;
Ok(BuildCodeGraphResult {
graph: NormalizedGraph::from_project(cg),
warnings: Vec::new(),
})
}
DiagramLevel::Module | DiagramLevel::Type => {
let analyze = AnalyzeCodebase::new(self.discovery, self.source_analyzer);
let result = analyze.execute(root, config)?;
let mut graph = result.graph().clone();
if level == DiagramLevel::Module {
if let Some(pa) = self.project_analyzer {
let project_cg = pa.analyze(root)?;
graph.merge_project_edges(&project_cg);
}
}
Ok(BuildCodeGraphResult {
graph,
warnings: result.warnings().to_vec(),
})
}
}
}
}

View File

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

View File

@@ -0,0 +1,152 @@
mod fakes;
use std::path::Path;
use archlens_application::use_cases::build_code_graph::BuildCodeGraph;
use archlens_domain::{
AnalysisConfig, AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, CodeGraph,
DiagramLevel, FilePath, Language, SourceFile,
};
use fakes::{FakeFileDiscovery, FakeProjectAnalyzer, FakeSourceAnalyzer};
#[test]
fn project_level_returns_project_analyzer_graph() {
let mut cg = CodeGraph::new();
cg.add_element(
CodeElement::new(
"MyApp",
CodeElementKind::Project,
FilePath::new("Cargo.toml").unwrap(),
1,
)
.unwrap(),
);
let use_case = BuildCodeGraph {
discovery: FakeFileDiscovery::empty(),
source_analyzer: FakeSourceAnalyzer::new(),
project_analyzer: Some(Box::new(FakeProjectAnalyzer::new(cg))),
};
let result = use_case
.execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Project)
.unwrap();
assert_eq!(result.graph.elements().len(), 1);
assert_eq!(result.graph.elements()[0].name(), "MyApp");
assert!(result.warnings.is_empty());
}
#[test]
fn project_level_without_analyzer_returns_error() {
let use_case = BuildCodeGraph {
discovery: FakeFileDiscovery::empty(),
source_analyzer: FakeSourceAnalyzer::new(),
project_analyzer: None,
};
let err = use_case
.execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Project)
.unwrap_err();
assert!(err.to_string().contains("no project analyzer"));
}
#[test]
fn type_level_uses_source_analyzer_not_project() {
let files = vec![SourceFile::new(
FilePath::new("src/order.rs").unwrap(),
Language::Rust,
)];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new().with_result(
"src/order.rs",
AnalysisResult::new(
vec![CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap()],
vec![],
vec![],
),
);
let mut project_cg = CodeGraph::new();
project_cg.add_element(
CodeElement::new(
"ProjectElement",
CodeElementKind::Project,
FilePath::new("Cargo.toml").unwrap(),
1,
)
.unwrap(),
);
let use_case = BuildCodeGraph {
discovery,
source_analyzer: analyzer,
project_analyzer: Some(Box::new(FakeProjectAnalyzer::new(project_cg))),
};
let result = use_case
.execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Type)
.unwrap();
// Source element present, project element NOT merged (Type level skips merge)
assert_eq!(result.graph.elements().len(), 1);
assert_eq!(result.graph.elements()[0].name(), "Order");
}
#[test]
fn module_level_without_project_analyzer_succeeds() {
let use_case = BuildCodeGraph {
discovery: FakeFileDiscovery::empty(),
source_analyzer: FakeSourceAnalyzer::new(),
project_analyzer: None,
};
let result = use_case
.execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Module)
.unwrap();
assert!(result.graph.elements().is_empty());
}
#[test]
fn warnings_from_source_analysis_are_propagated() {
let files = vec![SourceFile::new(
FilePath::new("src/broken.rs").unwrap(),
Language::Rust,
)];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new().with_result(
"src/broken.rs",
AnalysisResult::new(
vec![],
vec![],
vec![AnalysisWarning::new(
FilePath::new("src/broken.rs").unwrap(),
5,
"unparseable block",
)
.unwrap()],
),
);
let use_case = BuildCodeGraph {
discovery,
source_analyzer: analyzer,
project_analyzer: None,
};
let result = use_case
.execute(Path::new("."), &AnalysisConfig::default(), DiagramLevel::Module)
.unwrap();
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].message(), "unparseable block");
}

View File

@@ -3,9 +3,11 @@
mod diagram_renderer; mod diagram_renderer;
mod file_discovery; mod file_discovery;
mod output_writer; mod output_writer;
mod project_analyzer;
mod source_analyzer; mod source_analyzer;
pub use diagram_renderer::FakeDiagramRenderer; pub use diagram_renderer::FakeDiagramRenderer;
pub use file_discovery::FakeFileDiscovery; pub use file_discovery::FakeFileDiscovery;
pub use output_writer::FakeOutputWriter; pub use output_writer::FakeOutputWriter;
pub use project_analyzer::FakeProjectAnalyzer;
pub use source_analyzer::FakeSourceAnalyzer; pub use source_analyzer::FakeSourceAnalyzer;

View File

@@ -0,0 +1,23 @@
use std::path::Path;
use archlens_domain::{CodeGraph, DomainError, ports::ProjectAnalyzer};
pub struct FakeProjectAnalyzer {
graph: CodeGraph,
}
impl FakeProjectAnalyzer {
pub fn new(graph: CodeGraph) -> Self {
Self { graph }
}
pub fn empty() -> Self {
Self { graph: CodeGraph::new() }
}
}
impl ProjectAnalyzer for FakeProjectAnalyzer {
fn analyze(&self, _root: &Path) -> Result<CodeGraph, DomainError> {
Ok(self.graph.clone())
}
}

View File

@@ -2,8 +2,8 @@ mod cli;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use archlens_application::queries::AnalyzeCodebase;
use archlens_application::use_cases::{ use archlens_application::use_cases::{
build_code_graph::BuildCodeGraph,
check_freshness::CheckFreshness, check_freshness::CheckFreshness,
diff_diagram::DiffDiagram, diff_diagram::DiffDiagram,
generate_diagram::GenerateDiagram, generate_diagram::GenerateDiagram,
@@ -152,23 +152,28 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
analysis_config = analysis_config.with_changed_files(changed); analysis_config = analysis_config.with_changed_files(changed);
} }
if level == DiagramLevel::Project { let project_analyzer: Option<Box<dyn ProjectAnalyzer>> = {
let cargo_toml = args.path.join("Cargo.toml"); let cargo_toml = args.path.join("Cargo.toml");
let project_graph = if cargo_toml.exists() { let pyproject = args.path.join("pyproject.toml");
CargoWorkspaceAnalyzer::new().analyze(&args.path)? if cargo_toml.exists() {
Some(Box::new(CargoWorkspaceAnalyzer::new()))
} else if pyproject.exists() {
Some(Box::new(PythonProjectAnalyzer::new()))
} else { } else {
PythonProjectAnalyzer::new().analyze(&args.path)? None
};
return Ok(NormalizedGraph::from_project(project_graph));
} }
};
let discovery = WalkdirDiscovery::new(); let use_case = BuildCodeGraph {
let analyzer = TreeSitterAnalyzer::new(); discovery: WalkdirDiscovery::new(),
let analyze = AnalyzeCodebase::new(discovery, analyzer); source_analyzer: TreeSitterAnalyzer::new(),
let result = analyze.execute(&args.path, &analysis_config)?; project_analyzer,
};
if !result.warnings().is_empty() { let result = use_case.execute(&args.path, &analysis_config, level)?;
for warning in result.warnings() {
if !result.warnings.is_empty() {
for warning in &result.warnings {
eprintln!( eprintln!(
"WARNING: {}:{} {}", "WARNING: {}:{} {}",
warning.file_path().as_str(), warning.file_path().as_str(),
@@ -179,26 +184,12 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
if args.strict { if args.strict {
bail!( bail!(
"analysis produced {} warning(s) in strict mode", "analysis produced {} warning(s) in strict mode",
result.warnings().len() result.warnings.len()
); );
} }
} }
let mut graph = result.graph().clone(); Ok(result.graph)
if level == DiagramLevel::Module {
let workspace_toml = args.path.join("Cargo.toml");
let project_graph = if workspace_toml.exists() {
CargoWorkspaceAnalyzer::new().analyze(&args.path).ok()
} else {
PythonProjectAnalyzer::new().analyze(&args.path).ok()
};
if let Some(pg) = project_graph {
graph.merge_project_edges(&pg);
}
}
Ok(graph)
} }
fn create_renderer( fn create_renderer(