feat: use actual project data, real usage examples, dry-run/verbose flags, skip-unchanged optimization
- renderer: render_architecture_md accepts Config, uses project name and current date - renderer: generate real Python usage examples from analyzed symbols - writer: skip writing files when content unchanged (optimization) - cli: add --dry-run flag to generate command (lists files without writing) - cli: add verbose logging for file/module/symbol generation progress
This commit is contained in:
@@ -10,7 +10,7 @@ pub fn check_docs_consistency(root: &str, config: &Config) -> Result<()> {
|
|||||||
let model = analyze_project(root, config)?;
|
let model = analyze_project(root, config)?;
|
||||||
|
|
||||||
let renderer = archdoc_core::renderer::Renderer::new();
|
let renderer = archdoc_core::renderer::Renderer::new();
|
||||||
let _generated = renderer.render_architecture_md(&model)?;
|
let _generated = renderer.render_architecture_md(&model, None)?;
|
||||||
|
|
||||||
let architecture_md_path = std::path::Path::new(root).join(&config.project.entry_file);
|
let architecture_md_path = std::path::Path::new(root).join(&config.project.entry_file);
|
||||||
if !architecture_md_path.exists() {
|
if !architecture_md_path.exists() {
|
||||||
|
|||||||
@@ -55,7 +55,46 @@ pub fn analyze_project(root: &str, config: &Config) -> Result<ProjectModel> {
|
|||||||
Ok(model)
|
Ok(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_docs(model: &ProjectModel, out: &str, verbose: bool) -> Result<()> {
|
pub fn dry_run_docs(model: &ProjectModel, out: &str, config: &Config) -> Result<()> {
|
||||||
|
println!("{}", "Dry run — no files will be written.".cyan().bold());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let out_path = std::path::Path::new(out);
|
||||||
|
let arch_path = std::path::Path::new(".").join("ARCHITECTURE.md");
|
||||||
|
|
||||||
|
// ARCHITECTURE.md
|
||||||
|
let exists = arch_path.exists();
|
||||||
|
println!(" {} {}", if exists { "UPDATE" } else { "CREATE" }, arch_path.display());
|
||||||
|
|
||||||
|
// layout.md
|
||||||
|
let layout_path = out_path.join("layout.md");
|
||||||
|
let exists = layout_path.exists();
|
||||||
|
println!(" {} {}", if exists { "UPDATE" } else { "CREATE" }, layout_path.display());
|
||||||
|
|
||||||
|
// Module docs
|
||||||
|
for module_id in model.modules.keys() {
|
||||||
|
let p = out_path.join("modules").join(format!("{}.md", sanitize_filename(module_id)));
|
||||||
|
let exists = p.exists();
|
||||||
|
println!(" {} {}", if exists { "UPDATE" } else { "CREATE" }, p.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// File docs
|
||||||
|
for file_doc in model.files.values() {
|
||||||
|
let p = out_path.join("files").join(format!("{}.md", sanitize_filename(&file_doc.path)));
|
||||||
|
let exists = p.exists();
|
||||||
|
println!(" {} {}", if exists { "UPDATE" } else { "CREATE" }, p.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = config; // used for future extensions
|
||||||
|
println!();
|
||||||
|
println!("{} {} file(s) would be generated/updated",
|
||||||
|
"✓".green().bold(),
|
||||||
|
2 + model.modules.len() + model.files.len());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_docs(model: &ProjectModel, out: &str, verbose: bool, _config: &Config) -> Result<()> {
|
||||||
println!("{}", "Generating documentation...".cyan());
|
println!("{}", "Generating documentation...".cyan());
|
||||||
|
|
||||||
let out_path = std::path::Path::new(out);
|
let out_path = std::path::Path::new(out);
|
||||||
@@ -74,6 +113,9 @@ pub fn generate_docs(model: &ProjectModel, out: &str, verbose: bool) -> Result<(
|
|||||||
// Generate module docs
|
// Generate module docs
|
||||||
for module_id in model.modules.keys() {
|
for module_id in model.modules.keys() {
|
||||||
let module_doc_path = modules_path.join(format!("{}.md", sanitize_filename(module_id)));
|
let module_doc_path = modules_path.join(format!("{}.md", sanitize_filename(module_id)));
|
||||||
|
if verbose {
|
||||||
|
println!(" Generating module doc: {}", module_id);
|
||||||
|
}
|
||||||
match renderer.render_module_md(model, module_id) {
|
match renderer.render_module_md(model, module_id) {
|
||||||
Ok(module_content) => {
|
Ok(module_content) => {
|
||||||
std::fs::write(&module_doc_path, module_content)?;
|
std::fs::write(&module_doc_path, module_content)?;
|
||||||
@@ -88,6 +130,9 @@ pub fn generate_docs(model: &ProjectModel, out: &str, verbose: bool) -> Result<(
|
|||||||
|
|
||||||
// Generate file docs
|
// Generate file docs
|
||||||
for file_doc in model.files.values() {
|
for file_doc in model.files.values() {
|
||||||
|
if verbose {
|
||||||
|
println!(" Generating file doc: {}", file_doc.path);
|
||||||
|
}
|
||||||
let file_doc_path = files_path.join(format!("{}.md", sanitize_filename(&file_doc.path)));
|
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);
|
let mut file_content = format!("# File: {}\n\n", file_doc.path);
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ enum Commands {
|
|||||||
out: String,
|
out: String,
|
||||||
#[arg(short, long, default_value = "archdoc.toml")]
|
#[arg(short, long, default_value = "archdoc.toml")]
|
||||||
config: String,
|
config: String,
|
||||||
|
/// Show what would be generated without writing files
|
||||||
|
#[arg(long)]
|
||||||
|
dry_run: bool,
|
||||||
},
|
},
|
||||||
/// Check if documentation is up to date
|
/// Check if documentation is up to date
|
||||||
Check {
|
Check {
|
||||||
@@ -58,10 +61,14 @@ fn main() -> Result<()> {
|
|||||||
Commands::Init { root, out } => {
|
Commands::Init { root, out } => {
|
||||||
commands::init::init_project(root, out)?;
|
commands::init::init_project(root, out)?;
|
||||||
}
|
}
|
||||||
Commands::Generate { root, out, config } => {
|
Commands::Generate { root, out, config, dry_run } => {
|
||||||
let config = commands::generate::load_config(config)?;
|
let config = commands::generate::load_config(config)?;
|
||||||
let model = commands::generate::analyze_project(root, &config)?;
|
let model = commands::generate::analyze_project(root, &config)?;
|
||||||
commands::generate::generate_docs(&model, out, cli.verbose)?;
|
if *dry_run {
|
||||||
|
commands::generate::dry_run_docs(&model, out, &config)?;
|
||||||
|
} else {
|
||||||
|
commands::generate::generate_docs(&model, out, cli.verbose, &config)?;
|
||||||
|
}
|
||||||
output::print_generate_summary(&model);
|
output::print_generate_summary(&model);
|
||||||
}
|
}
|
||||||
Commands::Check { root, config } => {
|
Commands::Check { root, config } => {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
//! This module handles generating Markdown documentation from the project model
|
//! This module handles generating Markdown documentation from the project model
|
||||||
//! using templates.
|
//! using templates.
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
use crate::cycle_detector;
|
use crate::cycle_detector;
|
||||||
use crate::model::ProjectModel;
|
use crate::model::{ProjectModel, SymbolKind};
|
||||||
|
use chrono::Utc;
|
||||||
use handlebars::Handlebars;
|
use handlebars::Handlebars;
|
||||||
|
|
||||||
fn sanitize_for_link(filename: &str) -> String {
|
fn sanitize_for_link(filename: &str) -> String {
|
||||||
@@ -236,7 +238,7 @@ impl Renderer {
|
|||||||
"#
|
"#
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_architecture_md(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
|
pub fn render_architecture_md(&self, model: &ProjectModel, config: Option<&Config>) -> Result<String, anyhow::Error> {
|
||||||
// Collect integration information
|
// Collect integration information
|
||||||
let mut db_integrations = Vec::new();
|
let mut db_integrations = Vec::new();
|
||||||
let mut http_integrations = Vec::new();
|
let mut http_integrations = Vec::new();
|
||||||
@@ -254,19 +256,40 @@ impl Renderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine project name: config > directory name > fallback
|
||||||
|
let project_name = config
|
||||||
|
.and_then(|c| {
|
||||||
|
if c.project.name.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(c.project.name.clone())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
config.map(|c| {
|
||||||
|
std::path::Path::new(&c.project.root)
|
||||||
|
.canonicalize()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
|
||||||
|
.unwrap_or_else(|| "Project".to_string())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "Project".to_string());
|
||||||
|
|
||||||
|
let today = Utc::now().format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
// Prepare data for template
|
// Prepare data for template
|
||||||
let data = serde_json::json!({
|
let data = serde_json::json!({
|
||||||
"project_name": "New Project",
|
"project_name": project_name,
|
||||||
"project_description": "<FILL_MANUALLY: what this project does in 3–7 lines>",
|
"project_description": "<FILL_MANUALLY: what this project does in 3–7 lines>",
|
||||||
"created_date": "2026-01-25",
|
"created_date": &today,
|
||||||
"updated_date": "2026-01-25",
|
"updated_date": &today,
|
||||||
"key_decisions": ["<FILL_MANUALLY>"],
|
"key_decisions": ["<FILL_MANUALLY>"],
|
||||||
"non_goals": ["<FILL_MANUALLY>"],
|
"non_goals": ["<FILL_MANUALLY>"],
|
||||||
"change_notes": ["<FILL_MANUALLY>"],
|
"change_notes": ["<FILL_MANUALLY>"],
|
||||||
"db_integrations": db_integrations,
|
"db_integrations": db_integrations,
|
||||||
"http_integrations": http_integrations,
|
"http_integrations": http_integrations,
|
||||||
"queue_integrations": queue_integrations,
|
"queue_integrations": queue_integrations,
|
||||||
// TODO: Fill with more actual data from model
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.templates.render("architecture_md", &data)
|
self.templates.render("architecture_md", &data)
|
||||||
@@ -313,10 +336,50 @@ impl Renderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare usage examples (for now, just placeholders)
|
// Generate usage examples from public symbols
|
||||||
let usage_examples = vec![
|
let mut usage_examples = Vec::new();
|
||||||
"// Example usage of module functions\n// TODO: Add real usage examples based on module analysis".to_string()
|
for symbol_id in &module.symbols {
|
||||||
];
|
if let Some(symbol) = model.symbols.get(symbol_id) {
|
||||||
|
let short_name = symbol.qualname.rsplit('.').next().unwrap_or(&symbol.qualname);
|
||||||
|
match symbol.kind {
|
||||||
|
SymbolKind::Function | SymbolKind::AsyncFunction => {
|
||||||
|
// Extract args from signature: "def foo(a, b)" -> "a, b"
|
||||||
|
let args = symbol.signature
|
||||||
|
.find('(')
|
||||||
|
.and_then(|start| symbol.signature.rfind(')').map(|end| (start, end)))
|
||||||
|
.map(|(s, e)| &symbol.signature[s+1..e])
|
||||||
|
.unwrap_or("");
|
||||||
|
let clean_args = args.split(',')
|
||||||
|
.map(|a| a.split(':').next().unwrap_or("").trim())
|
||||||
|
.filter(|a| !a.is_empty() && *a != "self" && *a != "cls")
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let example_args = if clean_args.is_empty() { String::new() } else {
|
||||||
|
clean_args.split(", ").map(|a| {
|
||||||
|
if a.starts_with('*') { "..." } else { a }
|
||||||
|
}).collect::<Vec<_>>().join(", ")
|
||||||
|
};
|
||||||
|
let prefix = if symbol.kind == SymbolKind::AsyncFunction { "await " } else { "" };
|
||||||
|
usage_examples.push(format!(
|
||||||
|
"from {} import {}\nresult = {}{}({})",
|
||||||
|
module_id, short_name, prefix, short_name, example_args
|
||||||
|
));
|
||||||
|
}
|
||||||
|
SymbolKind::Class => {
|
||||||
|
usage_examples.push(format!(
|
||||||
|
"from {} import {}\ninstance = {}()",
|
||||||
|
module_id, short_name, short_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
SymbolKind::Method => {
|
||||||
|
// Skip methods - they're shown via class usage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if usage_examples.is_empty() {
|
||||||
|
usage_examples.push(format!("import {}", module_id));
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare data for template
|
// Prepare data for template
|
||||||
let data = serde_json::json!({
|
let data = serde_json::json!({
|
||||||
|
|||||||
@@ -70,17 +70,13 @@ impl DiffAwareWriter {
|
|||||||
// Check if content has changed
|
// Check if content has changed
|
||||||
let content_changed = existing_content != new_content;
|
let content_changed = existing_content != new_content;
|
||||||
|
|
||||||
// Write updated content
|
// Only write if content actually changed (optimization)
|
||||||
if content_changed {
|
if content_changed {
|
||||||
let updated_content = self.update_timestamp(new_content)?;
|
let updated_content = self.update_timestamp(new_content)?;
|
||||||
fs::write(file_path, updated_content)
|
fs::write(file_path, updated_content)
|
||||||
.map_err(ArchDocError::Io)?;
|
.map_err(ArchDocError::Io)?;
|
||||||
} else {
|
|
||||||
// Content hasn't changed, but we might still need to update timestamp
|
|
||||||
// TODO: Implement timestamp update logic based on config
|
|
||||||
fs::write(file_path, new_content)
|
|
||||||
.map_err(ArchDocError::Io)?;
|
|
||||||
}
|
}
|
||||||
|
// If not changed, skip writing entirely
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -118,17 +114,13 @@ impl DiffAwareWriter {
|
|||||||
// Check if content has changed
|
// Check if content has changed
|
||||||
let content_changed = existing_content != new_content;
|
let content_changed = existing_content != new_content;
|
||||||
|
|
||||||
// Write updated content
|
// Only write if content actually changed (optimization)
|
||||||
if content_changed {
|
if content_changed {
|
||||||
let updated_content = self.update_timestamp(new_content)?;
|
let updated_content = self.update_timestamp(new_content)?;
|
||||||
fs::write(file_path, updated_content)
|
fs::write(file_path, updated_content)
|
||||||
.map_err(ArchDocError::Io)?;
|
.map_err(ArchDocError::Io)?;
|
||||||
} else {
|
|
||||||
// Content hasn't changed, but we might still need to update timestamp
|
|
||||||
// TODO: Implement timestamp update logic based on config
|
|
||||||
fs::write(file_path, new_content)
|
|
||||||
.map_err(ArchDocError::Io)?;
|
|
||||||
}
|
}
|
||||||
|
// If not changed, skip writing entirely
|
||||||
} else {
|
} else {
|
||||||
eprintln!("Warning: No symbol marker found for {} in {}", symbol_id, file_path.display());
|
eprintln!("Warning: No symbol marker found for {} in {}", symbol_id, file_path.display());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ fn test_renderer_produces_output() {
|
|||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
let model = ProjectModel::new();
|
let model = ProjectModel::new();
|
||||||
let renderer = Renderer::new();
|
let renderer = Renderer::new();
|
||||||
let result = renderer.render_architecture_md(&model);
|
let result = renderer.render_architecture_md(&model, None);
|
||||||
assert!(result.is_ok(), "Renderer should produce output for empty model");
|
assert!(result.is_ok(), "Renderer should produce output for empty model");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ fn test_render_with_integrations() {
|
|||||||
let renderer = Renderer::new();
|
let renderer = Renderer::new();
|
||||||
|
|
||||||
// Render architecture documentation
|
// Render architecture documentation
|
||||||
let result = renderer.render_architecture_md(&project_model);
|
let result = renderer.render_architecture_md(&project_model, None);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let rendered_content = result.unwrap();
|
let rendered_content = result.unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user