961 lines
33 KiB
Rust
961 lines
33 KiB
Rust
//! Markdown renderer for WTIsMyCode
|
|
//!
|
|
//! This module handles generating Markdown documentation from the project model
|
|
//! using templates.
|
|
|
|
use crate::config::Config;
|
|
use crate::cycle_detector;
|
|
use crate::model::{ProjectModel, SymbolKind};
|
|
use chrono::Utc;
|
|
use handlebars::Handlebars;
|
|
|
|
fn sanitize_for_link(filename: &str) -> String {
|
|
let cleaned = filename.strip_prefix("./").unwrap_or(filename);
|
|
cleaned.replace('/', "__")
|
|
}
|
|
|
|
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:** wtismycode (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}}
|
|
|
|
### Storage Integrations
|
|
{{#each storage_integrations}}
|
|
- {{{this}}}
|
|
{{/each}}
|
|
|
|
### AI/ML Integrations
|
|
{{#each ai_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}}
|
|
|
|
{{#if has_storage_integrations}}
|
|
### Storage Integrations
|
|
{{#each storage_symbols}}
|
|
- {{{this}}}
|
|
{{/each}}
|
|
{{/if}}
|
|
|
|
{{#if has_ai_integrations}}
|
|
### AI/ML Integrations
|
|
{{#each ai_symbols}}
|
|
- {{{this}}}
|
|
{{/each}}
|
|
{{/if}}
|
|
|
|
## Usage Examples
|
|
|
|
{{#each usage_examples}}
|
|
```python
|
|
{{{this}}}
|
|
```
|
|
|
|
{{/each}}
|
|
"#
|
|
}
|
|
|
|
pub fn render_architecture_md(&self, model: &ProjectModel, config: Option<&Config>) -> 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();
|
|
let mut storage_integrations = Vec::new();
|
|
let mut ai_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));
|
|
}
|
|
if symbol.integrations_flags.storage {
|
|
storage_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
|
|
}
|
|
if symbol.integrations_flags.ai {
|
|
ai_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
|
|
}
|
|
}
|
|
|
|
// Determine project name: config > pyproject.toml > 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(|| {
|
|
// Try pyproject.toml
|
|
config.and_then(|c| {
|
|
let pyproject_path = std::path::Path::new(&c.project.root).join("pyproject.toml");
|
|
std::fs::read_to_string(&pyproject_path).ok().and_then(|content| {
|
|
// Simple TOML parsing for [project] name = "..."
|
|
let mut in_project = false;
|
|
for line in content.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed == "[project]" {
|
|
in_project = true;
|
|
continue;
|
|
}
|
|
if trimmed.starts_with('[') {
|
|
in_project = false;
|
|
continue;
|
|
}
|
|
if in_project && trimmed.starts_with("name") {
|
|
if let Some(val) = trimmed.split('=').nth(1) {
|
|
let name = val.trim().trim_matches('"').trim_matches('\'');
|
|
if !name.is_empty() {
|
|
return Some(name.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
})
|
|
})
|
|
})
|
|
.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();
|
|
|
|
// Collect layout items grouped by top-level directory
|
|
let mut dir_files: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
|
|
for file_doc in model.files.values() {
|
|
let path = file_doc.path.strip_prefix("./").unwrap_or(&file_doc.path);
|
|
let top_dir = path.split('/').next().unwrap_or(path);
|
|
// If file is at root level (no '/'), use the filename itself
|
|
let top = if path.contains('/') {
|
|
format!("{}/", top_dir)
|
|
} else {
|
|
path.to_string()
|
|
};
|
|
dir_files.entry(top).or_default().push(path.to_string());
|
|
}
|
|
let mut layout_items = Vec::new();
|
|
for (dir, files) in &dir_files {
|
|
let file_count = files.len();
|
|
let purpose = if dir.ends_with('/') {
|
|
format!("{} files", file_count)
|
|
} else {
|
|
"Root file".to_string()
|
|
};
|
|
layout_items.push(serde_json::json!({
|
|
"path": dir,
|
|
"purpose": purpose,
|
|
"link": format!("docs/architecture/files/{}.md", sanitize_for_link(dir.trim_end_matches('/')))
|
|
}));
|
|
}
|
|
|
|
// Collect module items for template
|
|
let mut modules_list = Vec::new();
|
|
for (module_id, module) in &model.modules {
|
|
modules_list.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))
|
|
}));
|
|
}
|
|
|
|
// Collect critical points
|
|
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 {
|
|
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 {
|
|
high_fan_out.push(serde_json::json!({
|
|
"symbol": symbol_id,
|
|
"count": symbol.metrics.fan_out,
|
|
"critical": symbol.metrics.is_critical,
|
|
}));
|
|
}
|
|
}
|
|
|
|
let cycles: Vec<_> = cycle_detector::detect_cycles(model)
|
|
.iter()
|
|
.map(|cycle| {
|
|
serde_json::json!({
|
|
"cycle_path": format!("{} → {}", cycle.join(" → "), cycle.first().unwrap_or(&String::new()))
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
// Project statistics
|
|
let project_description = format!(
|
|
"Python project with {} modules, {} files, and {} symbols.",
|
|
model.modules.len(), model.files.len(), model.symbols.len()
|
|
);
|
|
|
|
// Prepare data for template
|
|
let data = serde_json::json!({
|
|
"project_name": project_name,
|
|
"project_description": project_description,
|
|
"created_date": &today,
|
|
"updated_date": &today,
|
|
"key_decisions": ["<FILL_MANUALLY>"],
|
|
"non_goals": ["<FILL_MANUALLY>"],
|
|
"change_notes": ["<FILL_MANUALLY>"],
|
|
"db_integrations": db_integrations,
|
|
"http_integrations": http_integrations,
|
|
"queue_integrations": queue_integrations,
|
|
"storage_integrations": storage_integrations,
|
|
"ai_integrations": ai_integrations,
|
|
"rails_summary": "\n\nNo tooling information available.\n",
|
|
"layout_items": layout_items,
|
|
"modules": modules_list,
|
|
"high_fan_in": high_fan_in,
|
|
"high_fan_out": high_fan_out,
|
|
"cycles": cycles,
|
|
});
|
|
|
|
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();
|
|
let mut storage_symbols = Vec::new();
|
|
let mut ai_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());
|
|
}
|
|
if symbol.integrations_flags.storage {
|
|
storage_symbols.push(symbol.qualname.clone());
|
|
}
|
|
if symbol.integrations_flags.ai {
|
|
ai_symbols.push(symbol.qualname.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate usage examples from public symbols
|
|
let mut usage_examples = Vec::new();
|
|
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 => {
|
|
// Find __init__ method to get constructor args
|
|
let init_name = format!("{}.__init__", short_name);
|
|
let init_args = module.symbols.iter()
|
|
.find_map(|sid| {
|
|
model.symbols.get(sid).and_then(|s| {
|
|
if s.qualname == init_name || s.id == init_name {
|
|
// Extract args from __init__ signature
|
|
let args = s.signature
|
|
.find('(')
|
|
.and_then(|start| s.signature.rfind(')').map(|end| (start, end)))
|
|
.map(|(st, en)| &s.signature[st+1..en])
|
|
.unwrap_or("");
|
|
let clean = args.split(',')
|
|
.map(|a| a.split(':').next().unwrap_or("").split('=').next().unwrap_or("").trim())
|
|
.filter(|a| !a.is_empty() && *a != "self" && *a != "cls" && !a.starts_with('*'))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
Some(clean)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
})
|
|
.unwrap_or_default();
|
|
usage_examples.push(format!(
|
|
"from {} import {}\ninstance = {}({})",
|
|
module_id, short_name, short_name, init_args
|
|
));
|
|
}
|
|
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
|
|
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(),
|
|
"has_storage_integrations": !storage_symbols.is_empty(),
|
|
"has_ai_integrations": !ai_symbols.is_empty(),
|
|
"db_symbols": db_symbols,
|
|
"http_symbols": http_symbols,
|
|
"queue_symbols": queue_symbols,
|
|
"storage_symbols": storage_symbols,
|
|
"ai_symbols": ai_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();
|
|
let mut storage_integrations = Vec::new();
|
|
let mut ai_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));
|
|
}
|
|
if symbol.integrations_flags.storage {
|
|
storage_integrations.push(format!("{} in {}", symbol_id, symbol.file_id));
|
|
}
|
|
if symbol.integrations_flags.ai {
|
|
ai_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,
|
|
"storage_integrations": storage_integrations,
|
|
"ai_integrations": ai_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}}
|
|
|
|
### Storage Integrations
|
|
{{#each storage_integrations}}
|
|
- {{{this}}}
|
|
{{/each}}
|
|
|
|
### AI/ML Integrations
|
|
{{#each ai_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 items grouped by top-level directory
|
|
let mut dir_files: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
|
|
for file_doc in model.files.values() {
|
|
let path = file_doc.path.strip_prefix("./").unwrap_or(&file_doc.path);
|
|
let top_dir = path.split('/').next().unwrap_or(path);
|
|
let top = if path.contains('/') {
|
|
format!("{}/", top_dir)
|
|
} else {
|
|
path.to_string()
|
|
};
|
|
dir_files.entry(top).or_default().push(path.to_string());
|
|
}
|
|
let mut layout_items = Vec::new();
|
|
for (dir, files) in &dir_files {
|
|
let file_count = files.len();
|
|
let purpose = if dir.ends_with('/') {
|
|
format!("{} files", file_count)
|
|
} else {
|
|
"Root file".to_string()
|
|
};
|
|
layout_items.push(serde_json::json!({
|
|
"path": dir,
|
|
"purpose": purpose,
|
|
"link": format!("docs/architecture/files/{}.md", sanitize_for_link(dir.trim_end_matches('/')))
|
|
}));
|
|
}
|
|
|
|
// 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}}
|
|
- {{{cycle_path}}}
|
|
{{/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 items grouped by top-level directory
|
|
let mut dir_files: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
|
|
for file_doc in model.files.values() {
|
|
let path = file_doc.path.strip_prefix("./").unwrap_or(&file_doc.path);
|
|
let top_dir = path.split('/').next().unwrap_or(path);
|
|
let top = if path.contains('/') {
|
|
format!("{}/", top_dir)
|
|
} else {
|
|
path.to_string()
|
|
};
|
|
dir_files.entry(top).or_default().push(path.to_string());
|
|
}
|
|
let mut layout_items = Vec::new();
|
|
for (dir, files) in &dir_files {
|
|
let file_count = files.len();
|
|
let purpose = if dir.ends_with('/') {
|
|
format!("{} files", file_count)
|
|
} else {
|
|
"Root file".to_string()
|
|
};
|
|
layout_items.push(serde_json::json!({
|
|
"path": dir,
|
|
"purpose": purpose,
|
|
"link": format!("files/{}.md", sanitize_for_link(dir.trim_end_matches('/')))
|
|
}));
|
|
}
|
|
|
|
// 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,
|
|
"storage": symbol.integrations_flags.storage,
|
|
"ai": symbol.integrations_flags.ai,
|
|
},
|
|
"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}}
|
|
- Storage: {{#if integrations.storage}}yes{{else}}no{{/if}}
|
|
- AI/ML: {{#if integrations.ai}}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))
|
|
}
|
|
} |