//! Markdown renderer for ArchDoc //! //! This module handles generating Markdown documentation from the project model //! using templates. use crate::model::ProjectModel; use handlebars::Handlebars; fn sanitize_for_link(filename: &str) -> String { filename .chars() .map(|c| match c { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', c => c, }) .collect() } pub struct Renderer { templates: Handlebars<'static>, } impl Renderer { pub fn new() -> Self { let mut handlebars = Handlebars::new(); // Register templates handlebars.register_template_string("architecture_md", Self::architecture_md_template()) .expect("Failed to register architecture_md template"); // Register module documentation template handlebars.register_template_string("module_md", Self::module_md_template()) .expect("Failed to register module_md template"); Self { templates: handlebars, } } fn architecture_md_template() -> &'static str { r#"# ARCHITECTURE — {{{project_name}}} ## Project summary **Name:** {{{project_name}}} **Description:** {{{project_description}}} ## Key decisions (manual) {{#each key_decisions}} - {{{this}}} {{/each}} ## Non-goals (manual) {{#each non_goals}} - {{{this}}} {{/each}} --- ## Document metadata - **Created:** {{{created_date}}} - **Updated:** {{{updated_date}}} - **Generated by:** archdoc (cli) v0.1 --- ## Integrations > Generated. Do not edit inside this block. ### Database Integrations {{#each db_integrations}} - {{{this}}} {{/each}} ### HTTP/API Integrations {{#each http_integrations}} - {{{this}}} {{/each}} ### Queue Integrations {{#each queue_integrations}} - {{{this}}} {{/each}} --- ## Rails / Tooling > Generated. Do not edit inside this block. {{{rails_summary}}} --- ## Repository layout (top-level) > Generated. Do not edit inside this block. | Path | Purpose | Link | |------|---------|------| {{#each layout_items}} | {{{path}}} | {{{purpose}}} | [details]({{{link}}}) | {{/each}} --- ## Modules index > Generated. Do not edit inside this block. | Module | Symbols | Inbound | Outbound | Link | |--------|---------|---------|----------|------| {{#each modules}} | {{{name}}} | {{{symbol_count}}} | {{{inbound_count}}} | {{{outbound_count}}} | [details]({{{link}}}) | {{/each}} --- ## Critical dependency points > Generated. Do not edit inside this block. ### High Fan-in (Most Called) | Symbol | Fan-in | Critical | |--------|--------|----------| {{#each high_fan_in}} | {{{symbol}}} | {{{count}}} | {{{critical}}} | {{/each}} ### High Fan-out (Calls Many) | Symbol | Fan-out | Critical | |--------|---------|----------| {{#each high_fan_out}} | {{{symbol}}} | {{{count}}} | {{{critical}}} | {{/each}} ### Module Cycles {{#each cycles}} - {{{cycle_path}}} {{/each}} --- ## Change notes (manual) {{#each change_notes}} - {{{this}}} {{/each}} "# } fn module_md_template() -> &'static str { r#"# Module: {{{module_name}}} {{{module_summary}}} ## Symbols {{#each symbols}} ### {{{name}}} {{{signature}}} {{{docstring}}} **Type:** {{{kind}}} **Metrics:** - Fan-in: {{{fan_in}}} - Fan-out: {{{fan_out}}} {{#if is_critical}} - Critical: Yes {{/if}} {{/each}} ## Dependencies ### Imports {{#each imports}} - {{{this}}} {{/each}} ### Outbound Modules {{#each outbound_modules}} - {{{this}}} {{/each}} ### Inbound Modules {{#each inbound_modules}} - {{{this}}} {{/each}} ## Integrations {{#if has_db_integrations}} ### Database Integrations {{#each db_symbols}} - {{{this}}} {{/each}} {{/if}} {{#if has_http_integrations}} ### HTTP/API Integrations {{#each http_symbols}} - {{{this}}} {{/each}} {{/if}} {{#if has_queue_integrations}} ### Queue Integrations {{#each queue_symbols}} - {{{this}}} {{/each}} {{/if}} ## Usage Examples {{#each usage_examples}} ```python {{{this}}} ``` {{/each}} "# } pub fn render_architecture_md(&self, model: &ProjectModel) -> Result { // Collect integration information let mut db_integrations = Vec::new(); let mut http_integrations = Vec::new(); let mut queue_integrations = Vec::new(); for (symbol_id, symbol) in &model.symbols { if symbol.integrations_flags.db { db_integrations.push(format!("{} in {}", symbol_id, symbol.file_id)); } if symbol.integrations_flags.http { http_integrations.push(format!("{} in {}", symbol_id, symbol.file_id)); } if symbol.integrations_flags.queue { queue_integrations.push(format!("{} in {}", symbol_id, symbol.file_id)); } } // Prepare data for template let data = serde_json::json!({ "project_name": "New Project", "project_description": "", "created_date": "2026-01-25", "updated_date": "2026-01-25", "key_decisions": [""], "non_goals": [""], "change_notes": [""], "db_integrations": db_integrations, "http_integrations": http_integrations, "queue_integrations": queue_integrations, // TODO: Fill with more actual data from model }); self.templates.render("architecture_md", &data) .map_err(|e| anyhow::anyhow!("Failed to render architecture.md: {}", e)) } pub fn render_module_md(&self, model: &ProjectModel, module_id: &str) -> Result { // Find the module in the project model let module = model.modules.get(module_id) .ok_or_else(|| anyhow::anyhow!("Module {} not found", module_id))?; // Collect symbols for this module let mut symbols = Vec::new(); for symbol_id in &module.symbols { if let Some(symbol) = model.symbols.get(symbol_id) { symbols.push(serde_json::json!({ "name": symbol.qualname, "signature": symbol.signature, "docstring": symbol.docstring_first_line.as_deref().unwrap_or("No documentation available"), "kind": format!("{:?}", symbol.kind), "fan_in": symbol.metrics.fan_in, "fan_out": symbol.metrics.fan_out, "is_critical": symbol.metrics.is_critical, })); } } // Collect integration information for this module let mut db_symbols = Vec::new(); let mut http_symbols = Vec::new(); let mut queue_symbols = Vec::new(); for symbol_id in &module.symbols { if let Some(symbol) = model.symbols.get(symbol_id) { if symbol.integrations_flags.db { db_symbols.push(symbol.qualname.clone()); } if symbol.integrations_flags.http { http_symbols.push(symbol.qualname.clone()); } if symbol.integrations_flags.queue { queue_symbols.push(symbol.qualname.clone()); } } } // Prepare usage examples (for now, just placeholders) let usage_examples = vec![ "// Example usage of module functions\n// TODO: Add real usage examples based on module analysis".to_string() ]; // Prepare data for template let data = serde_json::json!({ "module_name": module_id, "module_summary": module.doc_summary.as_deref().unwrap_or("No summary available"), "symbols": symbols, "imports": model.files.get(&module.files[0]).map(|f| f.imports.clone()).unwrap_or_default(), "outbound_modules": module.outbound_modules, "inbound_modules": module.inbound_modules, "has_db_integrations": !db_symbols.is_empty(), "has_http_integrations": !http_symbols.is_empty(), "has_queue_integrations": !queue_symbols.is_empty(), "db_symbols": db_symbols, "http_symbols": http_symbols, "queue_symbols": queue_symbols, "usage_examples": usage_examples, }); self.templates.render("module_md", &data) .map_err(|e| anyhow::anyhow!("Failed to render module.md: {}", e)) } pub fn render_integrations_section(&self, model: &ProjectModel) -> Result { // Collect integration information let mut db_integrations = Vec::new(); let mut http_integrations = Vec::new(); let mut queue_integrations = Vec::new(); for (symbol_id, symbol) in &model.symbols { if symbol.integrations_flags.db { db_integrations.push(format!("{} in {}", symbol_id, symbol.file_id)); } if symbol.integrations_flags.http { http_integrations.push(format!("{} in {}", symbol_id, symbol.file_id)); } if symbol.integrations_flags.queue { queue_integrations.push(format!("{} in {}", symbol_id, symbol.file_id)); } } // Prepare data for integrations section let data = serde_json::json!({ "db_integrations": db_integrations, "http_integrations": http_integrations, "queue_integrations": queue_integrations, }); // Create a smaller template just for the integrations section let integrations_template = r#" ### Database Integrations {{#each db_integrations}} - {{{this}}} {{/each}} ### HTTP/API Integrations {{#each http_integrations}} - {{{this}}} {{/each}} ### Queue Integrations {{#each queue_integrations}} - {{{this}}} {{/each}} "#; let mut handlebars = Handlebars::new(); handlebars.register_template_string("integrations", integrations_template) .map_err(|e| anyhow::anyhow!("Failed to register integrations template: {}", e))?; handlebars.render("integrations", &data) .map_err(|e| anyhow::anyhow!("Failed to render integrations section: {}", e)) } pub fn render_rails_section(&self, _model: &ProjectModel) -> Result { // For now, return a simple placeholder Ok("\n\nNo tooling information available.\n".to_string()) } pub fn render_layout_section(&self, model: &ProjectModel) -> Result { // Collect layout information from files let mut layout_items = Vec::new(); for (_file_id, file_doc) in &model.files { layout_items.push(serde_json::json!({ "path": file_doc.path, "purpose": "Source file", "link": format!("docs/architecture/files/{}.md", sanitize_for_link(&file_doc.path)) })); } // Prepare data for layout section let data = serde_json::json!({ "layout_items": layout_items, }); // Create a smaller template just for the layout section let layout_template = r#" | Path | Purpose | Link | |------|---------|------| {{#each layout_items}} | {{{path}}} | {{{purpose}}} | [details]({{{link}}}) | {{/each}} "#; let mut handlebars = Handlebars::new(); handlebars.register_template_string("layout", layout_template) .map_err(|e| anyhow::anyhow!("Failed to register layout template: {}", e))?; handlebars.render("layout", &data) .map_err(|e| anyhow::anyhow!("Failed to render layout section: {}", e)) } pub fn render_modules_index_section(&self, model: &ProjectModel) -> Result { // Collect module information let mut modules = Vec::new(); for (module_id, module) in &model.modules { modules.push(serde_json::json!({ "name": module_id, "symbol_count": module.symbols.len(), "inbound_count": module.inbound_modules.len(), "outbound_count": module.outbound_modules.len(), "link": format!("docs/architecture/modules/{}.md", sanitize_for_link(module_id)) })); } // Prepare data for modules index section let data = serde_json::json!({ "modules": modules, }); // Create a smaller template just for the modules index section let modules_template = r#" | Module | Symbols | Inbound | Outbound | Link | |--------|---------|---------|----------|------| {{#each modules}} | {{{name}}} | {{{symbol_count}}} | {{{inbound_count}}} | {{{outbound_count}}} | [details]({{{link}}}) | {{/each}} "#; let mut handlebars = Handlebars::new(); handlebars.register_template_string("modules_index", modules_template) .map_err(|e| anyhow::anyhow!("Failed to register modules_index template: {}", e))?; handlebars.render("modules_index", &data) .map_err(|e| anyhow::anyhow!("Failed to render modules index section: {}", e)) } pub fn render_critical_points_section(&self, model: &ProjectModel) -> Result { // Collect critical points information let mut high_fan_in = Vec::new(); let mut high_fan_out = Vec::new(); for (symbol_id, symbol) in &model.symbols { if symbol.metrics.fan_in > 5 { // Threshold for high fan-in high_fan_in.push(serde_json::json!({ "symbol": symbol_id, "count": symbol.metrics.fan_in, "critical": symbol.metrics.is_critical, })); } if symbol.metrics.fan_out > 5 { // Threshold for high fan-out high_fan_out.push(serde_json::json!({ "symbol": symbol_id, "count": symbol.metrics.fan_out, "critical": symbol.metrics.is_critical, })); } } // Prepare data for critical points section let data = serde_json::json!({ "high_fan_in": high_fan_in, "high_fan_out": high_fan_out, "cycles": Vec::::new(), // TODO: Implement cycle detection }); // Create a smaller template just for the critical points section let critical_points_template = r#" ### High Fan-in (Most Called) | Symbol | Fan-in | Critical | |--------|--------|----------| {{#each high_fan_in}} | {{{symbol}}} | {{{count}}} | {{{critical}}} | {{/each}} ### High Fan-out (Calls Many) | Symbol | Fan-out | Critical | |--------|---------|----------| {{#each high_fan_out}} | {{{symbol}}} | {{{count}}} | {{{critical}}} | {{/each}} ### Module Cycles {{#each cycles}} - {{{this}}} {{/each}} "#; let mut handlebars = Handlebars::new(); handlebars.register_template_string("critical_points", critical_points_template) .map_err(|e| anyhow::anyhow!("Failed to register critical_points template: {}", e))?; handlebars.render("critical_points", &data) .map_err(|e| anyhow::anyhow!("Failed to render critical points section: {}", e)) } pub fn render_layout_md(&self, model: &ProjectModel) -> Result { // Collect layout information from files let mut layout_items = Vec::new(); for (_file_id, file_doc) in &model.files { layout_items.push(serde_json::json!({ "path": file_doc.path, "purpose": "Source file", "link": format!("files/{}.md", sanitize_for_link(&file_doc.path)) })); } // Prepare data for layout template let data = serde_json::json!({ "layout_items": layout_items, }); // Create template for layout.md let layout_template = r#"# Repository layout ## Manual overrides - `src/app/` — --- ## Detected structure > Generated. Do not edit inside this block. | Path | Purpose | Link | |------|---------|------| {{#each layout_items}} | {{{path}}} | {{{purpose}}} | [details]({{{link}}}) | {{/each}} "#; let mut handlebars = Handlebars::new(); handlebars.register_template_string("layout_md", layout_template) .map_err(|e| anyhow::anyhow!("Failed to register layout_md template: {}", e))?; handlebars.render("layout_md", &data) .map_err(|e| anyhow::anyhow!("Failed to render layout.md: {}", e)) } pub fn render_symbol_details(&self, model: &ProjectModel, symbol_id: &str) -> Result { // Find the symbol in the project model let symbol = model.symbols.get(symbol_id) .ok_or_else(|| anyhow::anyhow!("Symbol {} not found", symbol_id))?; // Prepare data for symbol template let data = serde_json::json!({ "symbol_id": symbol_id, "qualname": symbol.qualname, "kind": format!("{:?}", symbol.kind), "signature": symbol.signature, "docstring": symbol.docstring_first_line.as_deref().unwrap_or("No documentation available"), "purpose": symbol.purpose, "integrations": { "http": symbol.integrations_flags.http, "db": symbol.integrations_flags.db, "queue": symbol.integrations_flags.queue, }, "metrics": { "fan_in": symbol.metrics.fan_in, "fan_out": symbol.metrics.fan_out, "is_critical": symbol.metrics.is_critical, "cycle_participant": symbol.metrics.cycle_participant, }, "outbound_calls": symbol.outbound_calls, "inbound_calls": symbol.inbound_calls, }); // Create template for symbol details let symbol_template = r#" ### `{{qualname}}` - **Kind:** {{kind}} - **Signature:** `{{{signature}}}` - **Docstring:** `{{{docstring}}}` #### What it does {{{purpose}}} #### Relations **Outbound calls (best-effort):** {{#each outbound_calls}} - {{{this}}} {{/each}} **Inbound (used by) (best-effort):** {{#each inbound_calls}} - {{{this}}} {{/each}} #### Integrations (heuristic) - HTTP: {{#if integrations.http}}yes{{else}}no{{/if}} - DB: {{#if integrations.db}}yes{{else}}no{{/if}} - Queue/Tasks: {{#if integrations.queue}}yes{{else}}no{{/if}} #### Risk / impact - fan-in: {{{metrics.fan_in}}} - fan-out: {{{metrics.fan_out}}} - cycle participant: {{#if metrics.cycle_participant}}yes{{else}}no{{/if}} - critical: {{#if metrics.is_critical}}yes{{else}}no{{/if}} #### Manual notes "#; let mut handlebars = Handlebars::new(); handlebars.register_template_string("symbol_details", symbol_template) .map_err(|e| anyhow::anyhow!("Failed to register symbol_details template: {}", e))?; handlebars.render("symbol_details", &data) .map_err(|e| anyhow::anyhow!("Failed to render symbol details: {}", e)) } }