init: archlens — architecture diagram generator
Some checks failed
CI / Check / Test (push) Failing after 1m24s
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:
15
crates/adapters/cargo-workspace/Cargo.toml
Normal file
15
crates/adapters/cargo-workspace/Cargo.toml
Normal 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
|
||||
123
crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs
Normal file
123
crates/adapters/cargo-workspace/src/cargo_workspace_analyzer.rs
Normal 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()
|
||||
}
|
||||
3
crates/adapters/cargo-workspace/src/lib.rs
Normal file
3
crates/adapters/cargo-workspace/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod cargo_workspace_analyzer;
|
||||
|
||||
pub use cargo_workspace_analyzer::CargoWorkspaceAnalyzer;
|
||||
132
crates/adapters/cargo-workspace/tests/cargo_workspace_tests.rs
Normal file
132
crates/adapters/cargo-workspace/tests/cargo_workspace_tests.rs
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user