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,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");
}