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