refactor: five architectural deepening improvements
Some checks failed
CI / Check / Test (push) Failing after 43s
Architecture Docs / Generate diagrams (push) Successful in 3m20s

Candidate 1 (NormalizedGraph): qualify→resolve→filter is now a single
named operation returning a distinct type; raw CodeGraph cannot call
module_edges/subgraph_by_module — pipeline order enforced at compile time.

Candidate 2 (Use cases): GenerateDiagram, CheckFreshness, DiffDiagram
extracted to application/src/use_cases/; presentation is now a thin CLI
dispatcher (~100 lines less, three fewer local functions).

Candidate 3 (ExtractionContext): shared accumulator for both Rust and
Python extractors replaces parallel Vec<> + 4-arg passing chains.

Candidate 4 (ModuleAssignment): ModuleName::assign() returns
ModuleAssignment { Explicit | Inferred | Unresolved } instead of Option,
callers can distinguish resolution strategies.

Candidate 5 (SplitRenderer): append_cross_module_deps removed from
DiagramRenderer port; replaced by render_for_module() default impl —
port interface now reflects what all renderers actually share.
This commit is contained in:
2026-06-17 11:24:18 +02:00
parent b159cafc9d
commit fc8ad0ebc0
18 changed files with 614 additions and 511 deletions

View File

@@ -226,26 +226,22 @@ impl DiagramRenderer for MermaidRenderer {
Ok(RenderOutput::single(file))
}
fn append_cross_module_deps(
fn render_for_module(
&self,
content: &str,
subgraph: &CodeGraph,
module: &ModuleName,
deps: &[(ModuleName, usize)],
) -> String {
if deps.is_empty() {
return content.to_string();
}
let src_id = format!(
"{}_module",
module.as_str().to_lowercase().replace('-', "_")
);
cross_deps: &[(ModuleName, usize)],
) -> Result<RenderOutput, DomainError> {
let base = self.render_class_diagram(subgraph);
let content = if cross_deps.is_empty() {
base
} else {
let src_id = format!("{}_module", module.as_str().to_lowercase().replace('-', "_"));
let mut extra = format!(
" class {src_id}[\"{}\"] {{\n <<module>>\n }}\n",
module.as_str()
);
for (dep_mod, count) in deps {
for (dep_mod, count) in cross_deps {
let dep_id = format!(
"{}_module",
dep_mod.as_str().to_lowercase().replace('-', "_")
@@ -254,14 +250,12 @@ impl DiagramRenderer for MermaidRenderer {
" class {dep_id}[\"{}\"] {{\n <<module>>\n }}\n",
dep_mod.as_str()
));
let label = if *count == 1 {
"1 dep".to_string()
} else {
format!("{count} deps")
};
let label = if *count == 1 { "1 dep".to_string() } else { format!("{count} deps") };
extra.push_str(&format!(" {src_id} --> {dep_id} : {label}\n"));
}
format!("{content}\n{extra}")
format!("{base}\n{extra}")
};
let file = RenderedFile::new("diagram.mmd", &content)?;
Ok(RenderOutput::single(file))
}
}

View File

@@ -0,0 +1,56 @@
use std::collections::HashSet;
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, DomainError, FilePath, Relationship,
};
/// Accumulator passed through tree-walking helpers.
///
/// Bundles the four parallel mutable collections that every language extractor
/// maintains, giving helpers a single receiver to push into and eliminating
/// 4-parameter passing chains.
pub struct ExtractionContext {
elements: Vec<CodeElement>,
relationships: Vec<Relationship>,
warnings: Vec<AnalysisWarning>,
/// Names of types defined in the current file — used to filter primitive
/// and external types from relationship targets.
pub local_types: HashSet<String>,
file_path: FilePath,
}
impl ExtractionContext {
pub fn new(file_path: FilePath) -> Self {
Self {
elements: Vec::new(),
relationships: Vec::new(),
warnings: Vec::new(),
local_types: HashSet::new(),
file_path,
}
}
pub fn add_element(&mut self, element: CodeElement) {
self.local_types.insert(element.name().to_string());
self.elements.push(element);
}
pub fn add_relationship(&mut self, rel: Relationship) {
self.relationships.push(rel.with_source_file(self.file_path.clone()));
}
pub fn add_warning(&mut self, file_path: FilePath, line: usize, message: &str) {
if let Ok(w) = AnalysisWarning::new(file_path, line, message) {
self.warnings.push(w);
}
}
pub fn file_path(&self) -> &FilePath {
&self.file_path
}
/// Consumes the context and returns the completed `AnalysisResult`.
pub fn into_result(self) -> Result<AnalysisResult, DomainError> {
Ok(AnalysisResult::new(self.elements, self.relationships, self.warnings))
}
}

View File

@@ -1,3 +1,4 @@
mod extraction_context;
mod language_extractor;
mod python;
mod rust;

View File

@@ -1,12 +1,11 @@
use std::collections::HashSet;
use tree_sitter::{Node, Parser};
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
Relationship, RelationshipKind,
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
RelationshipKind,
};
use crate::extraction_context::ExtractionContext;
use crate::language_extractor::LanguageExtractor;
pub struct PythonExtractor;
@@ -27,40 +26,16 @@ pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, Dom
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
let mut elements = Vec::new();
let mut relationships = Vec::new();
let mut warnings = Vec::new();
let mut type_names: HashSet<String> = HashSet::new();
let mut ctx = ExtractionContext::new(file_path.clone());
let root = tree.root_node();
collect_classes(
&root,
source,
file_path,
&mut elements,
&mut type_names,
&mut relationships,
&mut warnings,
);
collect_imports(&root, source, file_path, &mut relationships);
let relationships = relationships
.into_iter()
.map(|r| r.with_source_file(file_path.clone()))
.collect();
collect_classes(&root, source, &mut ctx);
collect_imports(&root, source, &mut ctx);
Ok(AnalysisResult::new(elements, relationships, warnings))
ctx.into_result()
}
fn collect_classes(
node: &Node,
source: &str,
file_path: &FilePath,
elements: &mut Vec<CodeElement>,
type_names: &mut HashSet<String>,
relationships: &mut Vec<Relationship>,
warnings: &mut Vec<AnalysisWarning>,
) {
fn collect_classes(node: &Node, source: &str, ctx: &mut ExtractionContext) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "class_definition" {
@@ -79,26 +54,21 @@ fn collect_classes(
.map(|body| collect_methods(&body, source))
.unwrap_or_default();
match CodeElement::new(name, CodeElementKind::Class, file_path.clone(), line) {
Ok(element) => {
type_names.insert(name.to_string());
elements.push(element.with_methods(methods));
}
match CodeElement::new(name, CodeElementKind::Class, ctx.file_path().clone(), line) {
Ok(element) => ctx.add_element(element.with_methods(methods)),
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
warnings.push(w);
}
ctx.add_warning(ctx.file_path().clone(), line, &e.to_string());
continue;
}
}
if let Some(superclasses) = child.child_by_field_name("superclasses") {
collect_inheritance(&superclasses, source, name, type_names, relationships);
collect_inheritance(&superclasses, source, name, ctx);
}
if let Some(body) = child.child_by_field_name("body") {
collect_typed_fields(&body, source, name, type_names, relationships);
collect_constructor_params(&body, source, name, type_names, relationships);
collect_typed_fields(&body, source, name, ctx);
collect_constructor_params(&body, source, name, ctx);
}
}
}
@@ -107,8 +77,7 @@ fn collect_inheritance(
superclasses: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
ctx: &mut ExtractionContext,
) {
let mut cursor = superclasses.walk();
for child in superclasses.children(&mut cursor) {
@@ -118,7 +87,7 @@ fn collect_inheritance(
&& let Ok(rel) =
Relationship::new(class_name, base_name, RelationshipKind::Inheritance)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}
@@ -228,16 +197,12 @@ fn is_external_import(module: &str) -> bool {
false
}
fn collect_imports(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
fn collect_imports(node: &Node, source: &str, ctx: &mut ExtractionContext) {
let file_name = std::path::Path::new(ctx.file_path().as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
.unwrap_or("unknown")
.to_string();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
@@ -249,9 +214,9 @@ fn collect_imports(
let module = &source[name_child.byte_range()];
if !is_external_import(module)
&& let Ok(rel) =
Relationship::new(file_name, module, RelationshipKind::Import)
Relationship::new(&file_name, module, RelationshipKind::Import)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}
@@ -261,9 +226,9 @@ fn collect_imports(
let module = &source[module_node.byte_range()];
if !is_external_import(module)
&& let Ok(rel) =
Relationship::new(file_name, module, RelationshipKind::Import)
Relationship::new(&file_name, module, RelationshipKind::Import)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}
@@ -314,7 +279,6 @@ fn extract_python_params(params_node: &Node, source: &str) -> String {
match param.kind() {
"typed_parameter" => {
if let Some(type_node) = param.child_by_field_name("type") {
// name is the first identifier child (not a named field)
let mut inner = param.walk();
let name = param
.children(&mut inner)
@@ -339,13 +303,7 @@ fn extract_python_params(params_node: &Node, source: &str) -> String {
parts.join(", ")
}
fn collect_constructor_params(
body: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
fn collect_constructor_params(body: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() != "function_definition" {
@@ -373,30 +331,18 @@ fn collect_constructor_params(
&& let Ok(rel) =
Relationship::new(class_name, base_type, RelationshipKind::Composition)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}
}
}
fn collect_typed_fields(
body: &Node,
source: &str,
class_name: &str,
type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
collect_typed_fields_recursive(body, source, class_name, type_names, relationships);
fn collect_typed_fields(body: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) {
collect_typed_fields_recursive(body, source, class_name, ctx);
}
fn collect_typed_fields_recursive(
node: &Node,
source: &str,
class_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
) {
fn collect_typed_fields_recursive(node: &Node, source: &str, class_name: &str, ctx: &mut ExtractionContext) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if (child.kind() == "assignment" || child.kind() == "typed_assignment")
@@ -410,10 +356,10 @@ fn collect_typed_fields_recursive(
&& let Ok(rel) =
Relationship::new(class_name, base_type, RelationshipKind::Composition)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
collect_typed_fields_recursive(&child, source, class_name, _type_names, relationships);
collect_typed_fields_recursive(&child, source, class_name, ctx);
}
}

View File

@@ -1,47 +1,17 @@
use std::collections::HashSet;
use tree_sitter::{Node, Parser};
const RUST_PRIMITIVES: &[&str] = &[
"bool",
"char",
"str",
"String",
"u8",
"u16",
"u32",
"u64",
"u128",
"usize",
"i8",
"i16",
"i32",
"i64",
"i128",
"isize",
"f32",
"f64",
"Vec",
"Option",
"Result",
"Box",
"Rc",
"Arc",
"HashMap",
"HashSet",
"BTreeMap",
"BTreeSet",
"PhantomData",
"Pin",
"Cow",
"Self",
"bool", "char", "str", "String", "u8", "u16", "u32", "u64", "u128", "usize", "i8", "i16",
"i32", "i64", "i128", "isize", "f32", "f64", "Vec", "Option", "Result", "Box", "Rc", "Arc",
"HashMap", "HashSet", "BTreeMap", "BTreeSet", "PhantomData", "Pin", "Cow", "Self",
];
use archlens_domain::{
AnalysisResult, AnalysisWarning, CodeElement, CodeElementKind, DomainError, FilePath,
Relationship, RelationshipKind, Visibility,
AnalysisResult, CodeElement, CodeElementKind, DomainError, FilePath, Relationship,
RelationshipKind, Visibility,
};
use crate::extraction_context::ExtractionContext;
use crate::language_extractor::LanguageExtractor;
pub struct RustExtractor;
@@ -62,46 +32,18 @@ pub fn analyze(source: &str, file_path: &FilePath) -> Result<AnalysisResult, Dom
.parse(source, None)
.ok_or_else(|| DomainError::AnalysisError("failed to parse".to_string()))?;
let mut elements = Vec::new();
let mut relationships = Vec::new();
let mut warnings = Vec::new();
let mut type_names: HashSet<String> = HashSet::new();
let mut ctx = ExtractionContext::new(file_path.clone());
let root = tree.root_node();
collect_types(
&root,
source,
file_path,
&mut elements,
&mut type_names,
&mut warnings,
);
collect_relationships(
&root,
source,
&type_names,
&mut relationships,
&mut warnings,
);
collect_mod_declarations(&root, source, file_path, &mut relationships);
collect_use_imports(&root, source, file_path, &mut relationships);
let relationships = relationships
.into_iter()
.map(|r| r.with_source_file(file_path.clone()))
.collect();
collect_types(&root, source, &mut ctx);
collect_relationships(&root, source, &mut ctx);
collect_mod_declarations(&root, source, &mut ctx);
collect_use_imports(&root, source, &mut ctx);
Ok(AnalysisResult::new(elements, relationships, warnings))
ctx.into_result()
}
fn collect_types(
node: &Node,
source: &str,
file_path: &FilePath,
elements: &mut Vec<CodeElement>,
type_names: &mut HashSet<String>,
warnings: &mut Vec<AnalysisWarning>,
) {
fn collect_types(node: &Node, source: &str, ctx: &mut ExtractionContext) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let (kind, name_field) = match child.kind() {
@@ -116,52 +58,34 @@ fn collect_types(
let line = child.start_position().row + 1;
let visibility = detect_visibility(&child, source);
match CodeElement::new(name, kind, file_path.clone(), line) {
match CodeElement::new(name, kind, ctx.file_path().clone(), line) {
Ok(element) => {
let fields = extract_fields(&child, source);
let methods = extract_methods(node, source, name);
let element = element
ctx.add_element(
element
.with_visibility(visibility)
.with_fields(fields)
.with_methods(methods);
type_names.insert(name.to_string());
elements.push(element);
}
Err(e) => {
if let Ok(w) = AnalysisWarning::new(file_path.clone(), line, &e.to_string()) {
warnings.push(w);
}
.with_methods(methods),
);
}
Err(e) => ctx.add_warning(ctx.file_path().clone(), line, &e.to_string()),
}
}
}
}
fn collect_relationships(
node: &Node,
source: &str,
type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
warnings: &mut Vec<AnalysisWarning>,
) {
fn collect_relationships(node: &Node, source: &str, ctx: &mut ExtractionContext) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"struct_item" => {
if let Some(name_node) = child.child_by_field_name("name") {
let struct_name = source[name_node.byte_range()].to_string();
collect_field_compositions(
&child,
source,
&struct_name,
type_names,
relationships,
);
collect_field_compositions(&child, source, &struct_name, ctx);
}
}
"impl_item" => {
collect_trait_impl(&child, source, type_names, relationships, warnings);
}
"impl_item" => collect_trait_impl(&child, source, ctx),
_ => {}
}
}
@@ -171,8 +95,7 @@ fn collect_field_compositions(
struct_node: &Node,
source: &str,
struct_name: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
ctx: &mut ExtractionContext,
) {
if let Some(body) = struct_node.child_by_field_name("body") {
let mut cursor = body.walk();
@@ -187,20 +110,14 @@ fn collect_field_compositions(
&& let Ok(rel) =
Relationship::new(struct_name, &type_text, RelationshipKind::Composition)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}
}
}
fn collect_trait_impl(
impl_node: &Node,
source: &str,
_type_names: &HashSet<String>,
relationships: &mut Vec<Relationship>,
_warnings: &mut Vec<AnalysisWarning>,
) {
fn collect_trait_impl(impl_node: &Node, source: &str, ctx: &mut ExtractionContext) {
let trait_node = impl_node.child_by_field_name("trait");
let type_node = impl_node.child_by_field_name("type");
@@ -215,7 +132,7 @@ fn collect_trait_impl(
&& let Ok(rel) =
Relationship::new(&type_name, &trait_name, RelationshipKind::Inheritance)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}
@@ -226,12 +143,8 @@ fn extract_fields(node: &Node, source: &str) -> Vec<String> {
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
if child.kind() == "field_declaration" {
let name = child
.child_by_field_name("name")
.map(|n| &source[n.byte_range()]);
let ty = child
.child_by_field_name("type")
.map(|n| extract_base_type(&n, source));
let name = child.child_by_field_name("name").map(|n| &source[n.byte_range()]);
let ty = child.child_by_field_name("type").map(|n| extract_base_type(&n, source));
if let (Some(name), Some(ty)) = (name, ty) {
fields.push(format!("{name}: {ty}"));
}
@@ -245,19 +158,15 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
let mut methods = Vec::new();
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() != "impl_item" {
if child.kind() != "impl_item" || child.child_by_field_name("trait").is_some() {
continue;
}
if child.child_by_field_name("trait").is_some() {
if child
.child_by_field_name("type")
.is_some_and(|tn| extract_base_type(&tn, source) != type_name)
{
continue;
}
let type_node = child.child_by_field_name("type");
if let Some(tn) = type_node {
let impl_name = extract_base_type(&tn, source);
if impl_name != type_name {
continue;
}
}
if let Some(body) = child.child_by_field_name("body") {
let mut body_cursor = body.walk();
for item in body.children(&mut body_cursor) {
@@ -265,11 +174,7 @@ fn extract_methods(root: &Node, source: &str, type_name: &str) -> Vec<String> {
&& let Some(name_node) = item.child_by_field_name("name")
{
let fn_name = &source[name_node.byte_range()];
let vis = if detect_visibility(&item, source) == Visibility::Public {
"+"
} else {
"-"
};
let vis = if detect_visibility(&item, source) == Visibility::Public { "+" } else { "-" };
let params = extract_fn_params(&item, source);
let ret = extract_fn_return(&item, source);
let sig = if ret.is_empty() {
@@ -317,45 +222,34 @@ fn extract_fn_return(fn_item: &Node, source: &str) -> String {
.unwrap_or_default()
}
fn collect_mod_declarations(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
fn collect_mod_declarations(node: &Node, source: &str, ctx: &mut ExtractionContext) {
let file_name = std::path::Path::new(ctx.file_path().as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
.unwrap_or("unknown")
.to_string();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() != "mod_item" {
continue;
}
if child.child_by_field_name("body").is_some() {
if child.kind() != "mod_item" || child.child_by_field_name("body").is_some() {
continue;
}
if let Some(name_node) = child.child_by_field_name("name") {
let mod_name = &source[name_node.byte_range()];
let target = format!("crate::{mod_name}");
if let Ok(rel) = Relationship::new(file_name, &target, RelationshipKind::Import) {
relationships.push(rel);
if let Ok(rel) = Relationship::new(&file_name, &target, RelationshipKind::Import) {
ctx.add_relationship(rel);
}
}
}
}
fn collect_use_imports(
node: &Node,
source: &str,
file_path: &FilePath,
relationships: &mut Vec<Relationship>,
) {
let file_name = std::path::Path::new(file_path.as_str())
fn collect_use_imports(node: &Node, source: &str, ctx: &mut ExtractionContext) {
let file_name = std::path::Path::new(ctx.file_path().as_str())
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
.unwrap_or("unknown")
.to_string();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
@@ -364,16 +258,12 @@ fn collect_use_imports(
}
if let Some(arg) = child.child_by_field_name("argument") {
let text = &source[arg.byte_range()];
let path = text
.split('{')
.next()
.unwrap_or(text)
.trim_end_matches("::");
let path = text.split('{').next().unwrap_or(text).trim_end_matches("::");
if (path.starts_with("crate::") || path.starts_with("super::"))
&& let Ok(rel) = Relationship::new(file_name, path, RelationshipKind::Import)
&& let Ok(rel) = Relationship::new(&file_name, path, RelationshipKind::Import)
{
relationships.push(rel);
ctx.add_relationship(rel);
}
}
}

View File

@@ -1 +1,2 @@
pub mod queries;
pub mod use_cases;

View File

@@ -4,7 +4,8 @@ use std::path::Path;
use rayon::prelude::*;
use archlens_domain::{
AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName, Relationship,
AnalysisConfig, AnalysisWarning, CodeElement, CodeGraph, DomainError, ModuleName,
NormalizedGraph, Relationship,
ports::{FileDiscovery, SourceAnalyzer},
};
@@ -40,26 +41,22 @@ where
.par_iter()
.map(|file| match self.source_analyzer.analyze_file(file) {
Ok(result) => {
let module =
ModuleName::from_path(file.path().as_str(), root, config.module_mappings());
let assignment =
ModuleName::assign(file.path().as_str(), root, config.module_mappings());
let elements: Vec<CodeElement> = result
.elements()
.iter()
.map(|el| {
let mut el = el.clone();
if el.module().is_none()
&& let Some(ref m) = module
&& let Some(m) = assignment.module_name()
{
el = el.with_module(m.clone());
}
el
})
.collect();
(
elements,
result.relationships().to_vec(),
result.warnings().to_vec(),
)
(elements, result.relationships().to_vec(), result.warnings().to_vec())
}
Err(err) => {
let mut warnings = Vec::new();
@@ -73,14 +70,14 @@ where
})
.collect();
let mut graph = CodeGraph::new();
let mut raw = CodeGraph::new();
let mut warnings = Vec::new();
for (elements, relationships, warns) in file_results {
for el in elements {
graph.add_element(el);
raw.add_element(el);
}
for rel in relationships {
graph.add_relationship(rel);
raw.add_relationship(rel);
}
warnings.extend(warns);
}
@@ -94,22 +91,19 @@ where
.map(|s| s.to_lowercase())
.collect();
let graph = graph
.qualify()
.resolve_relationships()
.filter_external_imports(&known_dirs);
let graph = NormalizedGraph::from_analyzed(raw, &known_dirs)?;
Ok(AnalyzeCodebaseResult { graph, warnings })
}
}
pub struct AnalyzeCodebaseResult {
graph: CodeGraph,
graph: NormalizedGraph,
warnings: Vec<AnalysisWarning>,
}
impl AnalyzeCodebaseResult {
pub fn graph(&self) -> &CodeGraph {
pub fn graph(&self) -> &NormalizedGraph {
&self.graph
}

View File

@@ -0,0 +1,18 @@
use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};
/// Compares the current rendered output against an on-disk file.
/// Returns `Ok(true)` if up to date, `Ok(false)` if stale.
pub struct CheckFreshness<'a> {
pub graph: &'a NormalizedGraph,
pub renderer: &'a dyn DiagramRenderer,
pub existing_path: &'a std::path::Path,
}
impl<'a> CheckFreshness<'a> {
pub fn execute(&self) -> Result<bool, DomainError> {
let rendered = self.renderer.render(self.graph.as_graph())?;
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
let existing = std::fs::read_to_string(self.existing_path).unwrap_or_default();
Ok(current == existing)
}
}

View File

@@ -0,0 +1,46 @@
use archlens_domain::{DomainError, NormalizedGraph, ports::DiagramRenderer};
/// The semantic result of comparing two diagram snapshots.
pub struct DiffResult {
pub added: Vec<String>,
pub removed: Vec<String>,
}
impl DiffResult {
pub fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty()
}
}
/// Compares the rendered current graph against an existing diagram file and
/// returns which lines were added or removed.
pub struct DiffDiagram<'a> {
pub graph: &'a NormalizedGraph,
pub renderer: &'a dyn DiagramRenderer,
pub existing_path: &'a std::path::Path,
}
impl<'a> DiffDiagram<'a> {
pub fn execute(&self) -> Result<DiffResult, DomainError> {
let rendered = self.renderer.render(self.graph.as_graph())?;
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
let existing = std::fs::read_to_string(self.existing_path).unwrap_or_default();
let current_lines: std::collections::HashSet<&str> = current.lines().collect();
let existing_lines: std::collections::HashSet<&str> = existing.lines().collect();
let added: Vec<String> = current_lines
.difference(&existing_lines)
.filter(|l| !l.trim().is_empty())
.map(|l| format!("+ {l}"))
.collect();
let removed: Vec<String> = existing_lines
.difference(&current_lines)
.filter(|l| !l.trim().is_empty())
.map(|l| format!("- {l}"))
.collect();
Ok(DiffResult { added, removed })
}
}

View File

@@ -0,0 +1,125 @@
use std::path::PathBuf;
use archlens_domain::{
BoundaryRule, DomainError, NormalizedGraph, RenderedFile, RenderOutput,
check_boundary_rules,
ports::DiagramRenderer,
};
/// Result of running the generate use case — exposed violations and any output
/// that should be written to disk.
pub struct GenerateDiagramResult {
pub violations: Vec<String>,
pub output: RenderOutput,
}
/// Orchestrates diagram generation: renders the graph (split or single),
/// checks boundary rules, and returns the output for the caller to write.
pub struct GenerateDiagram {
pub graph: NormalizedGraph,
pub renderer: Box<dyn DiagramRenderer>,
pub allow_rules: Vec<BoundaryRule>,
pub deny_rules: Vec<BoundaryRule>,
pub split_by_module: bool,
pub format_ext: String,
pub output_dir: Option<PathBuf>,
}
impl GenerateDiagram {
pub fn execute(self) -> Result<(), DomainError> {
// Boundary rule checking
let violations = if !self.allow_rules.is_empty() || !self.deny_rules.is_empty() {
check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules)
} else {
Vec::new()
};
// Render and write
if self.split_by_module {
write_split(
&self.graph,
&*self.renderer,
&self.output_dir,
&self.format_ext,
)?;
} else {
let rendered = self.renderer.render(self.graph.as_graph())?;
write_to_output(rendered, &self.output_dir)?;
}
// Report violations (after writing so the diagram is still produced)
for v in &violations {
eprintln!("RULE VIOLATION: {}", v.message());
}
Ok(())
}
pub fn check_violations_only(&self) -> Vec<String> {
if self.allow_rules.is_empty() && self.deny_rules.is_empty() {
return Vec::new();
}
check_boundary_rules(self.graph.as_graph(), &self.allow_rules, &self.deny_rules)
.into_iter()
.map(|v| v.message())
.collect()
}
}
pub fn write_split(
graph: &NormalizedGraph,
renderer: &dyn DiagramRenderer,
output_dir: &Option<PathBuf>,
ext: &str,
) -> Result<(), DomainError> {
let dir = output_dir
.clone()
.unwrap_or_else(|| PathBuf::from("."));
let overview = renderer.render(graph.as_graph())?;
let overview_file = RenderedFile::new(
&format!("overview.{ext}"),
overview.files().first().map(|f| f.content()).unwrap_or(""),
)?;
write_file_to_dir(&dir, overview_file)?;
for module in graph.modules() {
let subgraph = graph.subgraph_by_module(&module);
let cross_deps = graph.cross_module_deps_for(&module);
let module_output = renderer.render_for_module(&subgraph, &module, &cross_deps)?;
let module_file = RenderedFile::new(
&format!("{}.{ext}", module.as_str().to_lowercase()),
module_output.files().first().map(|f| f.content()).unwrap_or(""),
)?;
write_file_to_dir(&dir, module_file)?;
}
Ok(())
}
fn write_file_to_dir(dir: &PathBuf, file: RenderedFile) -> Result<(), DomainError> {
let path = dir.join(file.name());
std::fs::create_dir_all(dir)
.map_err(|e| DomainError::IoError(e.to_string()))?;
std::fs::write(&path, file.content())
.map_err(|e| DomainError::IoError(e.to_string()))?;
Ok(())
}
fn write_to_output(rendered: RenderOutput, output: &Option<PathBuf>) -> Result<(), DomainError> {
let content = rendered.files().first().map(|f| f.content()).unwrap_or("");
match output {
Some(path) => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| DomainError::IoError(e.to_string()))?;
}
std::fs::write(path, content)
.map_err(|e| DomainError::IoError(e.to_string()))
}
None => {
print!("{content}");
Ok(())
}
}
}

View File

@@ -0,0 +1,3 @@
pub mod check_freshness;
pub mod diff_diagram;
pub mod generate_diagram;

View File

@@ -1,3 +1,5 @@
mod code_graph;
mod normalized_graph;
pub use code_graph::CodeGraph;
pub use normalized_graph::NormalizedGraph;

View File

@@ -0,0 +1,77 @@
use std::collections::{HashMap, HashSet};
use crate::{CodeElement, CodeGraph, DomainError, ModuleName, Relationship};
/// A `CodeGraph` that has been fully normalized: qualified, resolved, and
/// filtered. Only this type exposes module-level and split-by-module queries —
/// callers cannot call those on a raw `CodeGraph`, making incorrect pipeline
/// order a compile-time error rather than a silent bug.
#[derive(Debug, Clone)]
pub struct NormalizedGraph(CodeGraph);
impl NormalizedGraph {
/// Normalize a raw `CodeGraph` — qualifies type names, resolves
/// relationships, and filters external imports in one named operation.
pub fn from_analyzed(
graph: CodeGraph,
known_dirs: &HashSet<String>,
) -> Result<Self, DomainError> {
let normalized = graph
.qualify()
.resolve_relationships()
.filter_external_imports(known_dirs);
Ok(Self(normalized))
}
/// Wrap a project-level graph (from `CargoWorkspaceAnalyzer` or
/// `PythonProjectAnalyzer`) that is already ready to render — no
/// analysis pipeline needed.
pub fn from_project(graph: CodeGraph) -> Self {
Self(graph)
}
// ── Element access ───────────────────────────────────────────────────────
pub fn elements(&self) -> &[CodeElement] {
self.0.elements()
}
pub fn relationships(&self) -> &[Relationship] {
self.0.relationships()
}
pub fn modules(&self) -> Vec<ModuleName> {
self.0.modules()
}
pub fn elements_by_module(
&self,
) -> (HashMap<String, Vec<&CodeElement>>, Vec<&CodeElement>) {
self.0.elements_by_module()
}
// ── Module-level queries (only available on NormalizedGraph) ─────────────
pub fn module_edges(&self) -> HashMap<(String, String), usize> {
self.0.module_edges()
}
pub fn subgraph_by_module(&self, module: &ModuleName) -> CodeGraph {
self.0.subgraph_by_module(module)
}
pub fn cross_module_deps_for(&self, module: &ModuleName) -> Vec<(ModuleName, usize)> {
self.0.cross_module_deps_for(module)
}
// ── Mutation (merge project edges after normalization) ───────────────────
pub fn merge_project_edges(&mut self, project_graph: &CodeGraph) {
self.0.merge_project_edges(project_graph);
}
/// Expose the inner graph for renderers that accept `&CodeGraph`.
pub fn as_graph(&self) -> &CodeGraph {
&self.0
}
}

View File

@@ -5,7 +5,7 @@ pub mod entities;
pub mod ports;
pub mod value_objects;
pub use aggregates::CodeGraph;
pub use aggregates::{CodeGraph, NormalizedGraph};
pub use entities::{CodeElement, Relationship};
pub use error::DomainError;
pub use value_objects::analysis::{AnalysisConfig, AnalysisResult, AnalysisWarning};
@@ -13,5 +13,6 @@ pub use value_objects::graph::{CodeElementKind, RelationshipKind, Visibility};
pub use value_objects::output::{DiagramLevel, OutputConfig, RenderOutput, RenderedFile};
pub use value_objects::rules::{BoundaryRule, RuleKind, RuleViolation, check_boundary_rules};
pub use value_objects::source::{
FilePath, Language, ModuleName, SourceFile, normalize_cargo_package, normalize_python_package,
FilePath, Language, ModuleAssignment, ModuleName, SourceFile,
normalize_cargo_package, normalize_python_package,
};

View File

@@ -3,13 +3,19 @@ use crate::{CodeGraph, DomainError, ModuleName, RenderOutput};
pub trait DiagramRenderer {
fn render(&self, graph: &CodeGraph) -> Result<RenderOutput, DomainError>;
fn append_cross_module_deps(
/// Render a single module's subgraph for split-by-module output.
///
/// `cross_deps` is the list of (external module, relationship count) pairs
/// for dependencies this module has on other modules. The default
/// implementation ignores cross_deps and falls back to `render(subgraph)`.
/// Adapters that support per-module annotations (e.g. Mermaid) override
/// this to include cross-module dependency information in the output.
fn render_for_module(
&self,
content: &str,
module: &ModuleName,
deps: &[(ModuleName, usize)],
) -> String {
let _ = (module, deps);
content.to_string()
subgraph: &CodeGraph,
_module: &ModuleName,
_cross_deps: &[(ModuleName, usize)],
) -> Result<RenderOutput, DomainError> {
self.render(subgraph)
}
}

View File

@@ -5,7 +5,7 @@ mod source_file;
pub use file_path::FilePath;
pub use language::Language;
pub use module_name::ModuleName;
pub use module_name::{ModuleAssignment, ModuleName};
pub use source_file::SourceFile;
pub fn normalize_cargo_package(name: &str) -> String {

View File

@@ -6,6 +6,40 @@ use crate::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ModuleName(String);
/// How a module name was assigned to a source file.
#[derive(Debug, Clone)]
pub enum ModuleAssignment {
/// Matched an explicit user-configured mapping in `archlens.toml`.
Explicit(ModuleName),
/// Derived from file path structure by heuristic (e.g. `crates/<name>/...`).
Inferred(ModuleName),
/// No mapping matched and the heuristic could not determine a module.
Unresolved(&'static str),
}
impl ModuleAssignment {
pub fn module_name(&self) -> Option<&ModuleName> {
match self {
Self::Explicit(m) | Self::Inferred(m) => Some(m),
Self::Unresolved(_) => None,
}
}
pub fn into_module_name(self) -> Option<ModuleName> {
match self {
Self::Explicit(m) | Self::Inferred(m) => Some(m),
Self::Unresolved(_) => None,
}
}
pub fn reason(&self) -> Option<&'static str> {
match self {
Self::Unresolved(r) => Some(r),
_ => None,
}
}
}
impl ModuleName {
pub fn new(value: &str) -> Result<Self, DomainError> {
let trimmed = value.trim();
@@ -15,11 +49,13 @@ impl ModuleName {
Ok(Self(trimmed.to_string()))
}
pub fn from_path(
/// Assign a module to a file path, returning a typed assignment that
/// distinguishes explicit mappings, heuristic inference, and failure.
pub fn assign(
file_path: &str,
root: &Path,
module_mappings: &HashMap<String, String>,
) -> Option<Self> {
) -> ModuleAssignment {
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let root_str = canonical_root.to_str().unwrap_or("");
let relative = file_path
@@ -27,15 +63,19 @@ impl ModuleName {
.unwrap_or(file_path)
.trim_start_matches('/');
// 1. Explicit mapping
for (pattern, module_name) in module_mappings {
if relative.starts_with(pattern.as_str()) {
return Self::new(module_name).ok();
if relative.starts_with(pattern.as_str())
&& let Ok(m) = Self::new(module_name)
{
return ModuleAssignment::Explicit(m);
}
}
// 2. Heuristic inference from path structure
let parts: Vec<&str> = relative.split('/').collect();
if parts.len() <= 1 {
return None;
return ModuleAssignment::Unresolved("path has no directory component");
}
let module_dir = if (parts[0] == "crates" || parts[0] == "src") && parts.len() > 2 {
@@ -43,10 +83,23 @@ impl ModuleName {
} else if parts[0] != "src" && parts.len() > 1 {
parts[0]
} else {
return None;
return ModuleAssignment::Unresolved("path under src/ with no further structure");
};
Self::new(&Self::capitalize(module_dir)).ok()
match Self::new(&Self::capitalize(module_dir)) {
Ok(m) => ModuleAssignment::Inferred(m),
Err(_) => ModuleAssignment::Unresolved("inferred directory name was empty"),
}
}
/// Convenience wrapper — returns None for Unresolved.
/// Prefer `assign()` when you want to distinguish strategies.
pub fn from_path(
file_path: &str,
root: &Path,
module_mappings: &HashMap<String, String>,
) -> Option<Self> {
Self::assign(file_path, root, module_mappings).into_module_name()
}
pub fn from_directory_group(member_path: &str) -> Option<Self> {

View File

@@ -1,28 +1,28 @@
mod cli;
use std::path::PathBuf;
use anyhow::{Result, bail};
use archlens_application::queries::AnalyzeCodebase;
use archlens_application::use_cases::{
check_freshness::CheckFreshness,
diff_diagram::DiffDiagram,
generate_diagram::{GenerateDiagram, write_split},
};
use archlens_ascii::AsciiRenderer;
use archlens_cargo_workspace::CargoWorkspaceAnalyzer;
use archlens_d2::D2Renderer;
use archlens_domain::{
BoundaryRule, CodeGraph, DiagramLevel, check_boundary_rules,
ports::{ConfigLoader, OutputWriter, ProjectAnalyzer},
BoundaryRule, DiagramLevel, NormalizedGraph,
ports::{ConfigLoader, ProjectAnalyzer},
};
use archlens_file_writer::FileOutputWriter;
use archlens_html::HtmlRenderer;
use archlens_mermaid::MermaidRenderer;
use archlens_python_project::PythonProjectAnalyzer;
use archlens_stdout_writer::StdoutOutputWriter;
use archlens_toml_config::TomlConfigLoader;
use archlens_tree_sitter::TreeSitterAnalyzer;
use archlens_walkdir::WalkdirDiscovery;
pub use cli::{Cli, Command};
pub type CliArgs = Cli;
pub fn run(args: Cli) -> Result<()> {
@@ -41,40 +41,44 @@ pub fn run(args: Cli) -> Result<()> {
let config_loader = load_config(&args)?;
let graph = build_graph(&args, level)?;
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
let ext = format_extension(&args.format);
if args.check {
return check_freshness(&args.output, &graph, &*renderer);
}
// Boundary rule checking
let (raw_allow, raw_deny) = config_loader.load_rules();
let allow: Vec<BoundaryRule> = raw_allow
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
let deny: Vec<BoundaryRule> = raw_deny
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
if !allow.is_empty() || !deny.is_empty() {
let violations = check_boundary_rules(&graph, &allow, &deny);
for v in &violations {
eprintln!("RULE VIOLATION: {}", v.message());
}
if args.strict && !violations.is_empty() {
bail!(
"{} boundary rule violation(s) in strict mode",
violations.len()
);
}
}
if args.split_by_module {
write_split(&graph, &*renderer, &args.output, ext)?;
let existing_path = args.output.as_ref()
.ok_or_else(|| anyhow::anyhow!("--check requires --output to specify the file to check against"))?;
let up_to_date = CheckFreshness {
graph: &graph,
renderer: &*renderer,
existing_path: std::path::Path::new(existing_path),
}.execute()?;
if up_to_date {
println!("Architecture diagram is up to date.");
} else {
write_single(&graph, &*renderer, &args.output)?;
eprintln!("Architecture diagram is outdated: {existing_path}");
std::process::exit(1);
}
return Ok(());
}
let (raw_allow, raw_deny) = config_loader.load_rules();
let allow: Vec<BoundaryRule> = raw_allow.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
let deny: Vec<BoundaryRule> = raw_deny.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
let output_dir = args.output.as_ref().map(std::path::PathBuf::from);
let use_case = GenerateDiagram {
graph,
renderer,
allow_rules: allow,
deny_rules: deny,
split_by_module: args.split_by_module,
format_ext: format_extension(&args.format).to_string(),
output_dir,
};
let violations = use_case.check_violations_only();
if args.strict && !violations.is_empty() {
bail!("{} boundary rule violation(s) in strict mode", violations.len());
}
use_case.execute()?;
Ok(())
}
@@ -93,7 +97,7 @@ fn load_config(args: &Cli) -> Result<TomlConfigLoader> {
}
}
fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
fn build_graph(args: &Cli, level: DiagramLevel) -> Result<NormalizedGraph> {
let config_loader = load_config(args)?;
let mut analysis_config = config_loader.load_analysis_config()?;
analysis_config = analysis_config.with_level(level);
@@ -116,10 +120,12 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
if level == DiagramLevel::Project {
let cargo_toml = args.path.join("Cargo.toml");
if cargo_toml.exists() {
return Ok(CargoWorkspaceAnalyzer::new().analyze(&args.path)?);
}
return Ok(PythonProjectAnalyzer::new().analyze(&args.path)?);
let project_graph = if cargo_toml.exists() {
CargoWorkspaceAnalyzer::new().analyze(&args.path)?
} else {
PythonProjectAnalyzer::new().analyze(&args.path)?
};
return Ok(NormalizedGraph::from_project(project_graph));
}
let discovery = WalkdirDiscovery::new();
@@ -129,18 +135,10 @@ fn build_graph(args: &Cli, level: DiagramLevel) -> Result<CodeGraph> {
if !result.warnings().is_empty() {
for warning in result.warnings() {
eprintln!(
"WARNING: {}:{} {}",
warning.file_path().as_str(),
warning.line(),
warning.message()
);
eprintln!("WARNING: {}:{} {}", warning.file_path().as_str(), warning.line(), warning.message());
}
if args.strict {
bail!(
"analysis produced {} warning(s) in strict mode",
result.warnings().len()
);
bail!("analysis produced {} warning(s) in strict mode", result.warnings().len());
}
}
@@ -167,9 +165,7 @@ fn create_renderer(
show_weights: bool,
) -> Result<Box<dyn archlens_domain::ports::DiagramRenderer>> {
match format {
"mermaid" => Ok(Box::new(
MermaidRenderer::with_level(level).with_weights(show_weights),
)),
"mermaid" => Ok(Box::new(MermaidRenderer::with_level(level).with_weights(show_weights))),
"ascii" => Ok(Box::new(AsciiRenderer::new())),
"d2" => Ok(Box::new(D2Renderer::with_level(level))),
"html" => Ok(Box::new(HtmlRenderer::new())),
@@ -186,85 +182,6 @@ fn format_extension(format: &str) -> &str {
}
}
fn check_freshness(
output: &Option<String>,
graph: &CodeGraph,
renderer: &dyn archlens_domain::ports::DiagramRenderer,
) -> Result<()> {
let Some(path) = output else {
bail!("--check requires --output to specify the file to check against");
};
let rendered = renderer.render(graph)?;
let current = rendered.files().first().map(|f| f.content()).unwrap_or("");
let existing = std::fs::read_to_string(path).unwrap_or_default();
if current != existing {
eprintln!("Architecture diagram is outdated: {path}");
std::process::exit(1);
}
println!("Architecture diagram is up to date.");
Ok(())
}
fn write_split(
graph: &CodeGraph,
renderer: &dyn archlens_domain::ports::DiagramRenderer,
output: &Option<String>,
ext: &str,
) -> Result<()> {
let output_dir = output
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let writer = FileOutputWriter::new(output_dir);
let overview = renderer.render(graph)?;
let overview_file = archlens_domain::RenderedFile::new(
&format!("overview.{ext}"),
overview.files().first().map(|f| f.content()).unwrap_or(""),
)?;
writer.write(&archlens_domain::RenderOutput::single(overview_file))?;
for module in graph.modules() {
let subgraph = graph.subgraph_by_module(&module);
let cross_deps = graph.cross_module_deps_for(&module);
let module_output = renderer.render(&subgraph)?;
let raw = module_output
.files()
.first()
.map(|f| f.content())
.unwrap_or("");
let content = renderer.append_cross_module_deps(raw, &module, &cross_deps);
let module_file = archlens_domain::RenderedFile::new(
&format!("{}.{ext}", module.as_str().to_lowercase()),
&content,
)?;
writer.write(&archlens_domain::RenderOutput::single(module_file))?;
}
Ok(())
}
fn write_single(
graph: &CodeGraph,
renderer: &dyn archlens_domain::ports::DiagramRenderer,
output: &Option<String>,
) -> Result<()> {
let rendered = renderer.render(graph)?;
match output {
Some(path) => {
let writer = FileOutputWriter::single_file(PathBuf::from(path));
writer.write(&rendered)?;
}
None => {
let writer = StdoutOutputWriter::new();
writer.write(&rendered)?;
}
}
Ok(())
}
fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
init_tracing(args.verbose);
@@ -272,47 +189,24 @@ fn run_diff(args: &Cli, existing_path: &std::path::Path) -> Result<()> {
let graph = build_graph(args, level)?;
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
let output = renderer.render(&graph)?;
let current = output.files().first().map(|f| f.content()).unwrap_or("");
let diff = DiffDiagram {
graph: &graph,
renderer: &*renderer,
existing_path,
}.execute()?;
let existing = std::fs::read_to_string(existing_path).unwrap_or_default();
if current == existing {
if diff.is_empty() {
println!("No changes detected.");
return Ok(());
}
let current_lines: Vec<&str> = current.lines().collect();
let existing_lines: Vec<&str> = existing.lines().collect();
let mut added = Vec::new();
let mut removed = Vec::new();
for line in &current_lines {
if !existing_lines.contains(line) {
added.push(*line);
for line in &diff.removed {
println!("{line}");
}
for line in &diff.added {
println!("{line}");
}
for line in &existing_lines {
if !current_lines.contains(line) {
removed.push(*line);
}
}
if !removed.is_empty() {
println!("Removed:");
for line in &removed {
println!(" - {line}");
}
}
if !added.is_empty() {
println!("Added:");
for line in &added {
println!(" + {line}");
}
}
println!("\n{} added, {} removed", added.len(), removed.len());
println!("\n{} added, {} removed", diff.added.len(), diff.removed.len());
std::process::exit(1);
}
@@ -372,7 +266,6 @@ fn init_tracing(verbosity: u8) {
2 => "debug",
_ => "trace",
};
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
@@ -393,10 +286,7 @@ fn get_changed_files(
.map_err(|e| anyhow::anyhow!("git not found: {e}"))?;
if !output.status.success() {
bail!(
"git diff failed: {}",
String::from_utf8_lossy(&output.stderr)
);
bail!("git diff failed: {}", String::from_utf8_lossy(&output.stderr));
}
let files = String::from_utf8_lossy(&output.stdout)
@@ -420,33 +310,40 @@ fn run_watch(args: Cli) -> Result<()> {
let config_loader = load_config(args)?;
let graph = build_graph(args, level)?;
let renderer = create_renderer(&args.format, level, !args.no_weights)?;
let output_dir = args.output.as_ref().map(std::path::PathBuf::from);
if args.split_by_module {
write_split(&graph, &*renderer, &args.output, ext)?;
write_split(&graph, &*renderer, &output_dir, ext)?;
} else {
write_single(&graph, &*renderer, &args.output)?;
let rendered = renderer.render(graph.as_graph())?;
let content = rendered.files().first().map(|f| f.content()).unwrap_or("");
match &output_dir {
Some(path) => std::fs::write(path, content)?,
None => print!("{content}"),
}
}
let (raw_allow, raw_deny) = config_loader.load_rules();
let allow: Vec<BoundaryRule> = raw_allow
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
let deny: Vec<BoundaryRule> = raw_deny
.iter()
.filter_map(|s| BoundaryRule::parse(s))
.collect();
let allow: Vec<BoundaryRule> = raw_allow.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
let deny: Vec<BoundaryRule> = raw_deny.iter().filter_map(|s| BoundaryRule::parse(s)).collect();
if !allow.is_empty() || !deny.is_empty() {
let violations = check_boundary_rules(&graph, &allow, &deny);
for v in &violations {
eprintln!("RULE VIOLATION: {}", v.message());
let use_case = GenerateDiagram {
graph,
renderer,
allow_rules: allow,
deny_rules: deny,
split_by_module: false,
format_ext: ext.to_string(),
output_dir: None,
};
for v in use_case.check_violations_only() {
eprintln!("RULE VIOLATION: {v}");
}
}
Ok(())
};
eprintln!(
"Watching {} for changes (Ctrl+C to stop)...",
args.path.display()
);
eprintln!("Watching {} for changes (Ctrl+C to stop)...", args.path.display());
if let Err(e) = run_once(&args) {
eprintln!("Error: {e}");
} else {
@@ -454,18 +351,14 @@ fn run_watch(args: Cli) -> Result<()> {
}
let (tx, rx) = mpsc::channel();
let mut watcher = recommended_watcher(move |res| {
let _ = tx.send(res);
})?;
let mut watcher = recommended_watcher(move |res| { let _ = tx.send(res); })?;
watcher.watch(&args.path, RecursiveMode::Recursive)?;
let mut last_run = Instant::now();
loop {
match rx.recv() {
Ok(_) => {
if last_run.elapsed() < debounce {
continue;
}
if last_run.elapsed() < debounce { continue; }
last_run = Instant::now();
eprintln!("Change detected, regenerating...");
if let Err(e) = run_once(&args) {
@@ -474,10 +367,7 @@ fn run_watch(args: Cli) -> Result<()> {
eprintln!("Diagram updated.");
}
}
Err(e) => {
eprintln!("Watch error: {e}");
break;
}
Err(e) => { eprintln!("Watch error: {e}"); break; }
}
}
Ok(())