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:
8
crates/domain/Cargo.toml
Normal file
8
crates/domain/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "archlens-domain"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
78
crates/domain/src/aggregates/code_graph.rs
Normal file
78
crates/domain/src/aggregates/code_graph.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
3
crates/domain/src/aggregates/mod.rs
Normal file
3
crates/domain/src/aggregates/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod code_graph;
|
||||
|
||||
pub use code_graph::CodeGraph;
|
||||
111
crates/domain/src/entities/code_element.rs
Normal file
111
crates/domain/src/entities/code_element.rs
Normal 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
|
||||
}
|
||||
}
|
||||
5
crates/domain/src/entities/mod.rs
Normal file
5
crates/domain/src/entities/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod code_element;
|
||||
mod relationship;
|
||||
|
||||
pub use code_element::CodeElement;
|
||||
pub use relationship::Relationship;
|
||||
49
crates/domain/src/entities/relationship.rs
Normal file
49
crates/domain/src/entities/relationship.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
14
crates/domain/src/error.rs
Normal file
14
crates/domain/src/error.rs
Normal 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
14
crates/domain/src/lib.rs
Normal 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};
|
||||
6
crates/domain/src/ports/config_loader.rs
Normal file
6
crates/domain/src/ports/config_loader.rs
Normal 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>;
|
||||
}
|
||||
5
crates/domain/src/ports/diagram_renderer.rs
Normal file
5
crates/domain/src/ports/diagram_renderer.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use crate::{CodeGraph, DomainError, RenderOutput};
|
||||
|
||||
pub trait DiagramRenderer {
|
||||
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError>;
|
||||
}
|
||||
9
crates/domain/src/ports/file_discovery.rs
Normal file
9
crates/domain/src/ports/file_discovery.rs
Normal 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>;
|
||||
}
|
||||
13
crates/domain/src/ports/mod.rs
Normal file
13
crates/domain/src/ports/mod.rs
Normal 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;
|
||||
5
crates/domain/src/ports/output_writer.rs
Normal file
5
crates/domain/src/ports/output_writer.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use crate::{DomainError, RenderOutput};
|
||||
|
||||
pub trait OutputWriter {
|
||||
fn write(&self, output: &RenderOutput) -> Result<(), DomainError>;
|
||||
}
|
||||
7
crates/domain/src/ports/project_analyzer.rs
Normal file
7
crates/domain/src/ports/project_analyzer.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{CodeGraph, DomainError};
|
||||
|
||||
pub trait ProjectAnalyzer {
|
||||
fn analyze(&self, root: &Path) -> Result<CodeGraph, DomainError>;
|
||||
}
|
||||
5
crates/domain/src/ports/source_analyzer.rs
Normal file
5
crates/domain/src/ports/source_analyzer.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use crate::{AnalysisResult, DomainError, SourceFile};
|
||||
|
||||
pub trait SourceAnalyzer: Send + Sync {
|
||||
fn analyze_file(&self, file: &SourceFile) -> Result<AnalysisResult, DomainError>;
|
||||
}
|
||||
60
crates/domain/src/value_objects/analysis/analysis_config.rs
Normal file
60
crates/domain/src/value_objects/analysis/analysis_config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
42
crates/domain/src/value_objects/analysis/analysis_result.rs
Normal file
42
crates/domain/src/value_objects/analysis/analysis_result.rs
Normal 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
|
||||
}
|
||||
}
|
||||
34
crates/domain/src/value_objects/analysis/analysis_warning.rs
Normal file
34
crates/domain/src/value_objects/analysis/analysis_warning.rs
Normal 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
|
||||
}
|
||||
}
|
||||
7
crates/domain/src/value_objects/analysis/mod.rs
Normal file
7
crates/domain/src/value_objects/analysis/mod.rs
Normal 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;
|
||||
@@ -0,0 +1,9 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CodeElementKind {
|
||||
Class,
|
||||
Struct,
|
||||
Trait,
|
||||
Interface,
|
||||
Enum,
|
||||
Project,
|
||||
}
|
||||
7
crates/domain/src/value_objects/graph/mod.rs
Normal file
7
crates/domain/src/value_objects/graph/mod.rs
Normal 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;
|
||||
@@ -0,0 +1,6 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum RelationshipKind {
|
||||
Inheritance,
|
||||
Composition,
|
||||
Import,
|
||||
}
|
||||
6
crates/domain/src/value_objects/graph/visibility.rs
Normal file
6
crates/domain/src/value_objects/graph/visibility.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Visibility {
|
||||
Public,
|
||||
Private,
|
||||
Internal,
|
||||
}
|
||||
4
crates/domain/src/value_objects/mod.rs
Normal file
4
crates/domain/src/value_objects/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod analysis;
|
||||
pub mod graph;
|
||||
pub mod output;
|
||||
pub mod source;
|
||||
6
crates/domain/src/value_objects/output/diagram_level.rs
Normal file
6
crates/domain/src/value_objects/output/diagram_level.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DiagramLevel {
|
||||
Project,
|
||||
Module,
|
||||
Type,
|
||||
}
|
||||
9
crates/domain/src/value_objects/output/mod.rs
Normal file
9
crates/domain/src/value_objects/output/mod.rs
Normal 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;
|
||||
25
crates/domain/src/value_objects/output/output_config.rs
Normal file
25
crates/domain/src/value_objects/output/output_config.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
20
crates/domain/src/value_objects/output/render_output.rs
Normal file
20
crates/domain/src/value_objects/output/render_output.rs
Normal 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
|
||||
}
|
||||
}
|
||||
32
crates/domain/src/value_objects/output/rendered_file.rs
Normal file
32
crates/domain/src/value_objects/output/rendered_file.rs
Normal 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
|
||||
}
|
||||
}
|
||||
18
crates/domain/src/value_objects/source/file_path.rs
Normal file
18
crates/domain/src/value_objects/source/file_path.rs
Normal 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
|
||||
}
|
||||
}
|
||||
16
crates/domain/src/value_objects/source/language.rs
Normal file
16
crates/domain/src/value_objects/source/language.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
9
crates/domain/src/value_objects/source/mod.rs
Normal file
9
crates/domain/src/value_objects/source/mod.rs
Normal 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;
|
||||
18
crates/domain/src/value_objects/source/module_name.rs
Normal file
18
crates/domain/src/value_objects/source/module_name.rs
Normal 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
|
||||
}
|
||||
}
|
||||
21
crates/domain/src/value_objects/source/source_file.rs
Normal file
21
crates/domain/src/value_objects/source/source_file.rs
Normal 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
|
||||
}
|
||||
}
|
||||
39
crates/domain/tests/analysis_result_tests.rs
Normal file
39
crates/domain/tests/analysis_result_tests.rs
Normal 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());
|
||||
}
|
||||
21
crates/domain/tests/analysis_warning_tests.rs
Normal file
21
crates/domain/tests/analysis_warning_tests.rs
Normal 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());
|
||||
}
|
||||
107
crates/domain/tests/code_element_tests.rs
Normal file
107
crates/domain/tests/code_element_tests.rs
Normal 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;
|
||||
}
|
||||
130
crates/domain/tests/code_graph_tests.rs
Normal file
130
crates/domain/tests/code_graph_tests.rs
Normal 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"));
|
||||
}
|
||||
37
crates/domain/tests/config_tests.rs
Normal file
37
crates/domain/tests/config_tests.rs
Normal 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;
|
||||
}
|
||||
29
crates/domain/tests/file_path_tests.rs
Normal file
29
crates/domain/tests/file_path_tests.rs
Normal 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);
|
||||
}
|
||||
18
crates/domain/tests/language_tests.rs
Normal file
18
crates/domain/tests/language_tests.rs
Normal 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);
|
||||
}
|
||||
23
crates/domain/tests/module_name_tests.rs
Normal file
23
crates/domain/tests/module_name_tests.rs
Normal 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);
|
||||
}
|
||||
37
crates/domain/tests/render_output_tests.rs
Normal file
37
crates/domain/tests/render_output_tests.rs
Normal 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);
|
||||
}
|
||||
10
crates/domain/tests/source_file_tests.rs
Normal file
10
crates/domain/tests/source_file_tests.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user