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