//! 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}}} ## 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:** wtismycode (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}} ### Storage Integrations {{#each storage_integrations}} - {{{this}}} {{/each}} ### AI/ML Integrations {{#each ai_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}} {{#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 { // 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> = 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": [""], "non_goals": [""], "change_notes": [""], "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 { // 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::>() .join(", "); let example_args = if clean_args.is_empty() { String::new() } else { clean_args.split(", ").map(|a| { if a.starts_with('*') { "..." } else { a } }).collect::>().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::>() .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 { // 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 { // 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 items grouped by top-level directory let mut dir_files: std::collections::BTreeMap> = 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 { // 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": cycle_detector::detect_cycles(model) .iter() .map(|cycle| { serde_json::json!({ "cycle_path": format!("{} → {}", cycle.join(" → "), cycle.first().unwrap_or(&String::new())) }) }) .collect::>(), }); // 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 { // Collect layout items grouped by top-level directory let mut dir_files: std::collections::BTreeMap> = 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 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, "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#" ### `{{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}} - Storage: {{#if integrations.storage}}yes{{else}}no{{/if}} - AI/ML: {{#if integrations.ai}}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)) } }