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 check_freshness;
|
||||||
pub mod diff_diagram;
|
pub mod diff_diagram;
|
||||||
pub mod generate_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 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;
|
||||||
|
|||||||
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 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(
|
||||||
|
|||||||
Reference in New Issue
Block a user