refactor: five architectural deepening improvements
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:
@@ -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);
|
||||
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 {
|
||||
eprintln!("Architecture diagram is outdated: {existing_path}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
if args.split_by_module {
|
||||
write_split(&graph, &*renderer, &args.output, ext)?;
|
||||
} else {
|
||||
write_single(&graph, &*renderer, &args.output)?;
|
||||
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 ¤t_lines {
|
||||
if !existing_lines.contains(line) {
|
||||
added.push(*line);
|
||||
}
|
||||
for line in &diff.removed {
|
||||
println!("{line}");
|
||||
}
|
||||
for line in &existing_lines {
|
||||
if !current_lines.contains(line) {
|
||||
removed.push(*line);
|
||||
}
|
||||
for line in &diff.added {
|
||||
println!("{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(())
|
||||
|
||||
Reference in New Issue
Block a user