diff --git a/crates/application/src/use_cases/build_code_graph.rs b/crates/application/src/use_cases/build_code_graph.rs new file mode 100644 index 0000000..ed9daa8 --- /dev/null +++ b/crates/application/src/use_cases/build_code_graph.rs @@ -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, +} + +pub struct BuildCodeGraph +where + F: FileDiscovery + Send + Sync, + S: SourceAnalyzer, +{ + pub discovery: F, + pub source_analyzer: S, + pub project_analyzer: Option>, +} + +impl BuildCodeGraph +where + F: FileDiscovery + Send + Sync, + S: SourceAnalyzer, +{ + pub fn execute( + self, + root: &Path, + config: &AnalysisConfig, + level: DiagramLevel, + ) -> Result { + 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(), + }) + } + } + } +} diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs index 4c069d1..7a3c25a 100644 --- a/crates/application/src/use_cases/mod.rs +++ b/crates/application/src/use_cases/mod.rs @@ -1,3 +1,4 @@ +pub mod build_code_graph; pub mod check_freshness; pub mod diff_diagram; pub mod generate_diagram; diff --git a/crates/application/tests/build_code_graph_tests.rs b/crates/application/tests/build_code_graph_tests.rs new file mode 100644 index 0000000..d431374 --- /dev/null +++ b/crates/application/tests/build_code_graph_tests.rs @@ -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"); +} diff --git a/crates/application/tests/fakes/mod.rs b/crates/application/tests/fakes/mod.rs index 3272980..0ca1c2a 100644 --- a/crates/application/tests/fakes/mod.rs +++ b/crates/application/tests/fakes/mod.rs @@ -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; diff --git a/crates/application/tests/fakes/project_analyzer.rs b/crates/application/tests/fakes/project_analyzer.rs new file mode 100644 index 0000000..6c6ad8a --- /dev/null +++ b/crates/application/tests/fakes/project_analyzer.rs @@ -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 { + Ok(self.graph.clone()) + } +} diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index 4449f3a..5fd7c04 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -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 { analysis_config = analysis_config.with_changed_files(changed); } - if level == DiagramLevel::Project { + let project_analyzer: Option> = { 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 { 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(