feat: add BuildCodeGraph use case, sink orchestration out of presentation
This commit is contained in:
67
crates/application/src/use_cases/build_code_graph.rs
Normal file
67
crates/application/src/use_cases/build_code_graph.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod build_code_graph;
|
||||
pub mod check_freshness;
|
||||
pub mod diff_diagram;
|
||||
pub mod generate_diagram;
|
||||
|
||||
152
crates/application/tests/build_code_graph_tests.rs
Normal file
152
crates/application/tests/build_code_graph_tests.rs
Normal 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");
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
mod diagram_renderer;
|
||||
mod file_discovery;
|
||||
mod output_writer;
|
||||
mod project_analyzer;
|
||||
mod source_analyzer;
|
||||
|
||||
pub use diagram_renderer::FakeDiagramRenderer;
|
||||
pub use file_discovery::FakeFileDiscovery;
|
||||
pub use output_writer::FakeOutputWriter;
|
||||
pub use project_analyzer::FakeProjectAnalyzer;
|
||||
pub use source_analyzer::FakeSourceAnalyzer;
|
||||
|
||||
23
crates/application/tests/fakes/project_analyzer.rs
Normal file
23
crates/application/tests/fakes/project_analyzer.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ mod cli;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use archlens_application::queries::AnalyzeCodebase;
|
||||
use archlens_application::use_cases::{
|
||||
build_code_graph::BuildCodeGraph,
|
||||
check_freshness::CheckFreshness,
|
||||
diff_diagram::DiffDiagram,
|
||||
generate_diagram::GenerateDiagram,
|
||||
@@ -152,23 +152,28 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
|
||||
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 project_graph = if cargo_toml.exists() {
|
||||
CargoWorkspaceAnalyzer::new().analyze(&args.path)?
|
||||
let pyproject = args.path.join("pyproject.toml");
|
||||
if cargo_toml.exists() {
|
||||
Some(Box::new(CargoWorkspaceAnalyzer::new()))
|
||||
} else if pyproject.exists() {
|
||||
Some(Box::new(PythonProjectAnalyzer::new()))
|
||||
} else {
|
||||
PythonProjectAnalyzer::new().analyze(&args.path)?
|
||||
};
|
||||
return Ok(NormalizedGraph::from_project(project_graph));
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let discovery = WalkdirDiscovery::new();
|
||||
let analyzer = TreeSitterAnalyzer::new();
|
||||
let analyze = AnalyzeCodebase::new(discovery, analyzer);
|
||||
let result = analyze.execute(&args.path, &analysis_config)?;
|
||||
let use_case = BuildCodeGraph {
|
||||
discovery: WalkdirDiscovery::new(),
|
||||
source_analyzer: TreeSitterAnalyzer::new(),
|
||||
project_analyzer,
|
||||
};
|
||||
|
||||
if !result.warnings().is_empty() {
|
||||
for warning in result.warnings() {
|
||||
let result = use_case.execute(&args.path, &analysis_config, level)?;
|
||||
|
||||
if !result.warnings.is_empty() {
|
||||
for warning in &result.warnings {
|
||||
eprintln!(
|
||||
"WARNING: {}:{} {}",
|
||||
warning.file_path().as_str(),
|
||||
@@ -179,26 +184,12 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
|
||||
if args.strict {
|
||||
bail!(
|
||||
"analysis produced {} warning(s) in strict mode",
|
||||
result.warnings().len()
|
||||
result.warnings.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut graph = result.graph().clone();
|
||||
|
||||
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)
|
||||
Ok(result.graph)
|
||||
}
|
||||
|
||||
fn create_renderer(
|
||||
|
||||
Reference in New Issue
Block a user