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

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