Files
archdoc/archdoc-core/src/renderer.rs
Arkasha 40f87f4d61 feat: add config validation and dependency cycle detection
- Config::validate() checks project.root, language, scan.include,
  python.src_roots, caching.max_cache_age, and scan.max_file_size
- Add parse_duration() and parse_file_size() helper functions
- Implement DFS-based cycle detection in cycle_detector.rs
- Wire cycle detection into renderer critical points section
- Add comprehensive unit tests for all new functionality
2026-02-15 03:26:43 +03:00

666 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Markdown renderer for ArchDoc
//!
//! This module handles generating Markdown documentation from the project model
//! using templates.
use crate::cycle_detector;
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 Default for Renderer {
fn default() -> Self {
Self::new()
}
}
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}}}
<!-- MANUAL:BEGIN -->
## Project summary
**Name:** {{{project_name}}}
**Description:** {{{project_description}}}
## Key decisions (manual)
{{#each key_decisions}}
- {{{this}}}
{{/each}}
## Non-goals (manual)
{{#each non_goals}}
- {{{this}}}
{{/each}}
<!-- MANUAL:END -->
---
## Document metadata
- **Created:** {{{created_date}}}
- **Updated:** {{{updated_date}}}
- **Generated by:** archdoc (cli) v0.1
---
## Integrations
<!-- ARCHDOC:BEGIN section=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}}
<!-- ARCHDOC:END section=integrations -->
---
## Rails / Tooling
<!-- ARCHDOC:BEGIN section=rails -->
> Generated. Do not edit inside this block.
{{{rails_summary}}}
<!-- ARCHDOC:END section=rails -->
---
## Repository layout (top-level)
<!-- ARCHDOC:BEGIN section=layout -->
> Generated. Do not edit inside this block.
| Path | Purpose | Link |
|------|---------|------|
{{#each layout_items}}
| {{{path}}} | {{{purpose}}} | [details]({{{link}}}) |
{{/each}}
<!-- ARCHDOC:END section=layout -->
---
## Modules index
<!-- ARCHDOC:BEGIN section=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}}
<!-- ARCHDOC:END section=modules_index -->
---
## Critical dependency points
<!-- ARCHDOC:BEGIN section=critical_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}}
<!-- ARCHDOC:END section=critical_points -->
---
<!-- MANUAL:BEGIN -->
## Change notes (manual)
{{#each change_notes}}
- {{{this}}}
{{/each}}
<!-- MANUAL:END -->
"#
}
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<String, anyhow::Error> {
// 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": "<FILL_MANUALLY: what this project does in 37 lines>",
"created_date": "2026-01-25",
"updated_date": "2026-01-25",
"key_decisions": ["<FILL_MANUALLY>"],
"non_goals": ["<FILL_MANUALLY>"],
"change_notes": ["<FILL_MANUALLY>"],
"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<String, anyhow::Error> {
// 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<String, anyhow::Error> {
// 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<String, anyhow::Error> {
// For now, return a simple placeholder
Ok("\n\nNo tooling information available.\n".to_string())
}
pub fn render_layout_section(&self, model: &ProjectModel) -> Result<String, anyhow::Error> {
// Collect layout information from files
let mut layout_items = Vec::new();
for file_doc in model.files.values() {
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<String, anyhow::Error> {
// 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<String, anyhow::Error> {
// 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": cycle_detector::detect_cycles(model)
.iter()
.map(|cycle| {
serde_json::json!({
"cycle_path": format!("{} → {}", cycle.join(""), cycle.first().unwrap_or(&String::new()))
})
})
.collect::<Vec<_>>(),
});
// 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<String, anyhow::Error> {
// Collect layout information from files
let mut layout_items = Vec::new();
for file_doc in model.files.values() {
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:BEGIN -->
## Manual overrides
- `src/app/` — <FILL_MANUALLY>
<!-- MANUAL:END -->
---
## Detected structure
<!-- ARCHDOC:BEGIN section=layout_detected -->
> Generated. Do not edit inside this block.
| Path | Purpose | Link |
|------|---------|------|
{{#each layout_items}}
| {{{path}}} | {{{purpose}}} | [details]({{{link}}}) |
{{/each}}
<!-- ARCHDOC:END section=layout_detected -->
"#;
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<String, anyhow::Error> {
// 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#"<a id="{{symbol_id}}"></a>
### `{{qualname}}`
- **Kind:** {{kind}}
- **Signature:** `{{{signature}}}`
- **Docstring:** `{{{docstring}}}`
#### What it does
<!-- ARCHDOC:BEGIN section=purpose -->
{{{purpose}}}
<!-- ARCHDOC:END section=purpose -->
#### Relations
<!-- ARCHDOC:BEGIN section=relations -->
**Outbound calls (best-effort):**
{{#each outbound_calls}}
- {{{this}}}
{{/each}}
**Inbound (used by) (best-effort):**
{{#each inbound_calls}}
- {{{this}}}
{{/each}}
<!-- ARCHDOC:END section=relations -->
#### Integrations (heuristic)
<!-- ARCHDOC:BEGIN section=integrations -->
- 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}}
<!-- ARCHDOC:END section=integrations -->
#### Risk / impact
<!-- ARCHDOC:BEGIN section=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}}
<!-- ARCHDOC:END section=impact -->
<!-- MANUAL:BEGIN -->
#### Manual notes
<FILL_MANUALLY>
<!-- MANUAL:END -->
"#;
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))
}
}