init: archlens — architecture diagram generator
Some checks failed
CI / Check / Test (push) Failing after 1m24s

Hex arch + DDD, tree-sitter parsing, Mermaid/ASCII output.
Supports Rust + Python. 92 tests. CI, diff, --check for staleness detection.
This commit is contained in:
2026-06-16 16:13:04 +02:00
commit 35f27d00b0
106 changed files with 6744 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
[package]
name = "archlens-ascii"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,153 @@
use std::collections::HashMap;
use archlens_domain::{
CodeElement, CodeGraph, DomainError, RelationshipKind, RenderOutput, RenderedFile,
ports::DiagramRenderer,
};
pub struct AsciiRenderer;
impl Default for AsciiRenderer {
fn default() -> Self {
Self::new()
}
}
impl AsciiRenderer {
pub fn new() -> Self {
Self
}
fn format_kind(element: &CodeElement) -> &'static str {
match element.kind() {
archlens_domain::CodeElementKind::Class => "cls",
archlens_domain::CodeElementKind::Struct => "str",
archlens_domain::CodeElementKind::Trait => "trt",
archlens_domain::CodeElementKind::Interface => "ifc",
archlens_domain::CodeElementKind::Enum => "enm",
archlens_domain::CodeElementKind::Project => "prj",
}
}
}
impl DiagramRenderer for AsciiRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
let mut lines = Vec::new();
let total_elements = graph.elements().len();
let total_rels = graph.relationships().len();
let total_modules = graph.modules().len();
lines.push("╔══════════════════════════════════════╗".to_string());
lines.push("║ Architecture Overview ║".to_string());
lines.push("╠══════════════════════════════════════╣".to_string());
lines.push(format!(
"║ Elements: {:<5} Modules: {:<5}",
total_elements, total_modules
));
lines.push(format!("║ Relationships: {:<19}", total_rels));
lines.push("╚══════════════════════════════════════╝".to_string());
if graph.elements().is_empty() {
lines.push(" (no elements found)".to_string());
let content = lines.join("\n");
let file = RenderedFile::new("diagram.txt", &content)?;
return Ok(RenderOutput::single(file));
}
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
if !ungrouped.is_empty() {
lines.push(String::new());
lines.push("┌─ (ungrouped)".to_string());
for el in &ungrouped {
lines.push(format!("│ [{}] {}", Self::format_kind(el), el.name()));
}
lines.push("└───".to_string());
}
let mut module_names: Vec<&String> = grouped.keys().collect();
module_names.sort();
for module in module_names {
let elements = &grouped[module];
lines.push(String::new());
lines.push(format!("┌─ {} ({} types)", module, elements.len()));
lines.push("".to_string());
for (i, el) in elements.iter().enumerate() {
let prefix = if i == elements.len() - 1 {
"└──"
} else {
"├──"
};
let generics = if el.generics().is_empty() {
String::new()
} else {
format!("<{}>", el.generics().join(", "))
};
lines.push(format!(
"{} [{}] {}{}",
prefix,
Self::format_kind(el),
el.name(),
generics
));
}
lines.push("└───".to_string());
}
let non_import_rels: Vec<_> = graph
.relationships()
.iter()
.filter(|r| r.kind() != RelationshipKind::Import)
.collect();
if !non_import_rels.is_empty() {
lines.push(String::new());
lines.push("── Relationships ──".to_string());
for rel in &non_import_rels {
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "extends",
RelationshipKind::Composition => "has",
RelationshipKind::Import => "imports",
};
lines.push(format!(
" {} ─[{}]─> {}",
rel.source(),
arrow,
rel.target()
));
}
}
let import_rels: Vec<_> = graph
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
if !import_rels.is_empty() {
lines.push(String::new());
lines.push(format!("── Imports ({}) ──", import_rels.len()));
for rel in &import_rels {
lines.push(format!(" {} ···> {}", rel.source(), rel.target()));
}
}
let content = lines.join("\n");
let file = RenderedFile::new("diagram.txt", &content)?;
Ok(RenderOutput::single(file))
}
}

View File

@@ -0,0 +1,3 @@
mod ascii_renderer;
pub use ascii_renderer::AsciiRenderer;

View File

@@ -0,0 +1,48 @@
use archlens_ascii::AsciiRenderer;
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, FilePath, Relationship, RelationshipKind,
ports::DiagramRenderer,
};
#[test]
fn renders_elements_and_relationships_as_text() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/service.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
let renderer = AsciiRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("OrderService"));
assert!(content.contains("Order"));
assert!(content.contains("has"));
}
#[test]
fn empty_graph_produces_header_only() {
let renderer = AsciiRenderer::new();
let output = renderer.render(&CodeGraph::new()).unwrap();
let content = output.files()[0].content();
assert!(content.contains("Architecture"));
}

View File

@@ -0,0 +1,15 @@
[package]
name = "archlens-cargo-workspace"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
toml.workspace = true
serde.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,123 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use serde::Deserialize;
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, DomainError, FilePath, ModuleName, Relationship,
RelationshipKind, ports::ProjectAnalyzer,
};
pub struct CargoWorkspaceAnalyzer;
impl Default for CargoWorkspaceAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl CargoWorkspaceAnalyzer {
pub fn new() -> Self {
Self
}
}
#[derive(Deserialize)]
struct WorkspaceToml {
workspace: Option<WorkspaceSection>,
}
#[derive(Deserialize)]
struct WorkspaceSection {
#[serde(default)]
members: Vec<String>,
}
#[derive(Deserialize)]
struct MemberToml {
package: Option<PackageSection>,
#[serde(default)]
dependencies: HashMap<String, toml::Value>,
}
#[derive(Deserialize)]
struct PackageSection {
name: String,
}
impl ProjectAnalyzer for CargoWorkspaceAnalyzer {
fn analyze(&self, root: &Path) -> Result<CodeGraph, DomainError> {
let workspace_toml_path = root.join("Cargo.toml");
let content = std::fs::read_to_string(&workspace_toml_path)
.map_err(|e| DomainError::IoError(e.to_string()))?;
let workspace: WorkspaceToml =
toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?;
let members = workspace.workspace.map(|w| w.members).unwrap_or_default();
let mut graph = CodeGraph::new();
let mut name_set: HashSet<String> = HashSet::new();
let mut member_names: Vec<(String, String)> = Vec::new(); // (member_path, package_name)
for member_path in &members {
let member_cargo = root.join(member_path).join("Cargo.toml");
let member_content = std::fs::read_to_string(&member_cargo)
.map_err(|e| DomainError::IoError(e.to_string()))?;
let member: MemberToml = toml::from_str(&member_content)
.map_err(|e| DomainError::ConfigError(e.to_string()))?;
let package_name = member
.package
.map(|p| p.name)
.unwrap_or_else(|| member_path.clone());
name_set.insert(package_name.clone());
member_names.push((member_path.clone(), package_name));
}
for (member_path, package_name) in &member_names {
let file_path = FilePath::new(&format!("{}/Cargo.toml", member_path))
.map_err(|e| DomainError::IoError(e.to_string()))?;
let mut element =
CodeElement::new(package_name, CodeElementKind::Project, file_path, 1)?;
if let Some(module) = infer_group(member_path) {
element = element.with_module(module);
}
graph.add_element(element);
}
for (member_path, package_name) in &member_names {
let member_cargo = root.join(member_path).join("Cargo.toml");
let member_content = std::fs::read_to_string(&member_cargo)
.map_err(|e| DomainError::IoError(e.to_string()))?;
let member: MemberToml = toml::from_str(&member_content)
.map_err(|e| DomainError::ConfigError(e.to_string()))?;
for dep_name in member.dependencies.keys() {
let normalized = dep_name.replace('_', "-");
if name_set.contains(&normalized)
&& let Ok(rel) =
Relationship::new(package_name, &normalized, RelationshipKind::Composition)
{
graph.add_relationship(rel);
}
}
}
Ok(graph)
}
}
fn infer_group(member_path: &str) -> Option<ModuleName> {
let parts: Vec<&str> = member_path.split('/').collect();
if parts.len() < 3 {
return None;
}
let group = parts[parts.len() - 2];
let capitalized = format!("{}{}", group[..1].to_uppercase(), &group[1..]);
ModuleName::new(&capitalized).ok()
}

View File

@@ -0,0 +1,3 @@
mod cargo_workspace_analyzer;
pub use cargo_workspace_analyzer::CargoWorkspaceAnalyzer;

View File

@@ -0,0 +1,132 @@
use std::fs;
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
use archlens_domain::{CodeElementKind, RelationshipKind, ports::ProjectAnalyzer};
fn create_workspace(dir: &std::path::Path) {
fs::write(
dir.join("Cargo.toml"),
r#"
[workspace]
members = ["crates/domain", "crates/application", "crates/adapters/sqlite"]
"#,
)
.unwrap();
fs::create_dir_all(dir.join("crates/domain/src")).unwrap();
fs::write(
dir.join("crates/domain/Cargo.toml"),
r#"
[package]
name = "myapp-domain"
version = "0.1.0"
edition = "2024"
[dependencies]
"#,
)
.unwrap();
fs::write(dir.join("crates/domain/src/lib.rs"), "").unwrap();
fs::create_dir_all(dir.join("crates/application/src")).unwrap();
fs::write(
dir.join("crates/application/Cargo.toml"),
r#"
[package]
name = "myapp-application"
version = "0.1.0"
edition = "2024"
[dependencies]
myapp-domain = { path = "../domain" }
"#,
)
.unwrap();
fs::write(dir.join("crates/application/src/lib.rs"), "").unwrap();
fs::create_dir_all(dir.join("crates/adapters/sqlite/src")).unwrap();
fs::write(
dir.join("crates/adapters/sqlite/Cargo.toml"),
r#"
[package]
name = "myapp-sqlite"
version = "0.1.0"
edition = "2024"
[dependencies]
myapp-domain = { path = "../../domain" }
"#,
)
.unwrap();
fs::write(dir.join("crates/adapters/sqlite/src/lib.rs"), "").unwrap();
}
#[test]
fn discovers_workspace_members_as_project_elements() {
let dir = tempfile::tempdir().unwrap();
create_workspace(dir.path());
let analyzer = CargoWorkspaceAnalyzer::new();
let graph = analyzer.analyze(dir.path()).unwrap();
assert_eq!(graph.elements().len(), 3);
assert!(
graph
.elements()
.iter()
.all(|e| e.kind() == CodeElementKind::Project)
);
let names: Vec<&str> = graph.elements().iter().map(|e| e.name()).collect();
assert!(names.contains(&"myapp-domain"));
assert!(names.contains(&"myapp-application"));
assert!(names.contains(&"myapp-sqlite"));
}
#[test]
fn extracts_dependencies_between_workspace_members() {
let dir = tempfile::tempdir().unwrap();
create_workspace(dir.path());
let analyzer = CargoWorkspaceAnalyzer::new();
let graph = analyzer.analyze(dir.path()).unwrap();
assert_eq!(graph.relationships().len(), 2);
assert!(
graph
.relationships()
.iter()
.all(|r| r.kind() == RelationshipKind::Composition)
);
let deps: Vec<(&str, &str)> = graph
.relationships()
.iter()
.map(|r| (r.source(), r.target()))
.collect();
assert!(deps.contains(&("myapp-application", "myapp-domain")));
assert!(deps.contains(&("myapp-sqlite", "myapp-domain")));
}
#[test]
fn assigns_module_from_directory_grouping() {
let dir = tempfile::tempdir().unwrap();
create_workspace(dir.path());
let analyzer = CargoWorkspaceAnalyzer::new();
let graph = analyzer.analyze(dir.path()).unwrap();
let sqlite = graph
.elements()
.iter()
.find(|e| e.name() == "myapp-sqlite")
.unwrap();
assert_eq!(sqlite.module().unwrap().as_str(), "Adapters");
let domain = graph
.elements()
.iter()
.find(|e| e.name() == "myapp-domain")
.unwrap();
assert!(domain.module().is_none() || domain.module().unwrap().as_str() != "Adapters");
}

View File

@@ -0,0 +1,13 @@
[package]
name = "archlens-file-writer"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,52 @@
use std::fs;
use std::path::PathBuf;
use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter};
pub struct FileOutputWriter {
output_path: OutputPath,
}
enum OutputPath {
Directory(PathBuf),
File(PathBuf),
}
impl FileOutputWriter {
pub fn new(output_dir: PathBuf) -> Self {
Self {
output_path: OutputPath::Directory(output_dir),
}
}
pub fn single_file(path: PathBuf) -> Self {
Self {
output_path: OutputPath::File(path),
}
}
}
impl OutputWriter for FileOutputWriter {
fn write(&self, output: &RenderOutput) -> Result<(), DomainError> {
match &self.output_path {
OutputPath::File(path) => {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| DomainError::IoError(e.to_string()))?;
}
let content = output.files().first().map(|f| f.content()).unwrap_or("");
fs::write(path, content).map_err(|e| DomainError::IoError(e.to_string()))?;
}
OutputPath::Directory(dir) => {
fs::create_dir_all(dir).map_err(|e| DomainError::IoError(e.to_string()))?;
for file in output.files() {
let path = dir.join(file.name());
fs::write(&path, file.content())
.map_err(|e| DomainError::IoError(e.to_string()))?;
}
}
}
Ok(())
}
}

View File

@@ -0,0 +1,3 @@
mod file_output_writer;
pub use file_output_writer::FileOutputWriter;

View File

@@ -0,0 +1,35 @@
use std::fs;
use archlens_domain::{RenderOutput, RenderedFile, ports::OutputWriter};
use archlens_file_writer::FileOutputWriter;
#[test]
fn writes_single_file_to_directory() {
let dir = tempfile::tempdir().unwrap();
let writer = FileOutputWriter::new(dir.path().to_path_buf());
let file = RenderedFile::new("arch.mmd", "classDiagram").unwrap();
let output = RenderOutput::single(file);
writer.write(&output).unwrap();
let content = fs::read_to_string(dir.path().join("arch.mmd")).unwrap();
assert_eq!(content, "classDiagram");
}
#[test]
fn writes_multiple_files_to_directory() {
let dir = tempfile::tempdir().unwrap();
let writer = FileOutputWriter::new(dir.path().to_path_buf());
let files = vec![
RenderedFile::new("overview.mmd", "graph TD").unwrap(),
RenderedFile::new("orders.mmd", "classDiagram").unwrap(),
];
let output = RenderOutput::new(files);
writer.write(&output).unwrap();
assert!(dir.path().join("overview.mmd").exists());
assert!(dir.path().join("orders.mmd").exists());
}

View File

@@ -0,0 +1,10 @@
[package]
name = "archlens-mermaid"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,3 @@
mod mermaid_renderer;
pub use mermaid_renderer::MermaidRenderer;

View File

@@ -0,0 +1,266 @@
use std::collections::{HashMap, HashSet};
use archlens_domain::{
CodeElement, CodeGraph, DiagramLevel, DomainError, RelationshipKind, RenderOutput,
RenderedFile, Visibility, ports::DiagramRenderer,
};
pub struct MermaidRenderer {
level: DiagramLevel,
}
impl Default for MermaidRenderer {
fn default() -> Self {
Self::new()
}
}
impl MermaidRenderer {
pub fn new() -> Self {
Self {
level: DiagramLevel::Type,
}
}
pub fn with_level(level: DiagramLevel) -> Self {
Self { level }
}
fn format_element_name(element: &CodeElement) -> String {
let name = element.name();
if element.generics().is_empty() {
name.to_string()
} else {
format!("{}~{}~", name, element.generics().join(", "))
}
}
fn format_visibility(visibility: Visibility) -> &'static str {
match visibility {
Visibility::Public => "public",
Visibility::Private => "private",
Visibility::Internal => "internal",
}
}
fn render_class_diagram(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["classDiagram".to_string()];
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
let has_namespaces = !grouped.is_empty();
let mut seen: HashSet<String> = HashSet::new();
for element in &ungrouped {
if seen.insert(element.name().to_string()) {
Self::push_class_lines(&mut lines, element, " ");
}
}
if has_namespaces {
for (namespace, elements) in &grouped {
lines.push(format!(" namespace {namespace} {{"));
let mut ns_seen: HashSet<String> = HashSet::new();
for element in elements {
if ns_seen.insert(element.name().to_string()) {
Self::push_class_lines(&mut lines, element, " ");
}
}
lines.push(" }".to_string());
}
}
let mut rel_seen: HashSet<String> = HashSet::new();
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
continue;
}
let arrow = match rel.kind() {
RelationshipKind::Inheritance => "<|--",
RelationshipKind::Composition => "-->",
RelationshipKind::Import => "..>",
};
let key = format!("{} {} {}", rel.source(), arrow, rel.target());
if rel_seen.insert(key.clone()) {
lines.push(format!(" {key}"));
}
}
lines.join("\n")
}
fn push_class_lines(lines: &mut Vec<String>, element: &CodeElement, indent: &str) {
lines.push(format!(
"{indent}class {}",
Self::format_element_name(element)
));
if element.visibility() != Visibility::Public {
lines.push(format!(
"{indent}<<{}>> {}",
Self::format_visibility(element.visibility()),
element.name()
));
}
let name = element.name();
for field in element.fields() {
lines.push(format!("{indent}{name} : {field}"));
}
for method in element.methods() {
lines.push(format!("{indent}{name} : {method}"));
}
}
fn render_module_flowchart(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["graph TD".to_string()];
let mut name_to_modules: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut file_to_module: HashMap<String, String> = HashMap::new();
let mut modules: HashSet<String> = HashSet::new();
for element in graph.elements() {
if let Some(module) = element.module() {
name_to_modules
.entry(element.name())
.or_default()
.insert(module.as_str());
modules.insert(module.as_str().to_string());
let file_stem = std::path::Path::new(element.file_path().as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if !file_stem.is_empty() {
file_to_module.insert(file_stem.to_string(), module.as_str().to_string());
}
}
}
for module in &modules {
lines.push(format!(" {module}[{module}]"));
}
let mut module_edges: HashSet<(String, String)> = HashSet::new();
for rel in graph.relationships() {
match rel.kind() {
RelationshipKind::Import => {
let source_mod = file_to_module.get(rel.source());
let target_top = rel.target().split('.').next().unwrap_or("");
let target_mod = Self::capitalize(target_top);
if let Some(src) = source_mod
&& modules.contains(&target_mod)
&& *src != target_mod
{
module_edges.insert((src.clone(), target_mod));
}
}
_ => {
if modules.contains(rel.source())
&& modules.contains(rel.target())
&& rel.source() != rel.target()
{
module_edges.insert((rel.source().to_string(), rel.target().to_string()));
continue;
}
let src_mods = name_to_modules.get(rel.source());
let tgt_mods = name_to_modules.get(rel.target());
if let (Some(src_set), Some(tgt_set)) = (src_mods, tgt_mods) {
for src_mod in src_set {
if tgt_set.contains(src_mod) {
continue;
}
for tgt_mod in tgt_set {
if src_mod != tgt_mod {
module_edges.insert((src_mod.to_string(), tgt_mod.to_string()));
}
}
}
}
}
}
}
for (source, target) in &module_edges {
lines.push(format!(" {source} --> {target}"));
}
lines.join("\n")
}
fn capitalize(s: &str) -> String {
if s.is_empty() {
return String::new();
}
format!("{}{}", s[..1].to_uppercase(), &s[1..])
}
fn render_project_flowchart(&self, graph: &CodeGraph) -> String {
let mut lines = vec!["graph TD".to_string()];
let mut grouped: HashMap<String, Vec<&CodeElement>> = HashMap::new();
let mut ungrouped: Vec<&CodeElement> = Vec::new();
for element in graph.elements() {
if let Some(module) = element.module() {
grouped
.entry(module.as_str().to_string())
.or_default()
.push(element);
} else {
ungrouped.push(element);
}
}
for element in &ungrouped {
let id = Self::sanitize_id(element.name());
lines.push(format!(" {id}[{}]", element.name()));
}
for (group, elements) in &grouped {
lines.push(format!(" subgraph {group}"));
for element in elements {
let id = Self::sanitize_id(element.name());
lines.push(format!(" {id}[{}]", element.name()));
}
lines.push(" end".to_string());
}
for rel in graph.relationships() {
let source_id = Self::sanitize_id(rel.source());
let target_id = Self::sanitize_id(rel.target());
lines.push(format!(" {source_id} --> {target_id}"));
}
lines.join("\n")
}
fn sanitize_id(name: &str) -> String {
name.replace(['-', '.'], "_")
}
}
impl DiagramRenderer for MermaidRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
let content = match self.level {
DiagramLevel::Type => self.render_class_diagram(graph),
DiagramLevel::Module => self.render_module_flowchart(graph),
DiagramLevel::Project => self.render_project_flowchart(graph),
};
let file = RenderedFile::new("diagram.mmd", &content)?;
Ok(RenderOutput::single(file))
}
}

View File

@@ -0,0 +1,328 @@
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, DiagramLevel, FilePath, ModuleName, Relationship,
RelationshipKind, Visibility, ports::DiagramRenderer,
};
use archlens_mermaid::MermaidRenderer;
fn build_type_level_graph() -> CodeGraph {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/service.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
graph
}
#[test]
fn renders_class_diagram_with_elements_and_composition() {
let renderer = MermaidRenderer::new();
let output = renderer.render(&build_type_level_graph()).unwrap();
let content = output.files()[0].content();
assert!(content.contains("classDiagram"));
assert!(content.contains("class OrderService"));
assert!(content.contains("class Order"));
assert!(content.contains("OrderService --> Order"));
}
#[test]
fn inheritance_uses_different_arrow() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"Animal",
CodeElementKind::Class,
FilePath::new("src/animal.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"Dog",
CodeElementKind::Class,
FilePath::new("src/dog.rs").unwrap(),
1,
)
.unwrap(),
);
graph.add_relationship(
Relationship::new("Dog", "Animal", RelationshipKind::Inheritance).unwrap(),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("Dog <|-- Animal"));
}
#[test]
fn elements_show_kind_and_generics() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"Repository",
CodeElementKind::Trait,
FilePath::new("src/repo.rs").unwrap(),
1,
)
.unwrap()
.with_generics(vec!["T".to_string()]),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("class Repository~T~"));
}
#[test]
fn private_elements_show_visibility_annotation() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"InternalHelper",
CodeElementKind::Class,
FilePath::new("src/helper.rs").unwrap(),
1,
)
.unwrap()
.with_visibility(Visibility::Private),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("class InternalHelper"));
assert!(content.contains("<<private>>"));
}
#[test]
fn renders_module_level_flowchart() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"BillingService",
CodeElementKind::Class,
FilePath::new("src/billing/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Billing").unwrap()),
);
graph.add_relationship(
Relationship::new(
"OrderService",
"BillingService",
RelationshipKind::Composition,
)
.unwrap(),
);
let renderer = MermaidRenderer::with_level(DiagramLevel::Module);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("graph TD"));
assert!(content.contains("Orders"));
assert!(content.contains("Billing"));
assert!(content.contains("Orders --> Billing"));
}
#[test]
fn empty_graph_produces_valid_diagram() {
let renderer = MermaidRenderer::new();
let output = renderer.render(&CodeGraph::new()).unwrap();
let content = output.files()[0].content();
assert!(content.contains("classDiagram"));
}
#[test]
fn project_level_renders_subgraphs_for_grouped_projects() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"myapp-domain",
CodeElementKind::Project,
FilePath::new("crates/domain/Cargo.toml").unwrap(),
1,
)
.unwrap(),
);
graph.add_element(
CodeElement::new(
"myapp-sqlite",
CodeElementKind::Project,
FilePath::new("crates/adapters/sqlite/Cargo.toml").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Adapters").unwrap()),
);
graph.add_element(
CodeElement::new(
"myapp-nats",
CodeElementKind::Project,
FilePath::new("crates/adapters/nats/Cargo.toml").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Adapters").unwrap()),
);
graph.add_relationship(
Relationship::new(
"myapp-sqlite",
"myapp-domain",
RelationshipKind::Composition,
)
.unwrap(),
);
let renderer = MermaidRenderer::with_level(DiagramLevel::Project);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("graph TD"));
assert!(content.contains("subgraph Adapters"));
assert!(content.contains("myapp-sqlite"));
assert!(content.contains("myapp-nats"));
assert!(content.contains("myapp-domain"));
assert!(content.contains("-->"));
}
#[test]
fn type_level_groups_elements_by_module() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/orders/order.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"Invoice",
CodeElementKind::Struct,
FilePath::new("src/billing/invoice.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Billing").unwrap()),
);
let renderer = MermaidRenderer::new();
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
assert!(content.contains("namespace Orders"));
assert!(content.contains("namespace Billing"));
assert!(content.contains("OrderService"));
assert!(content.contains("Invoice"));
}
#[test]
fn module_level_aggregates_cross_module_deps_into_single_arrow() {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"OrderRepo",
CodeElementKind::Trait,
FilePath::new("src/orders/repo.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"DbPool",
CodeElementKind::Struct,
FilePath::new("src/infra/db.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Infra").unwrap()),
);
graph.add_element(
CodeElement::new(
"SqliteRepo",
CodeElementKind::Struct,
FilePath::new("src/infra/sqlite.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Infra").unwrap()),
);
// Two types in Orders depend on two types in Infra
graph.add_relationship(
Relationship::new("OrderService", "DbPool", RelationshipKind::Composition).unwrap(),
);
graph.add_relationship(
Relationship::new("OrderRepo", "SqliteRepo", RelationshipKind::Composition).unwrap(),
);
let renderer = MermaidRenderer::with_level(DiagramLevel::Module);
let output = renderer.render(&graph).unwrap();
let content = output.files()[0].content();
let arrow_count = content.matches("Orders --> Infra").count();
assert_eq!(
arrow_count, 1,
"should have exactly one aggregated arrow, got:\n{content}"
);
}

View File

@@ -0,0 +1,10 @@
[package]
name = "archlens-stdout-writer"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,3 @@
mod stdout_output_writer;
pub use stdout_output_writer::StdoutOutputWriter;

View File

@@ -0,0 +1,24 @@
use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter};
pub struct StdoutOutputWriter;
impl Default for StdoutOutputWriter {
fn default() -> Self {
Self::new()
}
}
impl StdoutOutputWriter {
pub fn new() -> Self {
Self
}
}
impl OutputWriter for StdoutOutputWriter {
fn write(&self, output: &RenderOutput) -> Result<(), DomainError> {
for file in output.files() {
println!("{}", file.content());
}
Ok(())
}
}

View File

@@ -0,0 +1,12 @@
use archlens_domain::{RenderOutput, RenderedFile, ports::OutputWriter};
use archlens_stdout_writer::StdoutOutputWriter;
#[test]
fn writes_without_error() {
let writer = StdoutOutputWriter::new();
let file = RenderedFile::new("arch.mmd", "classDiagram").unwrap();
let output = RenderOutput::single(file);
let result = writer.write(&output);
assert!(result.is_ok());
}

View File

@@ -0,0 +1,15 @@
[package]
name = "archlens-toml-config"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
toml.workspace = true
serde.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,3 @@
mod toml_config_loader;
pub use toml_config_loader::TomlConfigLoader;

View File

@@ -0,0 +1,81 @@
use std::collections::HashMap;
use std::path::Path;
use serde::Deserialize;
use archlens_domain::{
AnalysisConfig, DiagramLevel, DomainError, OutputConfig, ports::ConfigLoader,
};
#[derive(Debug, Deserialize, Default)]
struct RawConfig {
#[serde(default)]
analysis: RawAnalysis,
#[serde(default)]
output: RawOutput,
#[serde(default)]
modules: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawAnalysis {
#[serde(default)]
exclude: Vec<String>,
#[serde(default)]
level: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
struct RawOutput {
#[serde(default)]
#[allow(dead_code)]
format: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
split_by_module: bool,
}
#[derive(Default)]
pub struct TomlConfigLoader {
raw: RawConfig,
}
impl TomlConfigLoader {
pub fn from_path(path: &Path) -> Result<Self, DomainError> {
let content =
std::fs::read_to_string(path).map_err(|e| DomainError::IoError(e.to_string()))?;
let raw: RawConfig =
toml::from_str(&content).map_err(|e| DomainError::ConfigError(e.to_string()))?;
Ok(Self { raw })
}
fn parse_level(level: &Option<String>) -> DiagramLevel {
match level.as_deref() {
Some("type") => DiagramLevel::Type,
Some("project") => DiagramLevel::Project,
_ => DiagramLevel::Module,
}
}
}
impl ConfigLoader for TomlConfigLoader {
fn load_analysis_config(&self) -> Result<AnalysisConfig, DomainError> {
let config = AnalysisConfig::default()
.with_excludes(self.raw.analysis.exclude.clone())
.with_level(Self::parse_level(&self.raw.analysis.level))
.with_module_mappings(self.raw.modules.clone());
Ok(config)
}
fn load_output_config(&self) -> Result<OutputConfig, DomainError> {
let mut config =
OutputConfig::default().with_split_by_module(self.raw.output.split_by_module);
if let Some(path) = &self.raw.output.path {
config = config.with_output_path(path.clone());
}
Ok(config)
}
}

View File

@@ -0,0 +1,68 @@
use std::fs;
use archlens_domain::{DiagramLevel, ports::ConfigLoader};
use archlens_toml_config::TomlConfigLoader;
#[test]
fn loads_analysis_config_from_toml_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("archlens.toml");
fs::write(
&config_path,
r#"
[analysis]
exclude = ["tests/", "vendor/"]
level = "type"
[modules]
"src/orders" = "Orders"
"src/billing" = "Billing"
"#,
)
.unwrap();
let loader = TomlConfigLoader::from_path(&config_path).unwrap();
let config = loader.load_analysis_config().unwrap();
assert_eq!(config.excludes(), &["tests/", "vendor/"]);
assert_eq!(config.level(), DiagramLevel::Type);
assert_eq!(
config.module_mappings().get("src/orders").unwrap(),
"Orders"
);
}
#[test]
fn loads_output_config_from_toml_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("archlens.toml");
fs::write(
&config_path,
r#"
[output]
format = "mermaid"
path = "docs/arch.mmd"
split_by_module = true
"#,
)
.unwrap();
let loader = TomlConfigLoader::from_path(&config_path).unwrap();
let config = loader.load_output_config().unwrap();
assert!(config.split_by_module());
assert_eq!(config.output_path(), Some("docs/arch.mmd"));
}
#[test]
fn missing_file_returns_defaults() {
let loader = TomlConfigLoader::default();
let analysis = loader.load_analysis_config().unwrap();
assert!(analysis.excludes().is_empty());
assert_eq!(analysis.level(), DiagramLevel::Module);
let output = loader.load_output_config().unwrap();
assert!(!output.split_by_module());
assert!(output.output_path().is_none());
}

View File

@@ -0,0 +1,17 @@
[package]
name = "archlens-tree-sitter"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
tree-sitter.workspace = true
tree-sitter-rust.workspace = true
tree-sitter-python.workspace = true
tree-sitter-c-sharp.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,5 @@
mod python;
mod rust;
mod tree_sitter_analyzer;
pub use tree_sitter_analyzer::TreeSitterAnalyzer;

View File

@@ -0,0 +1,337 @@
use std::collections::HashSet;
use tree_sitter::{Node, Parser};
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
Relationship, RelationshipKind,
};
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
let tree = parser
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
let mut elements = Vec::new();
let mut relationships = Vec::new();
let mut warnings = Vec::new();
let mut type_names: HashSet<String> = HashSet::new();
let root = tree.root_node();
collect_classes(
&root,
source,
file_path,
&mut elements,
&mut type_names,
&mut relationships,
&mut warnings,
);
collect_imports(&root, source, file_path, &mut relationships);
let relationships = relationships
.into_iter()
.map(|r| r.with_source_file(file_path.clone()))
.collect();
Ok(AnalysisResult::new(elements, relationships, warnings))
}
fn collect_classes(
node: &Node,
source: &str,
file_path: &FilePath,
elements: &mut Vec<CodeElement>,
type_names: &mut HashSet<String>,
relationships: &mut Vec<Relationship>,
warnings: &mut Vec<AnalysisWarning>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "class_definition" {
continue;
}
let Some(name_node) = child.child_by_field_name("name") else {
continue;
};
let name = &source[name_node.byte_range()];
let line = child.start_position().row + 1;
match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) {
Ok(element) => {
type_names.insert(name.to_string());
elements.push(element);
}
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
warnings.push(w);
}
continue;
}
}
if let Some(superclasses) = child.child_by_field_name("superclasses") {
collect_inheritance(&superclasses, source, name, type_names, relationships);
}
if let Some(body) = child.child_by_field_name("body") {
collect_typed_fields(&body, source, name, type_names, relationships);
collect_constructor_params(&body, source, name, type_names, relationships);
}
}
}
fn collect_inheritance(
superclasses: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
let mut cursor = superclasses.walk();
for child in superclasses.children(&mut cursor) {
if child.kind() == "identifier" {
let base_name = &source[child.byte_range()];
if !is_python_builtin(base_name)
&& let Ok(rel) =
Relationship::new(class_name, base_name, RelationshipKind::Inheritance)
{
relationships.push(rel);
}
}
}
}
const PYTHON_BUILTINS: &[&str] = &[
"str",
"int",
"float",
"bool",
"bytes",
"list",
"dict",
"set",
"tuple",
"None",
"type",
"object",
"Exception",
"BaseException",
"Optional",
"Any",
"Union",
"List",
"Dict",
"Set",
"Tuple",
"Callable",
"Sequence",
"Mapping",
"Iterable",
"Iterator",
"Generator",
"Coroutine",
"AsyncGenerator",
"ClassVar",
"Final",
"Literal",
"TypeVar",
"Generic",
"Protocol",
"runtime_checkable",
"Self",
];
fn is_python_builtin(name: &str) -> bool {
PYTHON_BUILTINS.contains(&name)
}
const STDLIB_MODULES: &[&str] = &[
"os",
"sys",
"typing",
"logging",
"json",
"re",
"io",
"abc",
"collections",
"datetime",
"enum",
"functools",
"hashlib",
"http",
"importlib",
"inspect",
"itertools",
"math",
"pathlib",
"pickle",
"random",
"shutil",
"signal",
"socket",
"string",
"subprocess",
"tempfile",
"threading",
"time",
"traceback",
"unittest",
"urllib",
"uuid",
"warnings",
"contextlib",
"dataclasses",
"copy",
"struct",
"base64",
"csv",
"glob",
"operator",
"textwrap",
"asyncio",
"concurrent",
"multiprocessing",
];
fn is_external_import(module: &str) -> bool {
let top = module.split('.').next().unwrap_or(module);
if STDLIB_MODULES.contains(&top) {
return true;
}
if top.starts_with('_') {
return true;
}
false
}
fn collect_imports(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"import_statement" => {
let mut name_cursor = child.walk();
for name_child in child.children(&mut name_cursor) {
if name_child.kind() == "dotted_name" {
let module = &source[name_child.byte_range()];
if !is_external_import(module)
&& let Ok(rel) =
Relationship::new(file_name, module, RelationshipKind::Import)
{
relationships.push(rel);
}
}
}
}
"import_from_statement" => {
if let Some(module_node) = child.child_by_field_name("module_name") {
let module = &source[module_node.byte_range()];
if !is_external_import(module)
&& let Ok(rel) =
Relationship::new(file_name, module, RelationshipKind::Import)
{
relationships.push(rel);
}
}
}
_ => {}
}
}
}
fn collect_constructor_params(
body: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() != "function_definition" {
continue;
}
let Some(fn_name) = child.child_by_field_name("name") else {
continue;
};
if &source[fn_name.byte_range()] != "__init__" {
continue;
}
let Some(params) = child.child_by_field_name("parameters") else {
continue;
};
let mut param_cursor = params.walk();
for param in params.children(&mut param_cursor) {
if param.kind() == "typed_parameter"
&& let Some(type_node) = param.child_by_field_name("type")
{
let type_text = &source[type_node.byte_range()];
let base_type = type_text.split('[').next().unwrap_or(type_text).trim();
if base_type != class_name
&& !base_type.is_empty()
&& !is_python_builtin(base_type)
&& let Ok(rel) =
Relationship::new(class_name, base_type, RelationshipKind::Composition)
{
relationships.push(rel);
}
}
}
}
}
fn collect_typed_fields(
body: &Node,
source: &str,
class_name: &str,
type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
collect_typed_fields_recursive(body, source, class_name, type_names, relationships);
}
fn collect_typed_fields_recursive(
node: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if (child.kind() == "assignment" || child.kind() == "typed_assignment")
&& let Some(type_node) = child.child_by_field_name("type")
{
let type_text = &source[type_node.byte_range()];
let base_type = type_text.split('[').next().unwrap_or(type_text).trim();
if base_type != class_name
&& !base_type.is_empty()
&& !is_python_builtin(base_type)
&& let Ok(rel) =
Relationship::new(class_name, base_type, RelationshipKind::Composition)
{
relationships.push(rel);
}
}
collect_typed_fields_recursive(&child, source, class_name, _type_names, relationships);
}
}

View File

@@ -0,0 +1,349 @@
use std::collections::HashSet;
use tree_sitter::{Node, Parser};
const RUST_PRIMITIVES: &[&str] = &[
"bool",
"char",
"str",
"String",
"u8",
"u16",
"u32",
"u64",
"u128",
"usize",
"i8",
"i16",
"i32",
"i64",
"i128",
"isize",
"f32",
"f64",
"Vec",
"Option",
"Result",
"Box",
"Rc",
"Arc",
"HashMap",
"HashSet",
"BTreeMap",
"BTreeSet",
"PhantomData",
"Pin",
"Cow",
"Self",
];
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
Relationship, RelationshipKind, Visibility,
};
pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, DomainError> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_rust::LANGUAGE.into())
.map_err(|e| DomainError::AnalysisError(e.to_string()))?;
let tree = parser
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
let mut elements = Vec::new();
let mut relationships = Vec::new();
let mut warnings = Vec::new();
let mut type_names: HashSet<String> = HashSet::new();
let root = tree.root_node();
collect_types(
&root,
source,
file_path,
&mut elements,
&mut type_names,
&mut warnings,
);
collect_relationships(
&root,
source,
&type_names,
&mut relationships,
&mut warnings,
);
collect_mod_declarations(&root, source, file_path, &mut relationships);
collect_use_imports(&root, source, file_path, &mut relationships);
let relationships = relationships
.into_iter()
.map(|r| r.with_source_file(file_path.clone()))
.collect();
Ok(AnalysisResult::new(elements, relationships, warnings))
}
fn collect_types(
node: &Node,
source: &str,
file_path: &FilePath,
elements: &mut Vec<CodeElement>,
type_names: &mut HashSet<String>,
warnings: &mut Vec<AnalysisWarning>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let (kind, name_field) = match child.kind() {
"struct_item" => (CodeElementKind::Struct, "name"),
"enum_item" => (CodeElementKind::Enum, "name"),
"trait_item" => (CodeElementKind::Trait, "name"),
_ => continue,
};
if let Some(name_node) = child.child_by_field_name(name_field) {
let name = &source[name_node.byte_range()];
let line = child.start_position().row + 1;
let visibility = detect_visibility(&child, source);
match CodeElement::new(name, kind, file_path.clone(), line) {
Ok(element) => {
let fields = extract_fields(&child, source);
let methods = extract_methods(node, source, name);
let element = element
.with_visibility(visibility)
.with_fields(fields)
.with_methods(methods);
type_names.insert(name.to_string());
elements.push(element);
}
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
warnings.push(w);
}
}
}
}
}
}
fn collect_relationships(
node: &Node,
source: &str,
type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
warnings: &mut Vec<AnalysisWarning>,
) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"struct_item" => {
if let Some(name_node) = child.child_by_field_name("name") {
let struct_name = source[name_node.byte_range()].to_string();
collect_field_compositions(
&child,
source,
&struct_name,
type_names,
relationships,
);
}
}
"impl_item" => {
collect_trait_impl(&child, source, type_names, relationships, warnings);
}
_ => {}
}
}
}
fn collect_field_compositions(
struct_node: &Node,
source: &str,
struct_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
if let Some(body) = struct_node.child_by_field_name("body") {
let mut cursor = body.walk();
for field in body.children(&mut cursor) {
if field.kind() == "field_declaration"
&& let Some(type_node) = field.child_by_field_name("type")
{
let type_text = extract_base_type(&type_node, source);
if type_text != struct_name
&& !type_text.is_empty()
&& !RUST_PRIMITIVES.contains(&type_text.as_str())
&& let Ok(rel) =
Relationship::new(struct_name, &type_text, RelationshipKind::Composition)
{
relationships.push(rel);
}
}
}
}
}
fn collect_trait_impl(
impl_node: &Node,
source: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
_warnings: &mut Vec<AnalysisWarning>,
) {
let trait_node = impl_node.child_by_field_name("trait");
let type_node = impl_node.child_by_field_name("type");
if let (Some(trait_n), Some(type_n)) = (trait_node, type_node) {
let trait_name = extract_base_type(&trait_n, source);
let type_name = extract_base_type(&type_n, source);
if !trait_name.is_empty()
&& !type_name.is_empty()
&& !RUST_PRIMITIVES.contains(&trait_name.as_str())
&& !RUST_PRIMITIVES.contains(&type_name.as_str())
&& let Ok(rel) =
Relationship::new(&type_name, &trait_name, RelationshipKind::Inheritance)
{
relationships.push(rel);
}
}
}
fn extract_fields(node: &Node, source: &str) -> Vec<String> {
let mut fields = Vec::new();
if let Some(body) = node.child_by_field_name("body") {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() == "field_declaration" {
let name = child
.child_by_field_name("name")
.map(|n| &source[n.byte_range()]);
let ty = child
.child_by_field_name("type")
.map(|n| extract_base_type(&n, source));
if let (Some(name), Some(ty)) = (name, ty) {
fields.push(format!("{name}: {ty}"));
}
}
}
}
fields
}
fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
let mut methods = Vec::new();
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() != "impl_item" {
continue;
}
if child.child_by_field_name("trait").is_some() {
continue;
}
let type_node = child.child_by_field_name("type");
if let Some(tn) = type_node {
let impl_name = extract_base_type(&tn, source);
if impl_name != type_name {
continue;
}
}
if let Some(body) = child.child_by_field_name("body") {
let mut body_cursor = body.walk();
for item in body.children(&mut body_cursor) {
if item.kind() == "function_item"
&& let Some(name_node) = item.child_by_field_name("name")
{
let fn_name = &source[name_node.byte_range()];
let vis = if detect_visibility(&item, source) == Visibility::Public {
"+"
} else {
"-"
};
methods.push(format!("{vis}{fn_name}()"));
}
}
}
}
methods
}
fn collect_mod_declarations(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "mod_item" {
continue;
}
if child.child_by_field_name("body").is_some() {
continue;
}
if let Some(name_node) = child.child_by_field_name("name") {
let mod_name = &source[name_node.byte_range()];
let target = format!("crate::{mod_name}");
if let Ok(rel) = Relationship::new(file_name, &target, RelationshipKind::Import) {
relationships.push(rel);
}
}
}
}
fn collect_use_imports(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "use_declaration" {
continue;
}
if let Some(arg) = child.child_by_field_name("argument") {
let text = &source[arg.byte_range()];
let path = text
.split('{')
.next()
.unwrap_or(text)
.trim_end_matches("::");
if (path.starts_with("crate::") || path.starts_with("super::"))
&& let Ok(rel) = Relationship::new(file_name, path, RelationshipKind::Import)
{
relationships.push(rel);
}
}
}
}
fn extract_base_type(node: &Node, source: &str) -> String {
let text = &source[node.byte_range()];
text.split('<').next().unwrap_or(text).trim().to_string()
}
fn detect_visibility(node: &Node, source: &str) -> Visibility {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "visibility_modifier" {
let text = &source[child.byte_range()];
if text.contains("pub") {
return Visibility::Public;
}
}
}
Visibility::Private
}

View File

@@ -0,0 +1,30 @@
use archlens_domain::{AnalysisResult, DomainError, Language, SourceFile, ports::SourceAnalyzer};
use crate::{python, rust};
pub struct TreeSitterAnalyzer;
impl Default for TreeSitterAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl TreeSitterAnalyzer {
pub fn new() -> Self {
Self
}
}
impl SourceAnalyzer for TreeSitterAnalyzer {
fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError> {
let source = std::fs::read_to_string(file.path().as_str())
.map_err(|e| DomainError::IoError(e.to_string()))?;
match file.language() {
Language::Rust => rust::analyze(&source, file.path()),
Language::Python => python::analyze(&source, file.path()),
Language::CSharp => Ok(AnalysisResult::empty()),
}
}
}

View File

@@ -0,0 +1,138 @@
use archlens_domain::{
CodeElementKind, FilePath, Language, RelationshipKind, SourceFile, ports::SourceAnalyzer,
};
use archlens_tree_sitter::TreeSitterAnalyzer;
fn analyze_python(source: &str, filename: &str) -> archlens_domain::AnalysisResult {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join(filename);
std::fs::write(&file_path, source).unwrap();
let analyzer = TreeSitterAnalyzer::new();
let source_file = SourceFile::new(
FilePath::new(file_path.to_str().unwrap()).unwrap(),
Language::Python,
);
analyzer.analyze_file(&source_file).unwrap()
}
#[test]
fn extracts_python_class() {
let result = analyze_python("class Order:\n pass\n", "order.py");
assert_eq!(result.elements().len(), 1);
assert_eq!(result.elements()[0].name(), "Order");
assert_eq!(result.elements()[0].kind(), CodeElementKind::Class);
}
#[test]
fn extracts_python_inheritance() {
let source = "class Animal:\n pass\n\nclass Dog(Animal):\n pass\n";
let result = analyze_python(source, "animals.py");
assert_eq!(result.elements().len(), 2);
let inheritance: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Inheritance)
.collect();
assert_eq!(inheritance.len(), 1);
assert_eq!(inheritance[0].source(), "Dog");
assert_eq!(inheritance[0].target(), "Animal");
}
#[test]
fn extracts_composition_from_type_annotated_fields() {
let source = "class Address:\n pass\n\nclass User:\n def __init__(self):\n self.address: Address = Address()\n";
let result = analyze_python(source, "user.py");
let composition: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Composition)
.collect();
assert_eq!(composition.len(), 1);
assert_eq!(composition[0].source(), "User");
assert_eq!(composition[0].target(), "Address");
}
#[test]
fn extracts_import_from_import_statement() {
let source = "import os\nfrom commons.src.schema import BaseModel\n";
let result = analyze_python(source, "service.py");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert!(imports.iter().any(|r| r.target() == "commons.src.schema"));
assert!(
!imports.iter().any(|r| r.target() == "os"),
"stdlib should be filtered"
);
}
#[test]
fn extracts_relative_imports_from_init() {
let source = "from .schema import BaseModel\nfrom .client import ApiClient\n";
let result = analyze_python(source, "__init__.py");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert_eq!(imports.len(), 2);
}
#[test]
fn extracts_import_from_plain_import() {
let source = "import commons.utils\n";
let result = analyze_python(source, "service.py");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert!(imports.iter().any(|r| r.target() == "commons.utils"));
}
#[test]
fn extracts_composition_from_constructor_params() {
let source = "class Config:\n pass\n\nclass Service:\n def __init__(self, config: Config):\n pass\n";
let result = analyze_python(source, "service.py");
let composition: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Composition)
.collect();
assert_eq!(composition.len(), 1);
assert_eq!(composition[0].source(), "Service");
assert_eq!(composition[0].target(), "Config");
}
#[test]
fn extracts_composition_from_class_level_annotations() {
let source = "class Gad:\n pass\n\nclass Definition:\n gad: Gad\n name: str\n";
let result = analyze_python(source, "models.py");
let composition: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Composition)
.collect();
assert_eq!(composition.len(), 1);
assert_eq!(composition[0].source(), "Definition");
assert_eq!(composition[0].target(), "Gad");
}

View File

@@ -0,0 +1,130 @@
use archlens_domain::{
CodeElementKind, FilePath, Language, RelationshipKind, SourceFile, ports::SourceAnalyzer,
};
use archlens_tree_sitter::TreeSitterAnalyzer;
fn analyze_rust(source: &str, filename: &str) -> archlens_domain::AnalysisResult {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join(filename);
std::fs::write(&file_path, source).unwrap();
let analyzer = TreeSitterAnalyzer::new();
let source_file = SourceFile::new(
FilePath::new(file_path.to_str().unwrap()).unwrap(),
Language::Rust,
);
analyzer.analyze_file(&source_file).unwrap()
}
#[test]
fn extracts_rust_struct() {
let result = analyze_rust("pub struct Order {\n id: u64,\n}", "order.rs");
assert_eq!(result.elements().len(), 1);
assert_eq!(result.elements()[0].name(), "Order");
assert_eq!(result.elements()[0].kind(), CodeElementKind::Struct);
}
#[test]
fn extracts_rust_enum() {
let result = analyze_rust(
"pub enum Status {\n Active,\n Inactive,\n}",
"status.rs",
);
assert_eq!(result.elements().len(), 1);
assert_eq!(result.elements()[0].name(), "Status");
assert_eq!(result.elements()[0].kind(), CodeElementKind::Enum);
}
#[test]
fn extracts_rust_trait() {
let result = analyze_rust("pub trait Repository {\n fn find(&self);\n}", "repo.rs");
assert_eq!(result.elements().len(), 1);
assert_eq!(result.elements()[0].name(), "Repository");
assert_eq!(result.elements()[0].kind(), CodeElementKind::Trait);
}
#[test]
fn extracts_composition_from_struct_fields() {
let source =
"pub struct Order {\n id: u64,\n}\npub struct OrderService {\n order: Order,\n}";
let result = analyze_rust(source, "service.rs");
assert_eq!(result.elements().len(), 2);
assert_eq!(result.relationships().len(), 1);
assert_eq!(result.relationships()[0].source(), "OrderService");
assert_eq!(result.relationships()[0].target(), "Order");
assert_eq!(
result.relationships()[0].kind(),
RelationshipKind::Composition
);
}
#[test]
fn extracts_inheritance_from_trait_impl() {
let source = "pub trait Printable {}\npub struct Order {}\nimpl Printable for Order {}";
let result = analyze_rust(source, "order.rs");
let inheritance: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Inheritance)
.collect();
assert_eq!(inheritance.len(), 1);
assert_eq!(inheritance[0].source(), "Order");
assert_eq!(inheritance[0].target(), "Printable");
}
#[test]
fn extracts_use_imports() {
let source =
"use crate::domain::Order;\nuse crate::ports::Repository;\n\npub struct Service {}";
let result = analyze_rust(source, "service.rs");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert!(imports.iter().any(|r| r.target() == "crate::domain::Order"));
assert!(
imports
.iter()
.any(|r| r.target() == "crate::ports::Repository")
);
}
#[test]
fn filters_std_and_external_crate_imports() {
let source =
"use std::collections::HashMap;\nuse serde::Serialize;\nuse crate::models::Order;\n";
let result = analyze_rust(source, "lib.rs");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert_eq!(imports.len(), 1);
assert_eq!(imports[0].target(), "crate::models::Order");
}
#[test]
fn extracts_mod_declarations() {
let source = "mod models;\nmod services;\npub struct App {}";
let result = analyze_rust(source, "lib.rs");
let imports: Vec<_> = result
.relationships()
.iter()
.filter(|r| r.kind() == RelationshipKind::Import)
.collect();
assert!(imports.iter().any(|r| r.target() == "crate::models"));
assert!(imports.iter().any(|r| r.target() == "crate::services"));
}

View File

@@ -0,0 +1,15 @@
[package]
name = "archlens-walkdir"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
walkdir.workspace = true
ignore.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,3 @@
mod walkdir_discovery;
pub use walkdir_discovery::WalkdirDiscovery;

View File

@@ -0,0 +1,100 @@
use std::path::Path;
use ignore::WalkBuilder;
use archlens_domain::{
AnalysisConfig, DomainError, FilePath, Language, SourceFile, ports::FileDiscovery,
};
const DEFAULT_EXCLUDES: &[&str] = &[
".venv",
"venv",
"node_modules",
"__pycache__",
".git",
"target",
"bin",
"obj",
"dist",
".tox",
".eggs",
];
pub struct WalkdirDiscovery;
impl Default for WalkdirDiscovery {
fn default() -> Self {
Self::new()
}
}
impl WalkdirDiscovery {
pub fn new() -> Self {
Self
}
fn detect_language(path: &Path) -> Option<Language> {
match path.extension()?.to_str()? {
"rs" => Some(Language::Rust),
"py" => Some(Language::Python),
"cs" => Some(Language::CSharp),
_ => None,
}
}
fn is_excluded(path: &Path, root: &Path, excludes: &[String]) -> bool {
let relative = path.strip_prefix(root).unwrap_or(path);
let relative_str = relative.to_string_lossy();
for component in relative.components() {
let name = component.as_os_str().to_string_lossy();
if DEFAULT_EXCLUDES.iter().any(|e| name == *e) {
return true;
}
}
excludes
.iter()
.any(|exclude| relative_str.contains(exclude.trim_end_matches('/')))
}
}
impl FileDiscovery for WalkdirDiscovery {
fn discover(
&self,
root: &Path,
config: &AnalysisConfig,
) -> Result<Vec<SourceFile>, DomainError> {
let mut files = Vec::new();
let walker = WalkBuilder::new(root).hidden(true).git_ignore(true).build();
for entry in walker.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
if Self::is_excluded(path, root, config.excludes()) {
continue;
}
if let Some(scope) = config.scope() {
let relative = path.strip_prefix(root).unwrap_or(path);
if !relative.starts_with(scope) {
continue;
}
}
if let Some(language) = Self::detect_language(path) {
let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let file_path = FilePath::new(&absolute.to_string_lossy())
.map_err(|e| DomainError::IoError(e.to_string()))?;
files.push(SourceFile::new(file_path, language));
}
}
Ok(files)
}
}

View File

@@ -0,0 +1,71 @@
use std::fs;
use archlens_domain::{AnalysisConfig, Language, ports::FileDiscovery};
use archlens_walkdir::WalkdirDiscovery;
fn create_test_tree(dir: &std::path::Path) {
fs::create_dir_all(dir.join("src/orders")).unwrap();
fs::create_dir_all(dir.join("src/billing")).unwrap();
fs::write(dir.join("src/orders/service.rs"), "struct OrderService;").unwrap();
fs::write(dir.join("src/orders/model.py"), "class Order: pass").unwrap();
fs::write(dir.join("src/billing/invoice.cs"), "class Invoice {}").unwrap();
fs::write(dir.join("src/readme.txt"), "not source code").unwrap();
}
#[test]
fn discovers_rust_python_and_csharp_files() {
let dir = tempfile::tempdir().unwrap();
create_test_tree(dir.path());
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
assert_eq!(files.len(), 3);
let languages: Vec<Language> = files.iter().map(|f| f.language()).collect();
assert!(languages.contains(&Language::Rust));
assert!(languages.contains(&Language::Python));
assert!(languages.contains(&Language::CSharp));
}
#[test]
fn ignores_non_source_files() {
let dir = tempfile::tempdir().unwrap();
create_test_tree(dir.path());
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path().as_str()).collect();
assert!(!paths.iter().any(|p| p.ends_with(".txt")));
}
#[test]
fn respects_exclude_patterns() {
let dir = tempfile::tempdir().unwrap();
create_test_tree(dir.path());
let config = AnalysisConfig::default().with_excludes(vec!["billing".to_string()]);
let discovery = WalkdirDiscovery::new();
let files = discovery.discover(dir.path(), &config).unwrap();
assert_eq!(files.len(), 2);
assert!(!files.iter().any(|f| f.path().as_str().contains("billing")));
}
#[test]
fn empty_directory_returns_no_files() {
let dir = tempfile::tempdir().unwrap();
let discovery = WalkdirDiscovery::new();
let files = discovery
.discover(dir.path(), &AnalysisConfig::default())
.unwrap();
assert!(files.is_empty());
}

View File

@@ -0,0 +1,11 @@
[package]
name = "archlens-application"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
archlens-domain.workspace = true
thiserror.workspace = true
tracing.workspace = true
rayon.workspace = true

View File

@@ -0,0 +1 @@
pub mod queries;

View File

@@ -0,0 +1,244 @@
use std::collections::HashSet;
use std::path::Path;
use rayon::prelude::*;
use archlens_domain::{
AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship,
RelationshipKind,
ports::{FileDiscovery, SourceAnalyzer},
};
pub struct AnalyzeCodebase<F, S>
where
F: FileDiscovery + Send + Sync,
S: SourceAnalyzer,
{
file_discovery: F,
source_analyzer: S,
}
impl<F, S> AnalyzeCodebase<F, S>
where
F: FileDiscovery + Send + Sync,
S: SourceAnalyzer,
{
pub fn new(file_discovery: F, source_analyzer: S) -> Self {
Self {
file_discovery,
source_analyzer,
}
}
pub fn execute(
&self,
root: &Path,
config: &AnalysisConfig,
) -> Result<AnalyzeCodebaseResult, DomainError> {
let files = self.file_discovery.discover(root, config)?;
let file_results: Vec<(Vec<CodeElement>, Vec<Relationship>, Vec<AnalysisWarning>)> = files
.par_iter()
.map(|file| match self.source_analyzer.analyze_file(file) {
Ok(result) => {
let module = infer_module(file.path().as_str(), root, config);
let elements: Vec<CodeElement> = result
.elements()
.iter()
.map(|el| {
let mut el = el.clone();
if el.module().is_none()
&& let Some(ref m) = module
{
el = el.with_module(m.clone());
}
el
})
.collect();
(
elements,
result.relationships().to_vec(),
result.warnings().to_vec(),
)
}
Err(err) => {
let mut warnings = Vec::new();
if let Ok(warning) =
AnalysisWarning::new(file.path().clone(), 0, &err.to_string())
{
warnings.push(warning);
}
(Vec::new(), Vec::new(), warnings)
}
})
.collect();
let mut graph = CodeGraph::new();
let mut warnings = Vec::new();
for (elements, relationships, warns) in file_results {
for el in elements {
graph.add_element(el);
}
for rel in relationships {
graph.add_relationship(rel);
}
warnings.extend(warns);
}
let graph = resolve_cross_file_relationships(graph);
let graph = filter_external_imports(graph, root);
Ok(AnalyzeCodebaseResult { graph, warnings })
}
}
fn resolve_cross_file_relationships(graph: CodeGraph) -> CodeGraph {
use std::collections::HashMap;
let mut file_types: HashMap<String, HashSet<String>> = HashMap::new();
let mut name_modules: HashMap<&str, HashSet<Option<&str>>> = HashMap::new();
let all_type_names: HashSet<&str> = graph.elements().iter().map(|e| e.name()).collect();
for element in graph.elements() {
file_types
.entry(element.file_path().as_str().to_string())
.or_default()
.insert(element.name().to_string());
name_modules
.entry(element.name())
.or_default()
.insert(element.module().map(|m| m.as_str()));
}
let mut resolved = CodeGraph::new();
for element in graph.elements() {
resolved.add_element(element.clone());
}
for rel in graph.relationships() {
match rel.kind() {
RelationshipKind::Import => {
resolved.add_relationship(rel.clone());
}
_ => {
if !all_type_names.contains(rel.source()) || !all_type_names.contains(rel.target())
{
continue;
}
if let Some(src_file) = rel.source_file() {
let file_key = src_file.as_str().to_string();
if let Some(types_in_file) = file_types.get(&file_key)
&& types_in_file.contains(rel.target())
{
resolved.add_relationship(rel.clone());
continue;
}
}
let tgt_modules = &name_modules[rel.target()];
if tgt_modules.len() == 1 {
resolved.add_relationship(rel.clone());
}
}
}
}
resolved
}
fn filter_external_imports(graph: CodeGraph, root: &Path) -> CodeGraph {
let known_dirs: HashSet<String> = std::fs::read_dir(root)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.map(|s| s.to_lowercase())
.collect();
let module_names: HashSet<String> = graph
.modules()
.iter()
.map(|m| m.as_str().to_lowercase())
.collect();
let all_known: HashSet<&str> = known_dirs
.iter()
.map(|s| s.as_str())
.chain(module_names.iter().map(|s| s.as_str()))
.collect();
let mut filtered = CodeGraph::new();
for element in graph.elements() {
filtered.add_element(element.clone());
}
for rel in graph.relationships() {
if rel.kind() == RelationshipKind::Import {
let target_top = rel.target().split('.').next().unwrap_or("").to_lowercase();
if !all_known.contains(target_top.as_str()) {
continue;
}
}
filtered.add_relationship(rel.clone());
}
filtered
}
fn infer_module(file_path: &str, root: &Path, config: &AnalysisConfig) -> Option<ModuleName> {
let relative = if let Some(stripped) = file_path.strip_prefix(root.to_str().unwrap_or("")) {
stripped.trim_start_matches('/')
} else {
file_path
};
for (pattern, module_name) in config.module_mappings() {
if relative.starts_with(pattern) {
return ModuleName::new(module_name).ok();
}
}
let parts: Vec<&str> = relative.split('/').collect();
if parts.len() <= 1 {
return None;
}
let module_dir = if parts[0] == "crates" && parts.len() > 2 {
// workspace: crates/<crate-name>/src/...
parts[1]
} else if parts[0] == "src" && parts.len() > 2 {
// single project: src/<module>/...
parts[1]
} else if parts[0] != "src" && parts.len() > 1 {
parts[0]
} else {
return None;
};
let capitalized = module_dir
.split('-')
.map(|seg| {
if seg.is_empty() {
String::new()
} else {
format!("{}{}", seg[..1].to_uppercase(), &seg[1..])
}
})
.collect::<Vec<_>>()
.join("-");
ModuleName::new(&capitalized).ok()
}
pub struct AnalyzeCodebaseResult {
graph: CodeGraph,
warnings: Vec<AnalysisWarning>,
}
impl AnalyzeCodebaseResult {
pub fn graph(&self) -> &CodeGraph {
&self.graph
}
pub fn warnings(&self) -> &[AnalysisWarning] {
&self.warnings
}
}

View File

@@ -0,0 +1,5 @@
mod analyze_codebase;
mod render_diagrams;
pub use analyze_codebase::{AnalyzeCodebase, AnalyzeCodebaseResult};
pub use render_diagrams::RenderDiagrams;

View File

@@ -0,0 +1,45 @@
use archlens_domain::{
CodeGraph, DomainError, OutputConfig,
ports::{DiagramRenderer, OutputWriter},
};
pub struct RenderDiagrams<R, W>
where
R: DiagramRenderer,
W: OutputWriter,
{
renderer: R,
writer: W,
}
impl<R, W> RenderDiagrams<R, W>
where
R: DiagramRenderer,
W: OutputWriter,
{
pub fn new(renderer: R, writer: W) -> Self {
Self { renderer, writer }
}
pub fn execute(&self, graph: &CodeGraph, config: &OutputConfig) -> Result<(), DomainError> {
if config.split_by_module() {
let overview = self.renderer.render(graph)?;
self.writer.write(&overview)?;
for module in graph.modules() {
let subgraph = graph.subgraph_by_module(&module);
let output = self.renderer.render(&subgraph)?;
self.writer.write(&output)?;
}
} else {
let output = self.renderer.render(graph)?;
self.writer.write(&output)?;
}
Ok(())
}
pub fn writer(&self) -> &W {
&self.writer
}
}

View File

@@ -0,0 +1,344 @@
mod fakes;
use std::path::Path;
use archlens_application::queries::AnalyzeCodebase;
use archlens_domain::{
AnalysisConfig, AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError,
FilePath, Language, Relationship, RelationshipKind, SourceFile,
};
use fakes::{FakeFileDiscovery, FakeSourceAnalyzer};
#[test]
fn analyzes_discovered_files_and_builds_code_graph() {
let files = vec![
SourceFile::new(FilePath::new("src/order.rs").unwrap(), Language::Rust),
SourceFile::new(FilePath::new("src/service.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![],
),
)
.with_result(
"src/service.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/service.rs").unwrap(),
1,
)
.unwrap(),
],
vec![
Relationship::new("OrderService", "Order", RelationshipKind::Composition)
.unwrap(),
],
vec![],
),
);
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case
.execute(Path::new("."), &AnalysisConfig::default())
.unwrap();
assert_eq!(result.graph().elements().len(), 2);
assert_eq!(result.graph().relationships().len(), 1);
assert!(result.warnings().is_empty());
}
#[test]
fn empty_file_list_returns_empty_graph() {
let discovery = FakeFileDiscovery::empty();
let analyzer = FakeSourceAnalyzer::new();
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case
.execute(Path::new("."), &AnalysisConfig::default())
.unwrap();
assert!(result.graph().elements().is_empty());
assert!(result.graph().relationships().is_empty());
assert!(result.warnings().is_empty());
}
#[test]
fn aggregates_warnings_from_multiple_files() {
let files = vec![
SourceFile::new(FilePath::new("src/a.rs").unwrap(), Language::Rust),
SourceFile::new(FilePath::new("src/b.rs").unwrap(), Language::Rust),
];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new()
.with_result(
"src/a.rs",
AnalysisResult::new(
vec![],
vec![],
vec![
AnalysisWarning::new(FilePath::new("src/a.rs").unwrap(), 10, "unknown macro")
.unwrap(),
],
),
)
.with_result(
"src/b.rs",
AnalysisResult::new(
vec![],
vec![],
vec![
AnalysisWarning::new(
FilePath::new("src/b.rs").unwrap(),
5,
"unparseable block",
)
.unwrap(),
],
),
);
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case
.execute(Path::new("."), &AnalysisConfig::default())
.unwrap();
assert_eq!(result.warnings().len(), 2);
}
#[test]
fn analysis_error_on_file_collects_warning_and_continues() {
let files = vec![
SourceFile::new(FilePath::new("src/good.rs").unwrap(), Language::Rust),
SourceFile::new(FilePath::new("src/broken.rs").unwrap(), Language::Rust),
];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new()
.with_result(
"src/good.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"Good",
CodeElementKind::Struct,
FilePath::new("src/good.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
)
.with_error(
"src/broken.rs",
DomainError::AnalysisError("parse failed".to_string()),
);
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case
.execute(Path::new("."), &AnalysisConfig::default())
.unwrap();
assert_eq!(result.graph().elements().len(), 1);
assert_eq!(result.graph().elements()[0].name(), "Good");
assert_eq!(result.warnings().len(), 1);
}
#[test]
fn infers_module_from_directory_structure() {
let files = vec![
SourceFile::new(
FilePath::new("/project/src/orders/service.rs").unwrap(),
Language::Rust,
),
SourceFile::new(
FilePath::new("/project/src/billing/invoice.rs").unwrap(),
Language::Rust,
),
SourceFile::new(
FilePath::new("/project/src/lib.rs").unwrap(),
Language::Rust,
),
];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new()
.with_result(
"/project/src/orders/service.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("/project/src/orders/service.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
)
.with_result(
"/project/src/billing/invoice.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"Invoice",
CodeElementKind::Struct,
FilePath::new("/project/src/billing/invoice.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
)
.with_result(
"/project/src/lib.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"App",
CodeElementKind::Struct,
FilePath::new("/project/src/lib.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
);
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case
.execute(Path::new("/project"), &AnalysisConfig::default())
.unwrap();
let order_svc = result
.graph()
.elements()
.iter()
.find(|e| e.name() == "OrderService")
.unwrap();
assert_eq!(order_svc.module().unwrap().as_str(), "Orders");
let invoice = result
.graph()
.elements()
.iter()
.find(|e| e.name() == "Invoice")
.unwrap();
assert_eq!(invoice.module().unwrap().as_str(), "Billing");
let app = result
.graph()
.elements()
.iter()
.find(|e| e.name() == "App")
.unwrap();
assert!(app.module().is_none());
}
#[test]
fn infers_nested_module_from_deep_directories() {
let files = vec![SourceFile::new(
FilePath::new("/project/src/orders/models/order.rs").unwrap(),
Language::Rust,
)];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new().with_result(
"/project/src/orders/models/order.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("/project/src/orders/models/order.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
);
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case
.execute(Path::new("/project"), &AnalysisConfig::default())
.unwrap();
let order = result
.graph()
.elements()
.iter()
.find(|e| e.name() == "Order")
.unwrap();
assert_eq!(order.module().unwrap().as_str(), "Orders");
}
#[test]
fn respects_config_module_mappings() {
let files = vec![SourceFile::new(
FilePath::new("/project/src/infra/db.rs").unwrap(),
Language::Rust,
)];
let discovery = FakeFileDiscovery::new(files);
let analyzer = FakeSourceAnalyzer::new().with_result(
"/project/src/infra/db.rs",
AnalysisResult::new(
vec![
CodeElement::new(
"DbPool",
CodeElementKind::Struct,
FilePath::new("/project/src/infra/db.rs").unwrap(),
1,
)
.unwrap(),
],
vec![],
vec![],
),
);
let mut mappings = std::collections::HashMap::new();
mappings.insert("src/infra".to_string(), "Infrastructure".to_string());
let config = AnalysisConfig::default().with_module_mappings(mappings);
let use_case = AnalyzeCodebase::new(discovery, analyzer);
let result = use_case.execute(Path::new("/project"), &config).unwrap();
let db = result
.graph()
.elements()
.iter()
.find(|e| e.name() == "DbPool")
.unwrap();
assert_eq!(db.module().unwrap().as_str(), "Infrastructure");
}

View File

@@ -0,0 +1,17 @@
use archlens_domain::{CodeGraph, DomainError, RenderOutput, RenderedFile, ports::DiagramRenderer};
pub struct FakeDiagramRenderer;
impl FakeDiagramRenderer {
pub fn new() -> Self {
Self
}
}
impl DiagramRenderer for FakeDiagramRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError> {
let content = format!("graph with {} elements", graph.elements().len());
let file = RenderedFile::new("output.mmd", &content)?;
Ok(RenderOutput::single(file))
}
}

View File

@@ -0,0 +1,27 @@
use std::path::Path;
use archlens_domain::{AnalysisConfig, DomainError, SourceFile, ports::FileDiscovery};
pub struct FakeFileDiscovery {
files: Vec<SourceFile>,
}
impl FakeFileDiscovery {
pub fn new(files: Vec<SourceFile>) -> Self {
Self { files }
}
pub fn empty() -> Self {
Self { files: Vec::new() }
}
}
impl FileDiscovery for FakeFileDiscovery {
fn discover(
&self,
_root: &Path,
_config: &AnalysisConfig,
) -> Result<Vec<SourceFile>, DomainError> {
Ok(self.files.clone())
}
}

View File

@@ -0,0 +1,9 @@
mod diagram_renderer;
mod file_discovery;
mod output_writer;
mod source_analyzer;
pub use diagram_renderer::FakeDiagramRenderer;
pub use file_discovery::FakeFileDiscovery;
pub use output_writer::FakeOutputWriter;
pub use source_analyzer::FakeSourceAnalyzer;

View File

@@ -0,0 +1,26 @@
use std::cell::RefCell;
use archlens_domain::{DomainError, RenderOutput, ports::OutputWriter};
pub struct FakeOutputWriter {
written: RefCell<Vec<RenderOutput>>,
}
impl FakeOutputWriter {
pub fn new() -> Self {
Self {
written: RefCell::new(Vec::new()),
}
}
pub fn written_outputs(&self) -> Vec<RenderOutput> {
self.written.borrow().clone()
}
}
impl OutputWriter for FakeOutputWriter {
fn write(&self, output: &RenderOutput) -> Result<(), DomainError> {
self.written.borrow_mut().push(output.clone());
Ok(())
}
}

View File

@@ -0,0 +1,43 @@
use std::collections::HashMap;
use archlens_domain::{AnalysisResult, DomainError, SourceFile, ports::SourceAnalyzer};
enum FakeResponse {
Success(AnalysisResult),
Failure(DomainError),
}
pub struct FakeSourceAnalyzer {
results: HashMap<String, FakeResponse>,
}
impl FakeSourceAnalyzer {
pub fn new() -> Self {
Self {
results: HashMap::new(),
}
}
pub fn with_result(mut self, file_path: &str, result: AnalysisResult) -> Self {
self.results
.insert(file_path.to_string(), FakeResponse::Success(result));
self
}
pub fn with_error(mut self, file_path: &str, error: DomainError) -> Self {
self.results
.insert(file_path.to_string(), FakeResponse::Failure(error));
self
}
}
impl SourceAnalyzer for FakeSourceAnalyzer {
fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError> {
let key = file.path().as_str().to_string();
match self.results.get(&key) {
Some(FakeResponse::Success(result)) => Ok(result.clone()),
Some(FakeResponse::Failure(_)) => Err(DomainError::AnalysisError(key)),
None => Ok(AnalysisResult::empty()),
}
}
}

View File

@@ -0,0 +1,78 @@
mod fakes;
use archlens_application::queries::RenderDiagrams;
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, OutputConfig,
};
use fakes::{FakeDiagramRenderer, FakeOutputWriter};
fn build_graph() -> CodeGraph {
let mut graph = CodeGraph::new();
graph.add_element(
CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/service.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Orders").unwrap()),
);
graph.add_element(
CodeElement::new(
"BillingService",
CodeElementKind::Class,
FilePath::new("src/billing.rs").unwrap(),
1,
)
.unwrap()
.with_module(ModuleName::new("Billing").unwrap()),
);
graph
}
#[test]
fn renders_single_diagram_and_writes_output() {
let renderer = FakeDiagramRenderer::new();
let writer = FakeOutputWriter::new();
let config = OutputConfig::default();
let use_case = RenderDiagrams::new(renderer, writer);
use_case.execute(&build_graph(), &config).unwrap();
let outputs = use_case.writer().written_outputs();
assert_eq!(outputs.len(), 1);
assert_eq!(outputs[0].files().len(), 1);
}
#[test]
fn split_mode_renders_overview_plus_per_module_diagrams() {
let renderer = FakeDiagramRenderer::new();
let writer = FakeOutputWriter::new();
let config = OutputConfig::default().with_split_by_module(true);
let graph = build_graph(); // has 2 modules: Orders, Billing
let use_case = RenderDiagrams::new(renderer, writer);
use_case.execute(&graph, &config).unwrap();
let outputs = use_case.writer().written_outputs();
// 1 overview + 2 module diagrams = 3 writes
assert_eq!(outputs.len(), 3);
}
#[test]
fn empty_graph_still_produces_output() {
let renderer = FakeDiagramRenderer::new();
let writer = FakeOutputWriter::new();
let config = OutputConfig::default();
let graph = CodeGraph::new();
let use_case = RenderDiagrams::new(renderer, writer);
use_case.execute(&graph, &config).unwrap();
let outputs = use_case.writer().written_outputs();
assert_eq!(outputs.len(), 1);
}

8
crates/domain/Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[package]
name = "archlens-domain"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
thiserror.workspace = true

View File

@@ -0,0 +1,78 @@
use std::collections::HashSet;
use crate::{CodeElement, ModuleName, Relationship};
#[derive(Debug, Clone)]
pub struct CodeGraph {
elements: Vec<CodeElement>,
relationships: Vec<Relationship>,
}
impl Default for CodeGraph {
fn default() -> Self {
Self::new()
}
}
impl CodeGraph {
pub fn new() -> Self {
Self {
elements: Vec::new(),
relationships: Vec::new(),
}
}
pub fn add_element(&mut self, element: CodeElement) {
self.elements.push(element);
}
pub fn add_relationship(&mut self, relationship: Relationship) {
self.relationships.push(relationship);
}
pub fn elements(&self) -> &[CodeElement] {
&self.elements
}
pub fn relationships(&self) -> &[Relationship] {
&self.relationships
}
pub fn modules(&self) -> Vec<ModuleName> {
let mut seen = HashSet::new();
let mut modules = Vec::new();
for element in &self.elements {
if let Some(module) = element.module()
&& seen.insert(module.as_str().to_string())
{
modules.push(module.clone());
}
}
modules
}
pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph {
let filtered_elements: Vec<CodeElement> = self
.elements
.iter()
.filter(|e| e.module().is_some_and(|m| m == module))
.cloned()
.collect();
let element_names: HashSet<&str> = filtered_elements.iter().map(|e| e.name()).collect();
let filtered_relationships: Vec<Relationship> = self
.relationships
.iter()
.filter(|r| element_names.contains(r.source()) && element_names.contains(r.target()))
.cloned()
.collect();
CodeGraph {
elements: filtered_elements,
relationships: filtered_relationships,
}
}
}

View File

@@ -0,0 +1,3 @@
mod code_graph;
pub use code_graph::CodeGraph;

View File

@@ -0,0 +1,111 @@
use crate::{CodeElementKind, DomainError, FilePath, ModuleName, Visibility};
#[derive(Debug, Clone)]
pub struct CodeElement {
name: String,
kind: CodeElementKind,
file_path: FilePath,
line: usize,
visibility: Visibility,
module: Option<ModuleName>,
generics: Vec<String>,
attributes: Vec<String>,
fields: Vec<String>,
methods: Vec<String>,
}
impl CodeElement {
pub fn new(
name: &str,
kind: CodeElementKind,
file_path: FilePath,
line: usize,
) -> Result<Self, DomainError> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(DomainError::EmptyValue("CodeElement name"));
}
Ok(Self {
name: trimmed.to_string(),
kind,
file_path,
line,
visibility: Visibility::Public,
module: None,
generics: Vec::new(),
attributes: Vec::new(),
fields: Vec::new(),
methods: Vec::new(),
})
}
pub fn with_visibility(mut self, visibility: Visibility) -> Self {
self.visibility = visibility;
self
}
pub fn with_module(mut self, module: ModuleName) -> Self {
self.module = Some(module);
self
}
pub fn with_generics(mut self, generics: Vec<String>) -> Self {
self.generics = generics;
self
}
pub fn with_attributes(mut self, attributes: Vec<String>) -> Self {
self.attributes = attributes;
self
}
pub fn name(&self) -> &str {
&self.name
}
pub fn kind(&self) -> CodeElementKind {
self.kind
}
pub fn file_path(&self) -> &FilePath {
&self.file_path
}
pub fn line(&self) -> usize {
self.line
}
pub fn visibility(&self) -> Visibility {
self.visibility
}
pub fn module(&self) -> Option<&ModuleName> {
self.module.as_ref()
}
pub fn generics(&self) -> &[String] {
&self.generics
}
pub fn attributes(&self) -> &[String] {
&self.attributes
}
pub fn with_fields(mut self, fields: Vec<String>) -> Self {
self.fields = fields;
self
}
pub fn with_methods(mut self, methods: Vec<String>) -> Self {
self.methods = methods;
self
}
pub fn fields(&self) -> &[String] {
&self.fields
}
pub fn methods(&self) -> &[String] {
&self.methods
}
}

View File

@@ -0,0 +1,5 @@
mod code_element;
mod relationship;
pub use code_element::CodeElement;
pub use relationship::Relationship;

View File

@@ -0,0 +1,49 @@
use crate::{DomainError, FilePath, RelationshipKind};
#[derive(Debug, Clone)]
pub struct Relationship {
source: String,
target: String,
kind: RelationshipKind,
source_file: Option<FilePath>,
}
impl Relationship {
pub fn new(source: &str, target: &str, kind: RelationshipKind) -> Result<Self, DomainError> {
let source = source.trim();
let target = target.trim();
if source.is_empty() {
return Err(DomainError::EmptyValue("Relationship source"));
}
if target.is_empty() {
return Err(DomainError::EmptyValue("Relationship target"));
}
Ok(Self {
source: source.to_string(),
target: target.to_string(),
kind,
source_file: None,
})
}
pub fn with_source_file(mut self, file: FilePath) -> Self {
self.source_file = Some(file);
self
}
pub fn source(&self) -> &str {
&self.source
}
pub fn target(&self) -> &str {
&self.target
}
pub fn kind(&self) -> RelationshipKind {
self.kind
}
pub fn source_file(&self) -> Option<&FilePath> {
self.source_file.as_ref()
}
}

View File

@@ -0,0 +1,14 @@
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("{0} cannot be empty")]
EmptyValue(&'static str),
#[error("failed to analyze: {0}")]
AnalysisError(String),
#[error("IO error: {0}")]
IoError(String),
#[error("config error: {0}")]
ConfigError(String),
}

14
crates/domain/src/lib.rs Normal file
View File

@@ -0,0 +1,14 @@
mod error;
pub mod aggregates;
pub mod entities;
pub mod ports;
pub mod value_objects;
pub use aggregates::CodeGraph;
pub use entities::{CodeElement, Relationship};
pub use error::DomainError;
pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning};
pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility};
pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile};
pub use value_objects::source::{FilePath, Language, ModuleName, SourceFile};

View File

@@ -0,0 +1,6 @@
use crate::{AnalysisConfig, DomainError, OutputConfig};
pub trait ConfigLoader {
fn load_analysis_config(&self) -> Result<AnalysisConfig, DomainError>;
fn load_output_config(&self) -> Result<OutputConfig, DomainError>;
}

View File

@@ -0,0 +1,5 @@
use crate::{CodeGraph, DomainError, RenderOutput};
pub trait DiagramRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError>;
}

View File

@@ -0,0 +1,9 @@
use crate::{AnalysisConfig, DomainError, SourceFile};
pub trait FileDiscovery {
fn discover(
&self,
root: &std::path::Path,
config: &AnalysisConfig,
) -> Result<Vec<SourceFile>, DomainError>;
}

View File

@@ -0,0 +1,13 @@
mod config_loader;
mod diagram_renderer;
mod file_discovery;
mod output_writer;
mod project_analyzer;
mod source_analyzer;
pub use config_loader::ConfigLoader;
pub use diagram_renderer::DiagramRenderer;
pub use file_discovery::FileDiscovery;
pub use output_writer::OutputWriter;
pub use project_analyzer::ProjectAnalyzer;
pub use source_analyzer::SourceAnalyzer;

View File

@@ -0,0 +1,5 @@
use crate::{DomainError, RenderOutput};
pub trait OutputWriter {
fn write(&self, output: &RenderOutput) -> Result<(), DomainError>;
}

View File

@@ -0,0 +1,7 @@
use std::path::Path;
use crate::{CodeGraph, DomainError};
pub trait ProjectAnalyzer {
fn analyze(&self, root: &Path) -> Result<CodeGraph, DomainError>;
}

View File

@@ -0,0 +1,5 @@
use crate::{AnalysisResult, DomainError, SourceFile};
pub trait SourceAnalyzer: Send + Sync {
fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError>;
}

View File

@@ -0,0 +1,60 @@
use std::collections::HashMap;
use crate::DiagramLevel;
#[derive(Debug, Clone)]
pub struct AnalysisConfig {
excludes: Vec<String>,
level: DiagramLevel,
module_mappings: HashMap<String, String>,
scope: Option<String>,
}
impl AnalysisConfig {
pub fn with_excludes(mut self, excludes: Vec<String>) -> Self {
self.excludes = excludes;
self
}
pub fn with_level(mut self, level: DiagramLevel) -> Self {
self.level = level;
self
}
pub fn with_module_mappings(mut self, mappings: HashMap<String, String>) -> Self {
self.module_mappings = mappings;
self
}
pub fn excludes(&self) -> &[String] {
&self.excludes
}
pub fn level(&self) -> DiagramLevel {
self.level
}
pub fn with_scope(mut self, scope: String) -> Self {
self.scope = Some(scope);
self
}
pub fn module_mappings(&self) -> &HashMap<String, String> {
&self.module_mappings
}
pub fn scope(&self) -> Option<&str> {
self.scope.as_deref()
}
}
impl Default for AnalysisConfig {
fn default() -> Self {
Self {
excludes: Vec::new(),
level: DiagramLevel::Module,
module_mappings: HashMap::new(),
scope: None,
}
}
}

View File

@@ -0,0 +1,42 @@
use crate::{AnalysisWarning, CodeElement, Relationship};
#[derive(Debug, Clone)]
pub struct AnalysisResult {
elements: Vec<CodeElement>,
relationships: Vec<Relationship>,
warnings: Vec<AnalysisWarning>,
}
impl AnalysisResult {
pub fn new(
elements: Vec<CodeElement>,
relationships: Vec<Relationship>,
warnings: Vec<AnalysisWarning>,
) -> Self {
Self {
elements,
relationships,
warnings,
}
}
pub fn empty() -> Self {
Self {
elements: Vec::new(),
relationships: Vec::new(),
warnings: Vec::new(),
}
}
pub fn elements(&self) -> &[CodeElement] {
&self.elements
}
pub fn relationships(&self) -> &[Relationship] {
&self.relationships
}
pub fn warnings(&self) -> &[AnalysisWarning] {
&self.warnings
}
}

View File

@@ -0,0 +1,34 @@
use crate::{DomainError, FilePath};
#[derive(Debug, Clone)]
pub struct AnalysisWarning {
file_path: FilePath,
line: usize,
message: String,
}
impl AnalysisWarning {
pub fn new(file_path: FilePath, line: usize, message: &str) -> Result<Self, DomainError> {
let message = message.trim();
if message.is_empty() {
return Err(DomainError::EmptyValue("AnalysisWarning message"));
}
Ok(Self {
file_path,
line,
message: message.to_string(),
})
}
pub fn file_path(&self) -> &FilePath {
&self.file_path
}
pub fn line(&self) -> usize {
self.line
}
pub fn message(&self) -> &str {
&self.message
}
}

View File

@@ -0,0 +1,7 @@
mod analysis_config;
mod analysis_result;
mod analysis_warning;
pub use analysis_config::AnalysisConfig;
pub use analysis_result::AnalysisResult;
pub use analysis_warning::AnalysisWarning;

View File

@@ -0,0 +1,9 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CodeElementKind {
Class,
Struct,
Trait,
Interface,
Enum,
Project,
}

View File

@@ -0,0 +1,7 @@
mod code_element_kind;
mod relationship_kind;
mod visibility;
pub use code_element_kind::CodeElementKind;
pub use relationship_kind::RelationshipKind;
pub use visibility::Visibility;

View File

@@ -0,0 +1,6 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RelationshipKind {
Inheritance,
Composition,
Import,
}

View File

@@ -0,0 +1,6 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Visibility {
Public,
Private,
Internal,
}

View File

@@ -0,0 +1,4 @@
pub mod analysis;
pub mod graph;
pub mod output;
pub mod source;

View File

@@ -0,0 +1,6 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DiagramLevel {
Project,
Module,
Type,
}

View File

@@ -0,0 +1,9 @@
mod diagram_level;
mod output_config;
mod render_output;
mod rendered_file;
pub use diagram_level::DiagramLevel;
pub use output_config::OutputConfig;
pub use render_output::RenderOutput;
pub use rendered_file::RenderedFile;

View File

@@ -0,0 +1,25 @@
#[derive(Debug, Clone, Default)]
pub struct OutputConfig {
split_by_module: bool,
output_path: Option<String>,
}
impl OutputConfig {
pub fn with_split_by_module(mut self, split: bool) -> Self {
self.split_by_module = split;
self
}
pub fn with_output_path(mut self, path: String) -> Self {
self.output_path = Some(path);
self
}
pub fn split_by_module(&self) -> bool {
self.split_by_module
}
pub fn output_path(&self) -> Option<&str> {
self.output_path.as_deref()
}
}

View File

@@ -0,0 +1,20 @@
use crate::RenderedFile;
#[derive(Debug, Clone)]
pub struct RenderOutput {
files: Vec<RenderedFile>,
}
impl RenderOutput {
pub fn new(files: Vec<RenderedFile>) -> Self {
Self { files }
}
pub fn single(file: RenderedFile) -> Self {
Self { files: vec![file] }
}
pub fn files(&self) -> &[RenderedFile] {
&self.files
}
}

View File

@@ -0,0 +1,32 @@
use crate::DomainError;
#[derive(Debug, Clone)]
pub struct RenderedFile {
name: String,
content: String,
}
impl RenderedFile {
pub fn new(name: &str, content: &str) -> Result<Self, DomainError> {
let name = name.trim();
let content = content.trim();
if name.is_empty() {
return Err(DomainError::EmptyValue("RenderedFile name"));
}
if content.is_empty() {
return Err(DomainError::EmptyValue("RenderedFile content"));
}
Ok(Self {
name: name.to_string(),
content: content.to_string(),
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn content(&self) -> &str {
&self.content
}
}

View File

@@ -0,0 +1,18 @@
use crate::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FilePath(String);
impl FilePath {
pub fn new(value: &str) -> Result<Self, DomainError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(DomainError::EmptyValue("FilePath"));
}
Ok(Self(trimmed.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}

View File

@@ -0,0 +1,16 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Language {
Rust,
CSharp,
Python,
}
impl Language {
pub fn name(&self) -> &'static str {
match self {
Self::Rust => "Rust",
Self::CSharp => "CSharp",
Self::Python => "Python",
}
}
}

View File

@@ -0,0 +1,9 @@
mod file_path;
mod language;
mod module_name;
mod source_file;
pub use file_path::FilePath;
pub use language::Language;
pub use module_name::ModuleName;
pub use source_file::SourceFile;

View File

@@ -0,0 +1,18 @@
use crate::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ModuleName(String);
impl ModuleName {
pub fn new(value: &str) -> Result<Self, DomainError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(DomainError::EmptyValue("ModuleName"));
}
Ok(Self(trimmed.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}

View File

@@ -0,0 +1,21 @@
use crate::{FilePath, Language};
#[derive(Debug, Clone)]
pub struct SourceFile {
path: FilePath,
language: Language,
}
impl SourceFile {
pub fn new(path: FilePath, language: Language) -> Self {
Self { path, language }
}
pub fn path(&self) -> &FilePath {
&self.path
}
pub fn language(&self) -> Language {
self.language
}
}

View File

@@ -0,0 +1,39 @@
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, FilePath, Relationship,
RelationshipKind,
};
#[test]
fn analysis_result_collects_elements_relationships_and_warnings() {
let element = CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap();
let relationship =
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap();
let warning = AnalysisWarning::new(
FilePath::new("src/broken.rs").unwrap(),
10,
"unparseable macro",
)
.unwrap();
let result = AnalysisResult::new(vec![element], vec![relationship], vec![warning]);
assert_eq!(result.elements().len(), 1);
assert_eq!(result.relationships().len(), 1);
assert_eq!(result.warnings().len(), 1);
}
#[test]
fn empty_analysis_result() {
let result = AnalysisResult::empty();
assert!(result.elements().is_empty());
assert!(result.relationships().is_empty());
assert!(result.warnings().is_empty());
}

View File

@@ -0,0 +1,21 @@
use archlens_domain::{AnalysisWarning, FilePath};
#[test]
fn warning_carries_location_and_message() {
let warning = AnalysisWarning::new(
FilePath::new("src/broken.rs").unwrap(),
42,
"could not parse struct definition",
)
.unwrap();
assert_eq!(warning.file_path().as_str(), "src/broken.rs");
assert_eq!(warning.line(), 42);
assert_eq!(warning.message(), "could not parse struct definition");
}
#[test]
fn warning_rejects_empty_message() {
let result = AnalysisWarning::new(FilePath::new("src/broken.rs").unwrap(), 1, "");
assert!(result.is_err());
}

View File

@@ -0,0 +1,107 @@
use archlens_domain::{CodeElement, CodeElementKind, FilePath, ModuleName, Visibility};
#[test]
fn code_element_is_created_with_required_fields() {
let element = CodeElement::new(
"OrderService",
CodeElementKind::Class,
FilePath::new("src/orders/service.rs").unwrap(),
42,
)
.unwrap();
assert_eq!(element.name(), "OrderService");
assert_eq!(element.kind(), CodeElementKind::Class);
assert_eq!(element.file_path().as_str(), "src/orders/service.rs");
assert_eq!(element.line(), 42);
}
#[test]
fn code_element_with_empty_name_is_rejected() {
let result = CodeElement::new(
"",
CodeElementKind::Class,
FilePath::new("src/main.rs").unwrap(),
1,
);
assert!(result.is_err());
}
#[test]
fn code_element_defaults_to_public_visibility() {
let element = CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap();
assert_eq!(element.visibility(), Visibility::Public);
}
#[test]
fn code_element_with_visibility() {
let element = CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/order.rs").unwrap(),
1,
)
.unwrap()
.with_visibility(Visibility::Private);
assert_eq!(element.visibility(), Visibility::Private);
}
#[test]
fn code_element_with_module_path() {
let module = ModuleName::new("Orders").unwrap();
let element = CodeElement::new(
"Order",
CodeElementKind::Struct,
FilePath::new("src/orders/order.rs").unwrap(),
1,
)
.unwrap()
.with_module(module.clone());
assert_eq!(element.module(), Some(&module));
}
#[test]
fn code_element_with_generics() {
let element = CodeElement::new(
"Repository",
CodeElementKind::Trait,
FilePath::new("src/repo.rs").unwrap(),
1,
)
.unwrap()
.with_generics(vec!["T".to_string()]);
assert_eq!(element.generics(), &["T"]);
}
#[test]
fn code_element_with_attributes() {
let element = CodeElement::new(
"OrderController",
CodeElementKind::Class,
FilePath::new("src/controller.cs").unwrap(),
1,
)
.unwrap()
.with_attributes(vec!["ApiController".to_string()]);
assert_eq!(element.attributes(), &["ApiController"]);
}
#[test]
fn all_element_kinds_exist() {
let _class = CodeElementKind::Class;
let _struct = CodeElementKind::Struct;
let _trait = CodeElementKind::Trait;
let _interface = CodeElementKind::Interface;
let _enum = CodeElementKind::Enum;
}

View File

@@ -0,0 +1,130 @@
use archlens_domain::{
CodeElement, CodeElementKind, CodeGraph, FilePath, ModuleName, Relationship, RelationshipKind,
};
fn make_element(name: &str, module: Option<&str>) -> CodeElement {
let mut element = CodeElement::new(
name,
CodeElementKind::Class,
FilePath::new(&format!("src/{name}.rs")).unwrap(),
1,
)
.unwrap();
if let Some(m) = module {
element = element.with_module(ModuleName::new(m).unwrap());
}
element
}
#[test]
fn empty_graph_has_no_elements() {
let graph = CodeGraph::new();
assert!(graph.elements().is_empty());
assert!(graph.relationships().is_empty());
}
#[test]
fn graph_stores_added_elements() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", None));
graph.add_element(make_element("Order", None));
assert_eq!(graph.elements().len(), 2);
}
#[test]
fn graph_stores_relationships() {
let mut graph = CodeGraph::new();
let service = make_element("OrderService", None);
let repo = make_element("OrderRepository", None);
graph.add_element(service);
graph.add_element(repo);
graph.add_relationship(
Relationship::new(
"OrderService",
"OrderRepository",
RelationshipKind::Composition,
)
.unwrap(),
);
assert_eq!(graph.relationships().len(), 1);
let rel = &graph.relationships()[0];
assert_eq!(rel.source(), "OrderService");
assert_eq!(rel.target(), "OrderRepository");
assert_eq!(rel.kind(), RelationshipKind::Composition);
}
#[test]
fn subgraph_by_module_filters_elements() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", Some("Orders")));
graph.add_element(make_element("Order", Some("Orders")));
graph.add_element(make_element("BillingService", Some("Billing")));
let module = ModuleName::new("Orders").unwrap();
let subgraph = graph.subgraph_by_module(&module);
assert_eq!(subgraph.elements().len(), 2);
assert!(
subgraph
.elements()
.iter()
.all(|e| e.module().unwrap().as_str() == "Orders")
);
}
#[test]
fn subgraph_includes_relationships_within_module() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", Some("Orders")));
graph.add_element(make_element("Order", Some("Orders")));
graph.add_element(make_element("BillingService", Some("Billing")));
graph.add_relationship(
Relationship::new("OrderService", "Order", RelationshipKind::Composition).unwrap(),
);
graph.add_relationship(
Relationship::new(
"OrderService",
"BillingService",
RelationshipKind::Composition,
)
.unwrap(),
);
let module = ModuleName::new("Orders").unwrap();
let subgraph = graph.subgraph_by_module(&module);
assert_eq!(subgraph.relationships().len(), 1);
assert_eq!(subgraph.relationships()[0].target(), "Order");
}
#[test]
fn subgraph_of_nonexistent_module_is_empty() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", Some("Orders")));
let module = ModuleName::new("Unknown").unwrap();
let subgraph = graph.subgraph_by_module(&module);
assert!(subgraph.elements().is_empty());
assert!(subgraph.relationships().is_empty());
}
#[test]
fn graph_lists_unique_modules() {
let mut graph = CodeGraph::new();
graph.add_element(make_element("OrderService", Some("Orders")));
graph.add_element(make_element("Order", Some("Orders")));
graph.add_element(make_element("BillingService", Some("Billing")));
graph.add_element(make_element("Orphan", None));
let modules = graph.modules();
assert_eq!(modules.len(), 2);
assert!(modules.iter().any(|m| m.as_str() == "Orders"));
assert!(modules.iter().any(|m| m.as_str() == "Billing"));
}

View File

@@ -0,0 +1,37 @@
use archlens_domain::{AnalysisConfig, DiagramLevel, OutputConfig};
#[test]
fn analysis_config_has_sensible_defaults() {
let config = AnalysisConfig::default();
assert!(config.excludes().is_empty());
assert_eq!(config.level(), DiagramLevel::Module);
assert!(config.module_mappings().is_empty());
}
#[test]
fn analysis_config_with_excludes() {
let config =
AnalysisConfig::default().with_excludes(vec!["tests/".to_string(), "vendor/".to_string()]);
assert_eq!(config.excludes().len(), 2);
}
#[test]
fn output_config_has_sensible_defaults() {
let config = OutputConfig::default();
assert!(!config.split_by_module());
assert!(config.output_path().is_none());
}
#[test]
fn output_config_with_split() {
let config = OutputConfig::default().with_split_by_module(true);
assert!(config.split_by_module());
}
#[test]
fn all_diagram_levels_exist() {
let _project = DiagramLevel::Project;
let _module = DiagramLevel::Module;
let _type_level = DiagramLevel::Type;
}

View File

@@ -0,0 +1,29 @@
use archlens_domain::FilePath;
#[test]
fn valid_file_path_is_created() {
let path = FilePath::new("src/main.rs").unwrap();
assert_eq!(path.as_str(), "src/main.rs");
}
#[test]
fn empty_file_path_is_rejected() {
let result = FilePath::new("");
assert!(result.is_err());
}
#[test]
fn whitespace_only_file_path_is_rejected() {
let result = FilePath::new(" ");
assert!(result.is_err());
}
#[test]
fn file_paths_are_comparable() {
let a = FilePath::new("src/main.rs").unwrap();
let b = FilePath::new("src/main.rs").unwrap();
let c = FilePath::new("src/lib.rs").unwrap();
assert_eq!(a, b);
assert_ne!(a, c);
}

View File

@@ -0,0 +1,18 @@
use archlens_domain::Language;
#[test]
fn known_languages_are_available() {
let rust = Language::Rust;
let csharp = Language::CSharp;
let python = Language::Python;
assert_eq!(rust.name(), "Rust");
assert_eq!(csharp.name(), "CSharp");
assert_eq!(python.name(), "Python");
}
#[test]
fn languages_are_comparable() {
assert_eq!(Language::Rust, Language::Rust);
assert_ne!(Language::Rust, Language::Python);
}

View File

@@ -0,0 +1,23 @@
use archlens_domain::ModuleName;
#[test]
fn valid_module_name_is_created() {
let name = ModuleName::new("Orders").unwrap();
assert_eq!(name.as_str(), "Orders");
}
#[test]
fn empty_module_name_is_rejected() {
let result = ModuleName::new("");
assert!(result.is_err());
}
#[test]
fn module_names_are_comparable() {
let a = ModuleName::new("Orders").unwrap();
let b = ModuleName::new("Orders").unwrap();
let c = ModuleName::new("Billing").unwrap();
assert_eq!(a, b);
assert_ne!(a, c);
}

View File

@@ -0,0 +1,37 @@
use archlens_domain::{RenderOutput, RenderedFile};
#[test]
fn rendered_file_carries_name_and_content() {
let file = RenderedFile::new("overview.mmd", "graph TD;").unwrap();
assert_eq!(file.name(), "overview.mmd");
assert_eq!(file.content(), "graph TD;");
}
#[test]
fn rendered_file_rejects_empty_name() {
let result = RenderedFile::new("", "content");
assert!(result.is_err());
}
#[test]
fn rendered_file_rejects_empty_content() {
let result = RenderedFile::new("file.mmd", "");
assert!(result.is_err());
}
#[test]
fn render_output_holds_multiple_files() {
let files = vec![
RenderedFile::new("overview.mmd", "graph TD;").unwrap(),
RenderedFile::new("orders.mmd", "classDiagram").unwrap(),
];
let output = RenderOutput::new(files);
assert_eq!(output.files().len(), 2);
}
#[test]
fn render_output_can_be_single_file() {
let file = RenderedFile::new("arch.mmd", "graph TD;").unwrap();
let output = RenderOutput::single(file);
assert_eq!(output.files().len(), 1);
}

View File

@@ -0,0 +1,10 @@
use archlens_domain::{FilePath, Language, SourceFile};
#[test]
fn source_file_carries_path_and_language() {
let path = FilePath::new("src/main.rs").unwrap();
let file = SourceFile::new(path.clone(), Language::Rust);
assert_eq!(file.path(), &path);
assert_eq!(file.language(), Language::Rust);
}

View File

@@ -0,0 +1,28 @@
[package]
name = "archlens"
version = "0.1.0"
edition = "2024"
publish = false
[[bin]]
name = "archlens"
path = "src/main.rs"
[dependencies]
archlens-domain.workspace = true
archlens-application.workspace = true
archlens-tree-sitter.workspace = true
archlens-walkdir.workspace = true
archlens-mermaid.workspace = true
archlens-ascii.workspace = true
archlens-file-writer.workspace = true
archlens-stdout-writer.workspace = true
archlens-toml-config.workspace = true
archlens-cargo-workspace.workspace = true
anyhow.workspace = true
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
tempfile.workspace = true

View File

@@ -0,0 +1,61 @@
use std::path::PathBuf;
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(
name = "archlens",
about = "Generate architecture diagrams from source code"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
#[arg(default_value = ".")]
pub path: PathBuf,
#[arg(long, default_value = "module")]
pub level: String,
#[arg(long, default_value = "mermaid")]
pub format: String,
#[arg(long)]
pub output: Option<String>,
#[arg(long)]
pub config: Option<String>,
#[arg(long)]
pub scope: Option<String>,
#[arg(long)]
pub exclude: Vec<String>,
#[arg(long)]
pub split_by_module: bool,
#[arg(long)]
pub strict: bool,
#[arg(
long,
help = "Check if output matches existing file, exit 1 if different"
)]
pub check: bool,
#[arg(short, long, action = clap::ArgAction::Count)]
pub verbose: u8,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Init {
#[arg(default_value = ".")]
path: PathBuf,
},
Diff {
#[arg(help = "Path to existing diagram file to compare against")]
existing: PathBuf,
},
}

View File

@@ -0,0 +1,392 @@
mod cli;
use std::path::PathBuf;
use anyhow::{Result, bail};
use archlens_application::queries::AnalyzeCodebase;
use archlens_ascii::AsciiRenderer;
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
use archlens_domain::{
CodeGraph, DiagramLevel,
ports::{ConfigLoader, OutputWriter, ProjectAnalyzer},
};
use archlens_file_writer::FileOutputWriter;
use archlens_mermaid::MermaidRenderer;
use archlens_stdout_writer::StdoutOutputWriter;
use archlens_toml_config::TomlConfigLoader;
use archlens_tree_sitter::TreeSitterAnalyzer;
use archlens_walkdir::WalkdirDiscovery;
pub use cli::{Cli, Command};
pub type CliArgs = Cli;
pub fn run(args: Cli) -> Result<()> {
match &args.command {
Some(Command::Init { path }) => return init_config(path),
Some(Command::Diff { existing }) => return run_diff(&args, existing),
None => {}
}
init_tracing(args.verbose);
let config_loader = match &args.config {
Some(path) => TomlConfigLoader::from_path(std::path::Path::new(path))?,
None => {
let default_path = args.path.join("archlens.toml");
if default_path.exists() {
TomlConfigLoader::from_path(&default_path)?
} else {
TomlConfigLoader::default()
}
}
};
let mut analysis_config = config_loader.load_analysis_config()?;
let level = parse_level(&args.level);
analysis_config = analysis_config.with_level(level);
if let Some(ref scope) = args.scope {
analysis_config = analysis_config.with_scope(scope.clone());
}
if !args.exclude.is_empty() {
let mut excludes = analysis_config.excludes().to_vec();
excludes.extend(args.exclude.iter().cloned());
analysis_config = analysis_config.with_excludes(excludes);
}
let graph = if level == DiagramLevel::Project {
let project_analyzer = CargoWorkspaceAnalyzer::new();
project_analyzer.analyze(&args.path)?
} else {
let discovery = WalkdirDiscovery::new();
let analyzer = TreeSitterAnalyzer::new();
let analyze = AnalyzeCodebase::new(discovery, analyzer);
let result = analyze.execute(&args.path, &analysis_config)?;
if !result.warnings().is_empty() {
for warning in result.warnings() {
eprintln!(
"WARNING: {}:{} {}",
warning.file_path().as_str(),
warning.line(),
warning.message()
);
}
if args.strict {
bail!(
"analysis produced {} warning(s) in strict mode",
result.warnings().len()
);
}
}
let mut graph = result.graph().clone();
if level == DiagramLevel::Module {
let workspace_toml = args.path.join("Cargo.toml");
if workspace_toml.exists()
&& let Ok(project_graph) = CargoWorkspaceAnalyzer::new().analyze(&args.path)
{
merge_project_deps_as_module_edges(&mut graph, &project_graph);
}
}
graph
};
let renderer: Box<dyn archlens_domain::ports::DiagramRenderer> = match &args.format[..] {
"mermaid" => Box::new(MermaidRenderer::with_level(level)),
"ascii" => Box::new(AsciiRenderer::new()),
fmt => bail!("unknown format: {fmt}"),
};
let ext = match &args.format[..] {
"mermaid" => "mmd",
_ => "txt",
};
if args.check {
if let Some(ref path) = args.output {
let output = renderer.render(&graph)?;
let current = output.files().first().map(|f| f.content()).unwrap_or("");
let existing = std::fs::read_to_string(path).unwrap_or_default();
if current != existing {
eprintln!("Architecture diagram is outdated: {path}");
std::process::exit(1);
}
println!("Architecture diagram is up to date.");
return Ok(());
} else {
bail!("--check requires --output to specify the file to check against");
}
}
if args.split_by_module {
write_split(&graph, &*renderer, &args.output, ext)?;
} else {
write_single(&graph, &*renderer, &args.output)?;
}
Ok(())
}
fn write_split(
graph: &CodeGraph,
renderer: &dyn archlens_domain::ports::DiagramRenderer,
output: &Option<String>,
ext: &str,
) -> Result<()> {
let output_dir = output
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let writer = FileOutputWriter::new(output_dir);
let overview = renderer.render(graph)?;
let overview_file = archlens_domain::RenderedFile::new(
&format!("overview.{ext}"),
overview.files().first().map(|f| f.content()).unwrap_or(""),
)?;
writer.write(&archlens_domain::RenderOutput::single(overview_file))?;
for module in graph.modules() {
let subgraph = graph.subgraph_by_module(&module);
let module_output = renderer.render(&subgraph)?;
let module_file = archlens_domain::RenderedFile::new(
&format!("{}.{ext}", module.as_str().to_lowercase()),
module_output
.files()
.first()
.map(|f| f.content())
.unwrap_or(""),
)?;
writer.write(&archlens_domain::RenderOutput::single(module_file))?;
}
Ok(())
}
fn write_single(
graph: &CodeGraph,
renderer: &dyn archlens_domain::ports::DiagramRenderer,
output: &Option<String>,
) -> Result<()> {
let rendered = renderer.render(graph)?;
match output {
Some(path) => {
let writer = FileOutputWriter::single_file(PathBuf::from(path));
writer.write(&rendered)?;
}
None => {
let writer = StdoutOutputWriter::new();
writer.write(&rendered)?;
}
}
Ok(())
}
fn merge_project_deps_as_module_edges(
graph: &mut archlens_domain::CodeGraph,
project_graph: &archlens_domain::CodeGraph,
) {
use std::collections::HashMap;
let mut crate_to_module: HashMap<&str, &str> = HashMap::new();
for element in project_graph.elements() {
let module = element
.module()
.map(|m| m.as_str())
.unwrap_or(element.name());
crate_to_module.insert(element.name(), module);
}
let graph_modules: std::collections::HashSet<String> = graph
.modules()
.iter()
.map(|m| m.as_str().to_string())
.collect();
for rel in project_graph.relationships() {
let src_module = crate_to_module.get(rel.source());
let tgt_module = crate_to_module.get(rel.target());
if let (Some(src), Some(tgt)) = (src_module, tgt_module) {
let src_cap = capitalize(src);
let tgt_cap = capitalize(tgt);
if src_cap != tgt_cap
&& graph_modules.contains(&src_cap)
&& graph_modules.contains(&tgt_cap)
&& let Ok(edge) = archlens_domain::Relationship::new(
&src_cap,
&tgt_cap,
archlens_domain::RelationshipKind::Composition,
)
{
graph.add_relationship(edge);
}
}
}
}
fn capitalize(s: &str) -> String {
s.split('-')
.map(|seg| {
if seg.is_empty() {
String::new()
} else {
format!("{}{}", seg[..1].to_uppercase(), &seg[1..])
}
})
.collect::<Vec<_>>()
.join("-")
}
fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
init_tracing(args.verbose);
let config_loader = match &args.config {
Some(path) => TomlConfigLoader::from_path(std::path::Path::new(path))?,
None => {
let default_path = args.path.join("archlens.toml");
if default_path.exists() {
TomlConfigLoader::from_path(&default_path)?
} else {
TomlConfigLoader::default()
}
}
};
let mut analysis_config = config_loader.load_analysis_config()?;
let level = parse_level(&args.level);
analysis_config = analysis_config.with_level(level);
let graph = if level == DiagramLevel::Project {
CargoWorkspaceAnalyzer::new().analyze(&args.path)?
} else {
let discovery = WalkdirDiscovery::new();
let analyzer = TreeSitterAnalyzer::new();
let analyze = AnalyzeCodebase::new(discovery, analyzer);
let result = analyze.execute(&args.path, &analysis_config)?;
let mut graph = result.graph().clone();
if level == DiagramLevel::Module {
let workspace_toml = args.path.join("Cargo.toml");
if workspace_toml.exists()
&& let Ok(project_graph) = CargoWorkspaceAnalyzer::new().analyze(&args.path)
{
merge_project_deps_as_module_edges(&mut graph, &project_graph);
}
}
graph
};
let renderer: Box<dyn archlens_domain::ports::DiagramRenderer> = match &args.format[..] {
"mermaid" => Box::new(MermaidRenderer::with_level(level)),
"ascii" => Box::new(AsciiRenderer::new()),
fmt => bail!("unknown format: {fmt}"),
};
let output = renderer.render(&graph)?;
let current = output.files().first().map(|f| f.content()).unwrap_or("");
let existing = std::fs::read_to_string(existing_path).unwrap_or_default();
if current == existing {
println!("No changes detected.");
return Ok(());
}
let current_lines: Vec<&str> = current.lines().collect();
let existing_lines: Vec<&str> = existing.lines().collect();
let mut added = Vec::new();
let mut removed = Vec::new();
for line in &current_lines {
if !existing_lines.contains(line) {
added.push(*line);
}
}
for line in &existing_lines {
if !current_lines.contains(line) {
removed.push(*line);
}
}
if !removed.is_empty() {
println!("Removed:");
for line in &removed {
println!(" - {line}");
}
}
if !added.is_empty() {
println!("Added:");
for line in &added {
println!(" + {line}");
}
}
println!("\n{} added, {} removed", added.len(), removed.len());
std::process::exit(1);
}
fn init_config(path: &std::path::Path) -> Result<()> {
let config_path = path.join("archlens.toml");
if config_path.exists() {
bail!("archlens.toml already exists at {}", config_path.display());
}
let content = r#"[analysis]
# Directories to exclude from analysis
exclude = ["tests/", "vendor/", "generated/"]
# Default granularity: "module", "type", or "project"
level = "module"
[modules]
# Map directories to module names (overrides auto-detection)
# "src/infra" = "Infrastructure"
# "src/api" = "API"
[output]
# Default output format
format = "mermaid"
# Default output path (omit for stdout)
# path = "docs/architecture.mmd"
# Generate separate files per module
split_by_module = false
"#;
std::fs::write(&config_path, content)?;
println!("Created {}", config_path.display());
Ok(())
}
fn parse_level(level: &str) -> DiagramLevel {
match level {
"type" => DiagramLevel::Type,
"project" => DiagramLevel::Project,
_ => DiagramLevel::Module,
}
}
fn init_tracing(verbosity: u8) {
let filter = match verbosity {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
};
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)),
)
.try_init()
.ok();
}

View File

@@ -0,0 +1,9 @@
use anyhow::Result;
use clap::Parser;
use archlens::Cli;
fn main() -> Result<()> {
let args = Cli::parse();
archlens::run(args)
}

View File

@@ -0,0 +1,133 @@
use std::fs;
use archlens::run;
fn create_rust_project(dir: &std::path::Path) {
fs::create_dir_all(dir.join("src")).unwrap();
fs::write(
dir.join("src/order.rs"),
"pub struct Order {\n pub id: u64,\n}\n",
)
.unwrap();
fs::write(
dir.join("src/service.rs"),
"pub struct OrderService {\n order: Order,\n}\n",
)
.unwrap();
}
fn create_multi_module_project(dir: &std::path::Path) {
fs::create_dir_all(dir.join("src/orders")).unwrap();
fs::create_dir_all(dir.join("src/billing")).unwrap();
fs::write(
dir.join("src/orders/order.rs"),
"pub struct Order {\n pub id: u64,\n}\n",
)
.unwrap();
fs::write(
dir.join("src/orders/service.rs"),
"pub struct OrderService {\n order: Order,\n}\n",
)
.unwrap();
fs::write(
dir.join("src/billing/invoice.rs"),
"pub struct Invoice {\n pub total: f64,\n}\n",
)
.unwrap();
}
#[test]
fn analyzes_rust_project_and_writes_mermaid_to_file() {
let project = tempfile::tempdir().unwrap();
create_rust_project(project.path());
let output_dir = tempfile::tempdir().unwrap();
let output_file = output_dir.path().join("arch.mmd");
run(archlens::CliArgs {
command: None,
path: project.path().to_path_buf(),
level: "type".to_string(),
format: "mermaid".to_string(),
output: Some(output_file.to_str().unwrap().to_string()),
config: None,
scope: None,
exclude: vec![],
split_by_module: false,
strict: false,
check: false,
verbose: 0,
})
.unwrap();
let content = fs::read_to_string(&output_file).unwrap();
assert!(content.contains("classDiagram"));
assert!(content.contains("Order"));
assert!(content.contains("OrderService"));
}
#[test]
fn works_without_config_file() {
let project = tempfile::tempdir().unwrap();
create_rust_project(project.path());
let output_dir = tempfile::tempdir().unwrap();
let output_file = output_dir.path().join("arch.mmd");
let result = run(archlens::CliArgs {
command: None,
path: project.path().to_path_buf(),
level: "type".to_string(),
format: "mermaid".to_string(),
output: Some(output_file.to_str().unwrap().to_string()),
config: None,
scope: None,
exclude: vec![],
split_by_module: false,
strict: false,
check: false,
verbose: 0,
});
assert!(result.is_ok());
}
#[test]
fn split_by_module_writes_overview_and_per_module_files() {
let project = tempfile::tempdir().unwrap();
create_multi_module_project(project.path());
let output_dir = tempfile::tempdir().unwrap();
run(archlens::CliArgs {
command: None,
path: project.path().to_path_buf(),
level: "module".to_string(),
format: "mermaid".to_string(),
output: Some(output_dir.path().to_str().unwrap().to_string()),
config: None,
scope: None,
exclude: vec![],
split_by_module: true,
strict: false,
check: false,
verbose: 0,
})
.unwrap();
let overview = output_dir.path().join("overview.mmd");
assert!(overview.exists(), "overview.mmd should exist");
let overview_content = fs::read_to_string(&overview).unwrap();
assert!(overview_content.contains("graph TD") || overview_content.contains("classDiagram"));
let entries: Vec<_> = fs::read_dir(output_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert!(
entries.len() > 1,
"should have overview + at least one module file"
);
}