refactor: decompose CLI into commands, fix clippy, improve error handling

- Decompose main.rs into commands/ modules (generate, init, check, stats)
- Fix sanitize_filename to use safe replacements
- Compute Python module paths from src_roots instead of file paths
- Add stats command, colored output, progress bar, and generation summary
- Resolve all clippy warnings (redundant closures, collapsible ifs, etc.)
- Replace last unwrap() with proper error handling
- Add target/ to .gitignore, remove target/ artifacts from git tracking
This commit is contained in:
2026-02-15 03:21:36 +03:00
parent 736909ac3d
commit 9f823d2a2a
16 changed files with 626 additions and 3400 deletions

View File

@@ -0,0 +1,28 @@
use anyhow::Result;
use archdoc_core::Config;
use colored::Colorize;
use super::generate::analyze_project;
pub fn check_docs_consistency(root: &str, config: &Config) -> Result<()> {
println!("{}", "Checking documentation consistency...".cyan());
let model = analyze_project(root, config)?;
let renderer = archdoc_core::renderer::Renderer::new();
let _generated = renderer.render_architecture_md(&model)?;
let architecture_md_path = std::path::Path::new(root).join(&config.project.entry_file);
if !architecture_md_path.exists() {
println!("{} {} does not exist", "".red().bold(), architecture_md_path.display());
return Err(anyhow::anyhow!("Documentation file does not exist"));
}
let existing = std::fs::read_to_string(&architecture_md_path)?;
println!("{} Documentation is parseable and consistent", "".green().bold());
println!(" Generated content: {} chars", _generated.len());
println!(" Existing content: {} chars", existing.len());
Ok(())
}

View File

@@ -0,0 +1,179 @@
use anyhow::Result;
use archdoc_core::{Config, ProjectModel, scanner::FileScanner, python_analyzer::PythonAnalyzer};
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use std::path::Path;
use crate::output::sanitize_filename;
pub fn load_config(config_path: &str) -> Result<Config> {
Config::load_from_file(Path::new(config_path))
.map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))
}
pub fn analyze_project(root: &str, config: &Config) -> Result<ProjectModel> {
println!("{}", "Scanning project...".cyan());
let scanner = FileScanner::new(config.clone());
let python_files = scanner.scan_python_files(std::path::Path::new(root))?;
println!(" Found {} Python files", python_files.len().to_string().yellow());
let analyzer = PythonAnalyzer::new(config.clone());
let pb = ProgressBar::new(python_files.len() as u64);
pb.set_style(ProgressStyle::default_bar()
.template(" {spinner:.green} [{bar:30.cyan/dim}] {pos}/{len} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_bar())
.progress_chars("█▓░"));
let mut parsed_modules = Vec::new();
let mut parse_errors = 0;
for file_path in &python_files {
pb.set_message(file_path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default());
match analyzer.parse_module(file_path) {
Ok(module) => parsed_modules.push(module),
Err(e) => {
parse_errors += 1;
pb.println(format!(" {} Failed to parse {}: {}", "".yellow(), file_path.display(), e));
}
}
pb.inc(1);
}
pb.finish_and_clear();
if parse_errors > 0 {
println!(" {} {} file(s) had parse errors", "".yellow(), parse_errors);
}
println!("{}", "Resolving symbols...".cyan());
let model = analyzer.resolve_symbols(&parsed_modules)
.map_err(|e| anyhow::anyhow!("Failed to resolve symbols: {}", e))?;
Ok(model)
}
pub fn generate_docs(model: &ProjectModel, out: &str, verbose: bool) -> Result<()> {
println!("{}", "Generating documentation...".cyan());
let out_path = std::path::Path::new(out);
std::fs::create_dir_all(out_path)?;
let modules_path = out_path.join("modules");
let files_path = out_path.join("files");
std::fs::create_dir_all(&modules_path)?;
std::fs::create_dir_all(&files_path)?;
let renderer = archdoc_core::renderer::Renderer::new();
let writer = archdoc_core::writer::DiffAwareWriter::new();
let output_path = std::path::Path::new(".").join("ARCHITECTURE.md");
// Generate module docs
for module_id in model.modules.keys() {
let module_doc_path = modules_path.join(format!("{}.md", sanitize_filename(module_id)));
match renderer.render_module_md(model, module_id) {
Ok(module_content) => {
std::fs::write(&module_doc_path, module_content)?;
}
Err(e) => {
eprintln!(" {} Module {}: {}", "".yellow(), module_id, e);
let fallback = format!("# Module: {}\n\nTODO: Add module documentation\n", module_id);
std::fs::write(&module_doc_path, fallback)?;
}
}
}
// Generate file docs
for file_doc in model.files.values() {
let file_doc_path = files_path.join(format!("{}.md", sanitize_filename(&file_doc.path)));
let mut file_content = format!("# File: {}\n\n", file_doc.path);
file_content.push_str(&format!("- **Module:** {}\n", file_doc.module_id));
file_content.push_str(&format!("- **Defined symbols:** {}\n", file_doc.symbols.len()));
file_content.push_str(&format!("- **Imports:** {}\n\n", file_doc.imports.len()));
file_content.push_str("<!-- MANUAL:BEGIN -->\n## File intent (manual)\n<FILL_MANUALLY>\n<!-- MANUAL:END -->\n\n---\n\n");
file_content.push_str("## Imports & file-level dependencies\n<!-- ARCHDOC:BEGIN section=file_imports -->\n> Generated. Do not edit inside this block.\n");
for import in &file_doc.imports {
file_content.push_str(&format!("- {}\n", import));
}
file_content.push_str("<!-- ARCHDOC:END section=file_imports -->\n\n---\n\n");
file_content.push_str("## Symbols index\n<!-- ARCHDOC:BEGIN section=symbols_index -->\n> Generated. Do not edit inside this block.\n");
for symbol_id in &file_doc.symbols {
if let Some(symbol) = model.symbols.get(symbol_id) {
file_content.push_str(&format!("- `{}` ({:?})\n", symbol.qualname, symbol.kind));
}
}
file_content.push_str("<!-- ARCHDOC:END section=symbols_index -->\n\n---\n\n");
file_content.push_str("## Symbol details\n");
for symbol_id in &file_doc.symbols {
if model.symbols.contains_key(symbol_id) {
file_content.push_str(&format!("\n<!-- ARCHDOC:BEGIN symbol id={} -->\n", symbol_id));
file_content.push_str("<!-- AUTOGENERATED SYMBOL CONTENT WILL BE INSERTED HERE -->\n");
file_content.push_str(&format!("<!-- ARCHDOC:END symbol id={} -->\n", symbol_id));
}
}
std::fs::write(&file_doc_path, &file_content)?;
for symbol_id in &file_doc.symbols {
if model.symbols.contains_key(symbol_id) {
match renderer.render_symbol_details(model, symbol_id) {
Ok(content) => {
if verbose {
println!(" Updating symbol section for {}", symbol_id);
}
if let Err(e) = writer.update_symbol_section(&file_doc_path, symbol_id, &content) {
eprintln!(" {} Symbol {}: {}", "".yellow(), symbol_id, e);
}
}
Err(e) => {
eprintln!(" {} Symbol {}: {}", "".yellow(), symbol_id, e);
}
}
}
}
}
// Update ARCHITECTURE.md sections
let sections = [
("integrations", renderer.render_integrations_section(model)),
("rails", renderer.render_rails_section(model)),
("layout", renderer.render_layout_section(model)),
("modules_index", renderer.render_modules_index_section(model)),
("critical_points", renderer.render_critical_points_section(model)),
];
for (name, result) in sections {
match result {
Ok(content) => {
if let Err(e) = writer.update_file_with_markers(&output_path, &content, name)
&& verbose {
eprintln!(" {} Section {}: {}", "".yellow(), name, e);
}
}
Err(e) => {
if verbose {
eprintln!(" {} Section {}: {}", "".yellow(), name, e);
}
}
}
}
// Update layout.md
let layout_md_path = out_path.join("layout.md");
if let Ok(content) = renderer.render_layout_md(model) {
let _ = std::fs::write(&layout_md_path, &content);
}
println!("{} Documentation generated in {}", "".green().bold(), out);
Ok(())
}

View File

@@ -0,0 +1,168 @@
use anyhow::Result;
use colored::Colorize;
pub fn init_project(root: &str, out: &str) -> Result<()> {
println!("{}", "Initializing archdoc project...".cyan().bold());
let out_path = std::path::Path::new(out);
std::fs::create_dir_all(out_path)?;
std::fs::create_dir_all(out_path.join("modules"))?;
std::fs::create_dir_all(out_path.join("files"))?;
let layout_md_path = out_path.join("layout.md");
let layout_md_content = r#"# Repository layout
<!-- MANUAL:BEGIN -->
## Manual overrides
- `src/app/` — <FILL_MANUALLY>
<!-- MANUAL:END -->
---
## Detected structure
<!-- ARCHDOC:BEGIN section=layout_detected -->
> Generated. Do not edit inside this block.
<!-- ARCHDOC:END section=layout_detected -->
"#;
std::fs::write(&layout_md_path, layout_md_content)?;
let architecture_md_content = r#"# ARCHITECTURE — <PROJECT_NAME>
<!-- MANUAL:BEGIN -->
## Project summary
**Name:** <PROJECT_NAME>
**Description:** <FILL_MANUALLY: what this project does in 37 lines>
## Key decisions (manual)
- <FILL_MANUALLY>
## Non-goals (manual)
- <FILL_MANUALLY>
<!-- MANUAL:END -->
---
## Document metadata
- **Created:** <AUTO_ON_INIT: YYYY-MM-DD>
- **Updated:** <AUTO_ON_CHANGE: YYYY-MM-DD>
- **Generated by:** archdoc (cli) v0.1
---
## Rails / Tooling
<!-- ARCHDOC:BEGIN section=rails -->
> Generated. Do not edit inside this block.
<AUTO: rails summary + links to config files>
<!-- ARCHDOC:END section=rails -->
---
## Repository layout (top-level)
<!-- ARCHDOC:BEGIN section=layout -->
> Generated. Do not edit inside this block.
<AUTO: table of top-level folders + heuristic purpose + link to layout.md>
<!-- ARCHDOC:END section=layout -->
---
## Modules index
<!-- ARCHDOC:BEGIN section=modules_index -->
> Generated. Do not edit inside this block.
<AUTO: table modules + deps counts + links to module docs>
<!-- ARCHDOC:END section=modules_index -->
---
## Critical dependency points
<!-- ARCHDOC:BEGIN section=critical_points -->
> Generated. Do not edit inside this block.
<AUTO: top fan-in/out symbols + cycles>
<!-- ARCHDOC:END section=critical_points -->
---
<!-- MANUAL:BEGIN -->
## Change notes (manual)
- <FILL_MANUALLY>
<!-- MANUAL:END -->
"#;
let architecture_md_path = std::path::Path::new(root).join("ARCHITECTURE.md");
std::fs::write(&architecture_md_path, architecture_md_content)?;
let config_toml_content = r#"[project]
root = "."
out_dir = "docs/architecture"
entry_file = "ARCHITECTURE.md"
language = "python"
[scan]
include = ["src", "app", "tests"]
exclude = [
".venv", "venv", "__pycache__", ".git", "dist", "build",
".mypy_cache", ".ruff_cache", ".pytest_cache", "*.egg-info"
]
follow_symlinks = false
max_file_size = "10MB"
[python]
src_roots = ["src", "."]
include_tests = true
parse_docstrings = true
max_parse_errors = 10
[analysis]
resolve_calls = true
resolve_inheritance = false
detect_integrations = true
integration_patterns = [
{ type = "http", patterns = ["requests", "httpx", "aiohttp"] },
{ type = "db", patterns = ["sqlalchemy", "psycopg", "mysql", "sqlite3"] },
{ type = "queue", patterns = ["celery", "kafka", "pika", "redis"] }
]
[output]
single_file = false
per_file_docs = true
create_directories = true
overwrite_manual_sections = false
[diff]
update_timestamp_on_change_only = true
hash_algorithm = "sha256"
preserve_manual_content = true
[thresholds]
critical_fan_in = 20
critical_fan_out = 20
high_complexity = 50
[rendering]
template_engine = "handlebars"
max_table_rows = 100
truncate_long_descriptions = true
description_max_length = 200
[logging]
level = "info"
file = "archdoc.log"
format = "compact"
[caching]
enabled = true
cache_dir = ".archdoc/cache"
max_cache_age = "24h"
"#;
let config_toml_path = std::path::Path::new(root).join("archdoc.toml");
if !config_toml_path.exists() {
std::fs::write(&config_toml_path, config_toml_content)?;
}
println!("{} Project initialized!", "".green().bold());
println!(" {} {}", "".dimmed(), architecture_md_path.display());
println!(" {} {}", "".dimmed(), config_toml_path.display());
println!(" {} {} (directory)", "".dimmed(), out_path.display());
Ok(())
}

View File

@@ -0,0 +1,4 @@
pub mod init;
pub mod generate;
pub mod check;
pub mod stats;

View File

@@ -0,0 +1,97 @@
use archdoc_core::ProjectModel;
use colored::Colorize;
pub fn print_stats(model: &ProjectModel) {
println!();
println!("{}", "╔══════════════════════════════════════╗".cyan());
println!("{}", "║ archdoc project statistics ║".cyan().bold());
println!("{}", "╚══════════════════════════════════════╝".cyan());
println!();
// Basic counts
println!("{}", "Overview".bold().underline());
println!(" Files: {}", model.files.len().to_string().yellow());
println!(" Modules: {}", model.modules.len().to_string().yellow());
println!(" Symbols: {}", model.symbols.len().to_string().yellow());
println!(" Import edges: {}", model.edges.module_import_edges.len());
println!(" Call edges: {}", model.edges.symbol_call_edges.len());
println!();
// Symbol kinds
let mut functions = 0;
let mut methods = 0;
let mut classes = 0;
let mut async_functions = 0;
for symbol in model.symbols.values() {
match symbol.kind {
archdoc_core::model::SymbolKind::Function => functions += 1,
archdoc_core::model::SymbolKind::Method => methods += 1,
archdoc_core::model::SymbolKind::Class => classes += 1,
archdoc_core::model::SymbolKind::AsyncFunction => async_functions += 1,
}
}
println!("{}", "Symbol breakdown".bold().underline());
println!(" Classes: {}", classes);
println!(" Functions: {}", functions);
println!(" Async functions: {}", async_functions);
println!(" Methods: {}", methods);
println!();
// Top fan-in
let mut symbols_by_fan_in: Vec<_> = model.symbols.values().collect();
symbols_by_fan_in.sort_by(|a, b| b.metrics.fan_in.cmp(&a.metrics.fan_in));
println!("{}", "Top-10 by fan-in (most called)".bold().underline());
for (i, sym) in symbols_by_fan_in.iter().take(10).enumerate() {
if sym.metrics.fan_in == 0 { break; }
let critical = if sym.metrics.is_critical { " ⚠ CRITICAL".red().to_string() } else { String::new() };
println!(" {}. {} (fan-in: {}){}", i + 1, sym.qualname.green(), sym.metrics.fan_in, critical);
}
println!();
// Top fan-out
let mut symbols_by_fan_out: Vec<_> = model.symbols.values().collect();
symbols_by_fan_out.sort_by(|a, b| b.metrics.fan_out.cmp(&a.metrics.fan_out));
println!("{}", "Top-10 by fan-out (calls many)".bold().underline());
for (i, sym) in symbols_by_fan_out.iter().take(10).enumerate() {
if sym.metrics.fan_out == 0 { break; }
let critical = if sym.metrics.is_critical { " ⚠ CRITICAL".red().to_string() } else { String::new() };
println!(" {}. {} (fan-out: {}){}", i + 1, sym.qualname.green(), sym.metrics.fan_out, critical);
}
println!();
// Integrations
let http_symbols: Vec<_> = model.symbols.values().filter(|s| s.integrations_flags.http).collect();
let db_symbols: Vec<_> = model.symbols.values().filter(|s| s.integrations_flags.db).collect();
let queue_symbols: Vec<_> = model.symbols.values().filter(|s| s.integrations_flags.queue).collect();
if !http_symbols.is_empty() || !db_symbols.is_empty() || !queue_symbols.is_empty() {
println!("{}", "Detected integrations".bold().underline());
if !http_symbols.is_empty() {
println!(" {} HTTP: {}", "".yellow(), http_symbols.iter().map(|s| s.qualname.as_str()).collect::<Vec<_>>().join(", "));
}
if !db_symbols.is_empty() {
println!(" {} DB: {}", "".blue(), db_symbols.iter().map(|s| s.qualname.as_str()).collect::<Vec<_>>().join(", "));
}
if !queue_symbols.is_empty() {
println!(" {} Queue: {}", "".magenta(), queue_symbols.iter().map(|s| s.qualname.as_str()).collect::<Vec<_>>().join(", "));
}
println!();
}
// Cycles
println!("{}", "Cycle detection".bold().underline());
let mut found_cycles = false;
for edge in &model.edges.module_import_edges {
let has_reverse = model.edges.module_import_edges.iter()
.any(|e| e.from_id == edge.to_id && e.to_id == edge.from_id);
if has_reverse && edge.from_id < edge.to_id {
println!(" {} {}{}", "".red(), edge.from_id, edge.to_id);
found_cycles = true;
}
}
if !found_cycles {
println!(" {} No cycles detected", "".green());
}
}

View File

@@ -1,9 +1,8 @@
mod commands;
mod output;
use clap::{Parser, Subcommand};
use anyhow::Result;
use archdoc_core::{Config, ProjectModel, scanner::FileScanner, python_analyzer::PythonAnalyzer};
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use std::path::Path;
#[derive(Parser)]
#[command(name = "archdoc")]
@@ -12,7 +11,7 @@ use std::path::Path;
pub struct Cli {
#[command(subcommand)]
command: Commands,
/// Verbose output
#[arg(short, long, global = true)]
verbose: bool,
@@ -22,48 +21,31 @@ pub struct Cli {
enum Commands {
/// Initialize archdoc in the project
Init {
/// Project root directory
#[arg(short, long, default_value = ".")]
root: String,
/// Output directory for documentation
#[arg(short, long, default_value = "docs/architecture")]
out: String,
},
/// Generate or update documentation
Generate {
/// Project root directory
#[arg(short, long, default_value = ".")]
root: String,
/// Output directory for documentation
#[arg(short, long, default_value = "docs/architecture")]
out: String,
/// Configuration file path
#[arg(short, long, default_value = "archdoc.toml")]
config: String,
},
/// Check if documentation is up to date
Check {
/// Project root directory
#[arg(short, long, default_value = ".")]
root: String,
/// Configuration file path
#[arg(short, long, default_value = "archdoc.toml")]
config: String,
},
/// Show project statistics
Stats {
/// Project root directory
#[arg(short, long, default_value = ".")]
root: String,
/// Configuration file path
#[arg(short, long, default_value = "archdoc.toml")]
config: String,
},
@@ -71,517 +53,27 @@ enum Commands {
fn main() -> Result<()> {
let cli = Cli::parse();
match &cli.command {
Commands::Init { root, out } => {
init_project(root, out)?;
commands::init::init_project(root, out)?;
}
Commands::Generate { root, out, config } => {
let config = load_config(config)?;
let model = analyze_project(root, &config)?;
generate_docs(&model, out, cli.verbose)?;
print_generate_summary(&model);
let config = commands::generate::load_config(config)?;
let model = commands::generate::analyze_project(root, &config)?;
commands::generate::generate_docs(&model, out, cli.verbose)?;
output::print_generate_summary(&model);
}
Commands::Check { root, config } => {
let config = load_config(config)?;
check_docs_consistency(root, &config)?;
let config = commands::generate::load_config(config)?;
commands::check::check_docs_consistency(root, &config)?;
}
Commands::Stats { root, config } => {
let config = load_config(config)?;
let model = analyze_project(root, &config)?;
print_stats(&model);
let config = commands::generate::load_config(config)?;
let model = commands::generate::analyze_project(root, &config)?;
commands::stats::print_stats(&model);
}
}
Ok(())
}
fn init_project(root: &str, out: &str) -> Result<()> {
println!("{}", "Initializing archdoc project...".cyan().bold());
let out_path = std::path::Path::new(out);
std::fs::create_dir_all(out_path)?;
std::fs::create_dir_all(out_path.join("modules"))?;
std::fs::create_dir_all(out_path.join("files"))?;
let layout_md_path = out_path.join("layout.md");
let layout_md_content = r#"# Repository layout
<!-- MANUAL:BEGIN -->
## Manual overrides
- `src/app/` — <FILL_MANUALLY>
<!-- MANUAL:END -->
---
## Detected structure
<!-- ARCHDOC:BEGIN section=layout_detected -->
> Generated. Do not edit inside this block.
<!-- ARCHDOC:END section=layout_detected -->
"#;
std::fs::write(&layout_md_path, layout_md_content)?;
let architecture_md_content = r#"# ARCHITECTURE — <PROJECT_NAME>
<!-- MANUAL:BEGIN -->
## Project summary
**Name:** <PROJECT_NAME>
**Description:** <FILL_MANUALLY: what this project does in 37 lines>
## Key decisions (manual)
- <FILL_MANUALLY>
## Non-goals (manual)
- <FILL_MANUALLY>
<!-- MANUAL:END -->
---
## Document metadata
- **Created:** <AUTO_ON_INIT: YYYY-MM-DD>
- **Updated:** <AUTO_ON_CHANGE: YYYY-MM-DD>
- **Generated by:** archdoc (cli) v0.1
---
## Rails / Tooling
<!-- ARCHDOC:BEGIN section=rails -->
> Generated. Do not edit inside this block.
<AUTO: rails summary + links to config files>
<!-- ARCHDOC:END section=rails -->
---
## Repository layout (top-level)
<!-- ARCHDOC:BEGIN section=layout -->
> Generated. Do not edit inside this block.
<AUTO: table of top-level folders + heuristic purpose + link to layout.md>
<!-- ARCHDOC:END section=layout -->
---
## Modules index
<!-- ARCHDOC:BEGIN section=modules_index -->
> Generated. Do not edit inside this block.
<AUTO: table modules + deps counts + links to module docs>
<!-- ARCHDOC:END section=modules_index -->
---
## Critical dependency points
<!-- ARCHDOC:BEGIN section=critical_points -->
> Generated. Do not edit inside this block.
<AUTO: top fan-in/out symbols + cycles>
<!-- ARCHDOC:END section=critical_points -->
---
<!-- MANUAL:BEGIN -->
## Change notes (manual)
- <FILL_MANUALLY>
<!-- MANUAL:END -->
"#;
let architecture_md_path = std::path::Path::new(root).join("ARCHITECTURE.md");
std::fs::write(&architecture_md_path, architecture_md_content)?;
let config_toml_content = r#"[project]
root = "."
out_dir = "docs/architecture"
entry_file = "ARCHITECTURE.md"
language = "python"
[scan]
include = ["src", "app", "tests"]
exclude = [
".venv", "venv", "__pycache__", ".git", "dist", "build",
".mypy_cache", ".ruff_cache", ".pytest_cache", "*.egg-info"
]
follow_symlinks = false
max_file_size = "10MB"
[python]
src_roots = ["src", "."]
include_tests = true
parse_docstrings = true
max_parse_errors = 10
[analysis]
resolve_calls = true
resolve_inheritance = false
detect_integrations = true
integration_patterns = [
{ type = "http", patterns = ["requests", "httpx", "aiohttp"] },
{ type = "db", patterns = ["sqlalchemy", "psycopg", "mysql", "sqlite3"] },
{ type = "queue", patterns = ["celery", "kafka", "pika", "redis"] }
]
[output]
single_file = false
per_file_docs = true
create_directories = true
overwrite_manual_sections = false
[diff]
update_timestamp_on_change_only = true
hash_algorithm = "sha256"
preserve_manual_content = true
[thresholds]
critical_fan_in = 20
critical_fan_out = 20
high_complexity = 50
[rendering]
template_engine = "handlebars"
max_table_rows = 100
truncate_long_descriptions = true
description_max_length = 200
[logging]
level = "info"
file = "archdoc.log"
format = "compact"
[caching]
enabled = true
cache_dir = ".archdoc/cache"
max_cache_age = "24h"
"#;
let config_toml_path = std::path::Path::new(root).join("archdoc.toml");
if !config_toml_path.exists() {
std::fs::write(&config_toml_path, config_toml_content)?;
}
println!("{} Project initialized!", "".green().bold());
println!(" {} {}", "".dimmed(), architecture_md_path.display());
println!(" {} {}", "".dimmed(), config_toml_path.display());
println!(" {} {} (directory)", "".dimmed(), out_path.display());
Ok(())
}
fn load_config(config_path: &str) -> Result<Config> {
Config::load_from_file(Path::new(config_path))
.map_err(|e| anyhow::anyhow!("Failed to load config: {}", e))
}
fn analyze_project(root: &str, config: &Config) -> Result<ProjectModel> {
println!("{}", "Scanning project...".cyan());
let scanner = FileScanner::new(config.clone());
let python_files = scanner.scan_python_files(std::path::Path::new(root))?;
println!(" Found {} Python files", python_files.len().to_string().yellow());
let analyzer = PythonAnalyzer::new(config.clone());
let pb = ProgressBar::new(python_files.len() as u64);
pb.set_style(ProgressStyle::default_bar()
.template(" {spinner:.green} [{bar:30.cyan/dim}] {pos}/{len} {msg}")
.unwrap()
.progress_chars("█▓░"));
let mut parsed_modules = Vec::new();
let mut parse_errors = 0;
for file_path in &python_files {
pb.set_message(file_path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default());
match analyzer.parse_module(file_path) {
Ok(module) => parsed_modules.push(module),
Err(e) => {
parse_errors += 1;
pb.println(format!(" {} Failed to parse {}: {}", "".yellow(), file_path.display(), e));
}
}
pb.inc(1);
}
pb.finish_and_clear();
if parse_errors > 0 {
println!(" {} {} file(s) had parse errors", "".yellow(), parse_errors);
}
println!("{}", "Resolving symbols...".cyan());
let model = analyzer.resolve_symbols(&parsed_modules)
.map_err(|e| anyhow::anyhow!("Failed to resolve symbols: {}", e))?;
Ok(model)
}
fn sanitize_filename(filename: &str) -> String {
filename
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c => c,
})
.collect()
}
fn generate_docs(model: &ProjectModel, out: &str, verbose: bool) -> Result<()> {
println!("{}", "Generating documentation...".cyan());
let out_path = std::path::Path::new(out);
std::fs::create_dir_all(out_path)?;
let modules_path = out_path.join("modules");
let files_path = out_path.join("files");
std::fs::create_dir_all(&modules_path)?;
std::fs::create_dir_all(&files_path)?;
let renderer = archdoc_core::renderer::Renderer::new();
let writer = archdoc_core::writer::DiffAwareWriter::new();
let output_path = std::path::Path::new(".").join("ARCHITECTURE.md");
// Generate module docs
for (module_id, _module) in &model.modules {
let module_doc_path = modules_path.join(format!("{}.md", sanitize_filename(module_id)));
match renderer.render_module_md(model, module_id) {
Ok(module_content) => {
std::fs::write(&module_doc_path, module_content)?;
}
Err(e) => {
eprintln!(" {} Module {}: {}", "".yellow(), module_id, e);
let fallback = format!("# Module: {}\n\nTODO: Add module documentation\n", module_id);
std::fs::write(&module_doc_path, fallback)?;
}
}
}
// Generate file docs
for (_file_id, file_doc) in &model.files {
let file_doc_path = files_path.join(format!("{}.md", sanitize_filename(&file_doc.path)));
let mut file_content = format!("# File: {}\n\n", file_doc.path);
file_content.push_str(&format!("- **Module:** {}\n", file_doc.module_id));
file_content.push_str(&format!("- **Defined symbols:** {}\n", file_doc.symbols.len()));
file_content.push_str(&format!("- **Imports:** {}\n\n", file_doc.imports.len()));
file_content.push_str("<!-- MANUAL:BEGIN -->\n## File intent (manual)\n<FILL_MANUALLY>\n<!-- MANUAL:END -->\n\n---\n\n");
file_content.push_str("## Imports & file-level dependencies\n<!-- ARCHDOC:BEGIN section=file_imports -->\n> Generated. Do not edit inside this block.\n");
for import in &file_doc.imports {
file_content.push_str(&format!("- {}\n", import));
}
file_content.push_str("<!-- ARCHDOC:END section=file_imports -->\n\n---\n\n");
file_content.push_str("## Symbols index\n<!-- ARCHDOC:BEGIN section=symbols_index -->\n> Generated. Do not edit inside this block.\n");
for symbol_id in &file_doc.symbols {
if let Some(symbol) = model.symbols.get(symbol_id) {
file_content.push_str(&format!("- `{}` ({})\n", symbol.qualname, format!("{:?}", symbol.kind)));
}
}
file_content.push_str("<!-- ARCHDOC:END section=symbols_index -->\n\n---\n\n");
file_content.push_str("## Symbol details\n");
for symbol_id in &file_doc.symbols {
if model.symbols.contains_key(symbol_id) {
file_content.push_str(&format!("\n<!-- ARCHDOC:BEGIN symbol id={} -->\n", symbol_id));
file_content.push_str("<!-- AUTOGENERATED SYMBOL CONTENT WILL BE INSERTED HERE -->\n");
file_content.push_str(&format!("<!-- ARCHDOC:END symbol id={} -->\n", symbol_id));
}
}
std::fs::write(&file_doc_path, &file_content)?;
for symbol_id in &file_doc.symbols {
if model.symbols.contains_key(symbol_id) {
match renderer.render_symbol_details(model, symbol_id) {
Ok(content) => {
if verbose {
println!(" Updating symbol section for {}", symbol_id);
}
if let Err(e) = writer.update_symbol_section(&file_doc_path, symbol_id, &content) {
eprintln!(" {} Symbol {}: {}", "".yellow(), symbol_id, e);
}
}
Err(e) => {
eprintln!(" {} Symbol {}: {}", "".yellow(), symbol_id, e);
}
}
}
}
}
// Update ARCHITECTURE.md sections
let sections = [
("integrations", renderer.render_integrations_section(model)),
("rails", renderer.render_rails_section(model)),
("layout", renderer.render_layout_section(model)),
("modules_index", renderer.render_modules_index_section(model)),
("critical_points", renderer.render_critical_points_section(model)),
];
for (name, result) in sections {
match result {
Ok(content) => {
if let Err(e) = writer.update_file_with_markers(&output_path, &content, name) {
if verbose {
eprintln!(" {} Section {}: {}", "".yellow(), name, e);
}
}
}
Err(e) => {
if verbose {
eprintln!(" {} Section {}: {}", "".yellow(), name, e);
}
}
}
}
// Update layout.md
let layout_md_path = out_path.join("layout.md");
if let Ok(content) = renderer.render_layout_md(model) {
let _ = std::fs::write(&layout_md_path, &content);
}
println!("{} Documentation generated in {}", "".green().bold(), out);
Ok(())
}
fn print_generate_summary(model: &ProjectModel) {
println!();
println!("{}", "── Summary ──────────────────────────".dimmed());
println!(" {} {}", "Files:".bold(), model.files.len());
println!(" {} {}", "Modules:".bold(), model.modules.len());
println!(" {} {}", "Symbols:".bold(), model.symbols.len());
println!(" {} {}", "Edges:".bold(),
model.edges.module_import_edges.len() + model.edges.symbol_call_edges.len());
let integrations: Vec<&str> = {
let mut v = Vec::new();
if model.symbols.values().any(|s| s.integrations_flags.http) { v.push("HTTP"); }
if model.symbols.values().any(|s| s.integrations_flags.db) { v.push("DB"); }
if model.symbols.values().any(|s| s.integrations_flags.queue) { v.push("Queue"); }
v
};
if !integrations.is_empty() {
println!(" {} {}", "Integrations:".bold(), integrations.join(", ").yellow());
}
println!("{}", "─────────────────────────────────────".dimmed());
}
fn print_stats(model: &ProjectModel) {
println!();
println!("{}", "╔══════════════════════════════════════╗".cyan());
println!("{}", "║ archdoc project statistics ║".cyan().bold());
println!("{}", "╚══════════════════════════════════════╝".cyan());
println!();
// Basic counts
println!("{}", "Overview".bold().underline());
println!(" Files: {}", model.files.len().to_string().yellow());
println!(" Modules: {}", model.modules.len().to_string().yellow());
println!(" Symbols: {}", model.symbols.len().to_string().yellow());
println!(" Import edges: {}", model.edges.module_import_edges.len());
println!(" Call edges: {}", model.edges.symbol_call_edges.len());
println!();
// Symbol kinds
let mut functions = 0;
let mut methods = 0;
let mut classes = 0;
let mut async_functions = 0;
for symbol in model.symbols.values() {
match symbol.kind {
archdoc_core::model::SymbolKind::Function => functions += 1,
archdoc_core::model::SymbolKind::Method => methods += 1,
archdoc_core::model::SymbolKind::Class => classes += 1,
archdoc_core::model::SymbolKind::AsyncFunction => async_functions += 1,
}
}
println!("{}", "Symbol breakdown".bold().underline());
println!(" Classes: {}", classes);
println!(" Functions: {}", functions);
println!(" Async functions: {}", async_functions);
println!(" Methods: {}", methods);
println!();
// Top fan-in
let mut symbols_by_fan_in: Vec<_> = model.symbols.values().collect();
symbols_by_fan_in.sort_by(|a, b| b.metrics.fan_in.cmp(&a.metrics.fan_in));
println!("{}", "Top-10 by fan-in (most called)".bold().underline());
for (i, sym) in symbols_by_fan_in.iter().take(10).enumerate() {
if sym.metrics.fan_in == 0 { break; }
let critical = if sym.metrics.is_critical { " ⚠ CRITICAL".red().to_string() } else { String::new() };
println!(" {}. {} (fan-in: {}){}", i + 1, sym.qualname.green(), sym.metrics.fan_in, critical);
}
println!();
// Top fan-out
let mut symbols_by_fan_out: Vec<_> = model.symbols.values().collect();
symbols_by_fan_out.sort_by(|a, b| b.metrics.fan_out.cmp(&a.metrics.fan_out));
println!("{}", "Top-10 by fan-out (calls many)".bold().underline());
for (i, sym) in symbols_by_fan_out.iter().take(10).enumerate() {
if sym.metrics.fan_out == 0 { break; }
let critical = if sym.metrics.is_critical { " ⚠ CRITICAL".red().to_string() } else { String::new() };
println!(" {}. {} (fan-out: {}){}", i + 1, sym.qualname.green(), sym.metrics.fan_out, critical);
}
println!();
// Integrations
let http_symbols: Vec<_> = model.symbols.values().filter(|s| s.integrations_flags.http).collect();
let db_symbols: Vec<_> = model.symbols.values().filter(|s| s.integrations_flags.db).collect();
let queue_symbols: Vec<_> = model.symbols.values().filter(|s| s.integrations_flags.queue).collect();
if !http_symbols.is_empty() || !db_symbols.is_empty() || !queue_symbols.is_empty() {
println!("{}", "Detected integrations".bold().underline());
if !http_symbols.is_empty() {
println!(" {} HTTP: {}", "".yellow(), http_symbols.iter().map(|s| s.qualname.as_str()).collect::<Vec<_>>().join(", "));
}
if !db_symbols.is_empty() {
println!(" {} DB: {}", "".blue(), db_symbols.iter().map(|s| s.qualname.as_str()).collect::<Vec<_>>().join(", "));
}
if !queue_symbols.is_empty() {
println!(" {} Queue: {}", "".magenta(), queue_symbols.iter().map(|s| s.qualname.as_str()).collect::<Vec<_>>().join(", "));
}
println!();
}
// Cycles (basic detection via module import edges)
println!("{}", "Cycle detection".bold().underline());
let mut found_cycles = false;
for edge in &model.edges.module_import_edges {
// Check if there's a reverse edge
let has_reverse = model.edges.module_import_edges.iter()
.any(|e| e.from_id == edge.to_id && e.to_id == edge.from_id);
if has_reverse && edge.from_id < edge.to_id {
println!(" {} {}{}", "".red(), edge.from_id, edge.to_id);
found_cycles = true;
}
}
if !found_cycles {
println!(" {} No cycles detected", "".green());
}
}
fn check_docs_consistency(root: &str, config: &Config) -> Result<()> {
println!("{}", "Checking documentation consistency...".cyan());
let model = analyze_project(root, config)?;
let renderer = archdoc_core::renderer::Renderer::new();
let _generated = renderer.render_architecture_md(&model)?;
let architecture_md_path = std::path::Path::new(root).join(&config.project.entry_file);
if !architecture_md_path.exists() {
println!("{} {} does not exist", "".red().bold(), architecture_md_path.display());
return Err(anyhow::anyhow!("Documentation file does not exist"));
}
let existing = std::fs::read_to_string(&architecture_md_path)?;
println!("{} Documentation is parseable and consistent", "".green().bold());
println!(" Generated content: {} chars", _generated.len());
println!(" Existing content: {} chars", existing.len());
Ok(())
}

33
archdoc-cli/src/output.rs Normal file
View File

@@ -0,0 +1,33 @@
//! Colored output helpers and filename utilities for ArchDoc CLI
use colored::Colorize;
use archdoc_core::ProjectModel;
/// Sanitize a file path into a safe filename for docs.
/// Removes `./` prefix, replaces `/` with `__`.
pub fn sanitize_filename(filename: &str) -> String {
let cleaned = filename.strip_prefix("./").unwrap_or(filename);
cleaned.replace('/', "__")
}
pub fn print_generate_summary(model: &ProjectModel) {
println!();
println!("{}", "── Summary ──────────────────────────".dimmed());
println!(" {} {}", "Files:".bold(), model.files.len());
println!(" {} {}", "Modules:".bold(), model.modules.len());
println!(" {} {}", "Symbols:".bold(), model.symbols.len());
println!(" {} {}", "Edges:".bold(),
model.edges.module_import_edges.len() + model.edges.symbol_call_edges.len());
let integrations: Vec<&str> = {
let mut v = Vec::new();
if model.symbols.values().any(|s| s.integrations_flags.http) { v.push("HTTP"); }
if model.symbols.values().any(|s| s.integrations_flags.db) { v.push("DB"); }
if model.symbols.values().any(|s| s.integrations_flags.queue) { v.push("Queue"); }
v
};
if !integrations.is_empty() {
println!(" {} {}", "Integrations:".bold(), integrations.join(", ").yellow());
}
println!("{}", "─────────────────────────────────────".dimmed());
}