Skip to main content

reflex/pulse/
wiki.rs

1//! Wiki generation: per-module documentation pages
2//!
3//! Generates a living wiki page for each detected module (directory) in the codebase.
4//! Pages include structural sections (dependencies, dependents, key symbols, metrics)
5//! and optional LLM-generated summaries.
6
7use anyhow::{Context, Result};
8use rayon::prelude::*;
9use rusqlite::Connection;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13use crate::cache::CacheManager;
14use crate::dependency::DependencyIndex;
15use crate::models::{Language, SymbolKind};
16use crate::parsers::ParserFactory;
17use crate::query::{QueryEngine, QueryFilter};
18use crate::semantic::context::CodebaseContext;
19use crate::semantic::providers::LlmProvider;
20
21use super::llm_cache::LlmCache;
22use super::narrate;
23
24/// A detected module in the codebase
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ModuleDefinition {
27    /// Module path (e.g., "src", "tests", "src/parsers")
28    pub path: String,
29    /// Module tier: 1 = top-level, 2 = depth-2/3
30    pub tier: u8,
31    /// Number of files in this module
32    pub file_count: usize,
33    /// Total line count
34    pub total_lines: usize,
35    /// Languages present in this module
36    pub languages: Vec<String>,
37}
38
39/// A generated wiki page for a module
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct WikiPage {
42    pub module_path: String,
43    pub title: String,
44    pub sections: WikiSections,
45}
46
47/// Structural sections of a wiki page (all built without LLM)
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct WikiSections {
50    pub summary: Option<String>,
51    pub structure: String,
52    pub dependencies: String,
53    pub dependents: String,
54    pub dependency_diagram: Option<String>,
55    pub circular_deps: Option<String>,
56    pub key_symbols: String,
57    pub metrics: String,
58    pub recent_changes: Option<String>,
59}
60
61/// Configuration for module discovery depth and filtering
62#[derive(Debug, Clone)]
63pub struct ModuleDiscoveryConfig {
64    /// Max tier level (1 = top-level only, 2 = include sub-modules)
65    pub max_depth: u8,
66    /// Minimum file count for a module to be included
67    pub min_files: usize,
68}
69
70impl Default for ModuleDiscoveryConfig {
71    fn default() -> Self {
72        Self { max_depth: 2, min_files: 1 }
73    }
74}
75
76/// Detect modules in the codebase using CodebaseContext
77///
78/// Returns both top-level directories (Tier 1) and their immediate
79/// sub-directories with 3+ files (Tier 2). This produces granular modules
80/// like `src/parsers`, `src/semantic`, `src/pulse` instead of just `src`.
81///
82/// Use `config` to control discovery depth and minimum file filtering.
83pub fn detect_modules(cache: &CacheManager, config: &ModuleDiscoveryConfig) -> Result<Vec<ModuleDefinition>> {
84    let context = CodebaseContext::extract(cache)
85        .context("Failed to extract codebase context")?;
86
87    let db_path = cache.path().join("meta.db");
88    let conn = Connection::open(&db_path)?;
89
90    let mut modules = Vec::new();
91
92    // Tier 1: top-level directories
93    for dir in &context.top_level_dirs {
94        let dir_path = dir.trim_end_matches('/');
95        if let Some(module) = build_module_def(&conn, dir_path, 1)? {
96            if module.file_count >= config.min_files {
97                modules.push(module);
98            }
99        }
100    }
101
102    // Tier 2: discover sub-modules under each Tier 1 module
103    if config.max_depth >= 2 {
104        let tier1_paths: Vec<String> = modules.iter().map(|m| m.path.clone()).collect();
105        for parent in &tier1_paths {
106            let sub_modules = discover_sub_modules(&conn, parent)?;
107            for sub_path in sub_modules {
108                // Skip exact duplicates
109                if modules.iter().any(|m| m.path == sub_path) {
110                    continue;
111                }
112                if let Some(module) = build_module_def(&conn, &sub_path, 2)? {
113                    if module.file_count >= config.min_files {
114                        modules.push(module);
115                    }
116                }
117            }
118        }
119
120        // Also include common_paths that aren't covered by an exact match
121        for path in &context.common_paths {
122            let path_str = path.trim_end_matches('/');
123            if modules.iter().any(|m| m.path == path_str) {
124                continue;
125            }
126            if let Some(module) = build_module_def(&conn, path_str, 2)? {
127                if module.file_count >= config.min_files {
128                    modules.push(module);
129                }
130            }
131        }
132    }
133
134    // Sort by path for deterministic output
135    modules.sort_by(|a, b| a.path.cmp(&b.path));
136
137    Ok(modules)
138}
139
140/// Discover immediate child directories under a parent module that have 3+ files.
141///
142/// Queries meta.db for files under `parent_path/` and groups them by their
143/// immediate subdirectory. Returns paths like `src/parsers`, `src/semantic`.
144fn discover_sub_modules(conn: &Connection, parent_path: &str) -> Result<Vec<String>> {
145    let pattern = format!("{}/%", parent_path);
146    let prefix_len = parent_path.len() + 1; // +1 for the '/'
147
148    let mut stmt = conn.prepare(
149        "SELECT
150            SUBSTR(path, 1, ?2 + INSTR(SUBSTR(path, ?2 + 1), '/') - 1) AS sub_dir,
151            COUNT(*) AS file_count
152         FROM files
153         WHERE path LIKE ?1
154           AND INSTR(SUBSTR(path, ?2 + 1), '/') > 0
155         GROUP BY sub_dir
156         HAVING file_count >= 3
157         ORDER BY file_count DESC"
158    )?;
159
160    let rows: Vec<String> = stmt.query_map(
161        rusqlite::params![pattern, prefix_len],
162        |row| row.get(0),
163    )?.filter_map(|r| r.ok()).collect();
164
165    Ok(rows)
166}
167
168/// Generate a wiki page for a single module
169pub fn generate_wiki_page(
170    cache: &CacheManager,
171    module: &ModuleDefinition,
172    all_modules: &[ModuleDefinition],
173    diff: Option<&super::diff::SnapshotDiff>,
174    no_llm: bool,
175    provider: Option<&dyn LlmProvider>,
176    llm_cache: Option<&LlmCache>,
177    snapshot_id: &str,
178) -> Result<WikiPage> {
179    let db_path = cache.path().join("meta.db");
180    let conn = Connection::open(&db_path)?;
181    let deps_index = DependencyIndex::new(cache.clone());
182    let query_engine = QueryEngine::new(cache.clone());
183
184    // Find child modules of this module
185    let prefix = format!("{}/", module.path);
186    let child_modules: Vec<&ModuleDefinition> = all_modules.iter()
187        .filter(|m| m.path.starts_with(&prefix) && m.path != module.path)
188        .collect();
189
190    // Build structural sections
191    let structure = build_structure_section(&conn, &module.path, &child_modules)?;
192    let dependencies = build_dependencies_section(&conn, &module.path, all_modules)?;
193    let dependents = build_dependents_section(&conn, &deps_index, &module.path, all_modules)?;
194    let dependency_diagram = build_dependency_diagram(&conn, &module.path, all_modules);
195    let circular_deps = build_circular_deps_section(&deps_index, &module.path);
196    let key_symbols = build_key_symbols_section(&conn, &module.path, &query_engine);
197    let metrics = build_metrics_section(module, &conn)?;
198    let recent_changes = diff.map(|d| build_recent_changes(d, &module.path));
199
200    // Generate LLM summary when provider is available
201    let summary = if !no_llm {
202        if let (Some(provider), Some(llm_cache)) = (provider, llm_cache) {
203            // Build combined structural context for the summary
204            let mut context = String::new();
205            context.push_str(&format!("Module: {}\n\n", module.path));
206            context.push_str(&format!("## Structure\n{}\n\n", structure));
207            context.push_str(&format!("## Dependencies\n{}\n\n", dependencies));
208            context.push_str(&format!("## Dependents\n{}\n\n", dependents));
209            context.push_str(&format!("## Key Symbols\n{}\n\n", key_symbols));
210            context.push_str(&format!("## Metrics\n{}\n", metrics));
211
212            narrate::narrate_section(
213                provider,
214                narrate::wiki_system_prompt(),
215                &context,
216                llm_cache,
217                snapshot_id,
218                &module.path,
219            )
220        } else {
221            None
222        }
223    } else {
224        None
225    };
226
227    Ok(WikiPage {
228        module_path: module.path.clone(),
229        title: format!("{}/", module.path),
230        sections: WikiSections {
231            summary,
232            structure,
233            dependencies,
234            dependents,
235            dependency_diagram,
236            circular_deps,
237            key_symbols,
238            metrics,
239            recent_changes,
240        },
241    })
242}
243
244/// Generate wiki pages for all detected modules
245///
246/// `provider` and `llm_cache` are created by the caller (site.rs or CLI handler).
247pub fn generate_all_pages(
248    cache: &CacheManager,
249    diff: Option<&super::diff::SnapshotDiff>,
250    no_llm: bool,
251    snapshot_id: &str,
252    provider: Option<&dyn LlmProvider>,
253    llm_cache: Option<&LlmCache>,
254    discovery_config: &ModuleDiscoveryConfig,
255) -> Result<Vec<WikiPage>> {
256    let modules = detect_modules(cache, discovery_config)?;
257    let mut pages = Vec::new();
258
259    if provider.is_some() {
260        eprintln!("Generating wiki summaries...");
261    }
262
263    for module in &modules {
264        match generate_wiki_page(
265            cache,
266            module,
267            &modules,
268            diff,
269            no_llm,
270            provider,
271            llm_cache,
272            snapshot_id,
273        ) {
274            Ok(page) => pages.push(page),
275            Err(e) => {
276                log::warn!("Failed to generate wiki page for {}: {}", module.path, e);
277            }
278        }
279    }
280
281    Ok(pages)
282}
283
284/// A wiki page with pre-built narration context for batch LLM dispatch
285pub struct WikiPageWithContext {
286    pub page: WikiPage,
287    /// Combined structural context string for LLM narration (None if too brief)
288    pub narration_context: Option<String>,
289}
290
291/// Generate all wiki pages structurally (no LLM), using rayon for parallelism.
292///
293/// Each module's structural sections are built concurrently. Returns pages
294/// with `summary: None` and pre-built narration contexts for later batch dispatch.
295pub fn generate_all_pages_structural(
296    cache: &CacheManager,
297    diff: Option<&super::diff::SnapshotDiff>,
298    discovery_config: &ModuleDiscoveryConfig,
299) -> Result<Vec<WikiPageWithContext>> {
300    let modules = detect_modules(cache, discovery_config)?;
301
302    // Use rayon par_iter for concurrent structural builds.
303    // Each task opens its own DB connection and QueryEngine (safe for parallel use).
304    let results: Vec<_> = modules.par_iter().map(|module| {
305        let db_path = cache.path().join("meta.db");
306        let conn = match Connection::open(&db_path) {
307            Ok(c) => c,
308            Err(e) => return Err(anyhow::anyhow!("Failed to open meta.db for {}: {}", module.path, e)),
309        };
310        let deps_index = DependencyIndex::new(cache.clone());
311        let query_engine = QueryEngine::new(cache.clone());
312
313        let prefix = format!("{}/", module.path);
314        let child_modules: Vec<&ModuleDefinition> = modules.iter()
315            .filter(|m| m.path.starts_with(&prefix) && m.path != module.path)
316            .collect();
317
318        let structure = build_structure_section(&conn, &module.path, &child_modules)?;
319        let dependencies = build_dependencies_section(&conn, &module.path, &modules)?;
320        let dependents = build_dependents_section(&conn, &deps_index, &module.path, &modules)?;
321        let dependency_diagram = build_dependency_diagram(&conn, &module.path, &modules);
322        let circular_deps = build_circular_deps_section(&deps_index, &module.path);
323        let key_symbols = build_key_symbols_section(&conn, &module.path, &query_engine);
324        let metrics = build_metrics_section(module, &conn)?;
325        let recent_changes = diff.map(|d| build_recent_changes(d, &module.path));
326
327        // Build narration context string
328        let mut context = String::new();
329        context.push_str(&format!("Module: {}\n\n", module.path));
330        context.push_str(&format!("## Structure\n{}\n\n", structure));
331        context.push_str(&format!("## Dependencies\n{}\n\n", dependencies));
332        context.push_str(&format!("## Dependents\n{}\n\n", dependents));
333        context.push_str(&format!("## Key Symbols\n{}\n\n", key_symbols));
334        context.push_str(&format!("## Metrics\n{}\n", metrics));
335
336        let narration_context = Some(context);
337
338        Ok(WikiPageWithContext {
339            page: WikiPage {
340                module_path: module.path.clone(),
341                title: format!("{}/", module.path),
342                sections: WikiSections {
343                    summary: None,
344                    structure,
345                    dependencies,
346                    dependents,
347                    dependency_diagram,
348                    circular_deps,
349                    key_symbols,
350                    metrics,
351                    recent_changes,
352                },
353            },
354            narration_context,
355        })
356    }).collect();
357
358    // Collect results, logging failures
359    let mut pages = Vec::new();
360    for result in results {
361        match result {
362            Ok(page) => pages.push(page),
363            Err(e) => log::warn!("Failed to generate wiki page: {}", e),
364        }
365    }
366
367    // Sort by module path for deterministic output
368    pages.sort_by(|a, b| a.page.module_path.cmp(&b.page.module_path));
369
370    Ok(pages)
371}
372
373/// Render wiki pages as (filename, markdown) pairs
374pub fn render_wiki_markdown(pages: &[WikiPage]) -> Vec<(String, String)> {
375    pages.iter().map(|page| {
376        let filename = page.module_path.replace('/', "_") + ".md";
377        let mut md = String::new();
378
379        md.push_str(&format!("# {}\n\n", page.title));
380
381        if let Some(summary) = &page.sections.summary {
382            md.push_str(summary);
383            md.push_str("\n\n");
384        }
385
386        md.push_str("## Structure\n\n");
387        md.push_str(&page.sections.structure);
388        md.push_str("\n\n");
389
390        if let Some(diagram) = &page.sections.dependency_diagram {
391            md.push_str("## Dependency Diagram\n\n");
392            md.push_str("```mermaid\n");
393            md.push_str(diagram);
394            md.push_str("```\n\n");
395        }
396
397        md.push_str("## Dependencies\n\n");
398        md.push_str(&page.sections.dependencies);
399        md.push_str("\n\n");
400
401        md.push_str("## Dependents\n\n");
402        md.push_str(&page.sections.dependents);
403        md.push_str("\n\n");
404
405        if let Some(circular) = &page.sections.circular_deps {
406            md.push_str("## Circular Dependencies\n\n");
407            md.push_str(circular);
408            md.push_str("\n\n");
409        }
410
411        md.push_str("## Key Symbols\n\n");
412        md.push_str(&page.sections.key_symbols);
413        md.push_str("\n\n");
414
415        md.push_str("## Metrics\n\n");
416        md.push_str(&page.sections.metrics);
417        md.push_str("\n\n");
418
419        if let Some(changes) = &page.sections.recent_changes {
420            md.push_str("## Recent Changes\n\n");
421            md.push_str(changes);
422            md.push_str("\n\n");
423        }
424
425        (filename, md)
426    }).collect()
427}
428
429// --- Private helpers ---
430
431/// Build a focused mermaid dependency diagram for a single module.
432/// Shows the module as center node with direct deps and dependents.
433fn build_dependency_diagram(
434    conn: &Connection,
435    module_path: &str,
436    all_modules: &[ModuleDefinition],
437) -> Option<String> {
438    let pattern = format!("{}/%", module_path);
439
440    // Collect outgoing deps (module_path → target_module)
441    let mut outgoing: HashMap<String, usize> = HashMap::new();
442    if let Ok(mut stmt) = conn.prepare(
443        "SELECT f2.path FROM file_dependencies fd
444         JOIN files f1 ON fd.file_id = f1.id
445         JOIN files f2 ON fd.resolved_file_id = f2.id
446         WHERE f1.path LIKE ?1 AND f2.path NOT LIKE ?1"
447    ) {
448        if let Ok(rows) = stmt.query_map([&pattern], |row| row.get::<_, String>(0)) {
449            for dep_file in rows.flatten() {
450                let target = find_owning_module(&dep_file, all_modules);
451                *outgoing.entry(target).or_insert(0) += 1;
452            }
453        }
454    }
455
456    // Collect incoming deps (source_module → module_path)
457    let mut incoming: HashMap<String, usize> = HashMap::new();
458    if let Ok(mut stmt) = conn.prepare(
459        "SELECT f1.path FROM file_dependencies fd
460         JOIN files f1 ON fd.file_id = f1.id
461         JOIN files f2 ON fd.resolved_file_id = f2.id
462         WHERE f2.path LIKE ?1 AND f1.path NOT LIKE ?1"
463    ) {
464        if let Ok(rows) = stmt.query_map([&pattern], |row| row.get::<_, String>(0)) {
465            for dep_file in rows.flatten() {
466                let source = find_owning_module(&dep_file, all_modules);
467                *incoming.entry(source).or_insert(0) += 1;
468            }
469        }
470    }
471
472    if outgoing.is_empty() && incoming.is_empty() {
473        return None;
474    }
475
476    let mut diagram = String::new();
477    diagram.push_str("graph LR\n");
478
479    // Sanitize node IDs with m_ prefix to avoid Mermaid reserved word collisions
480    let sanitize = |s: &str| -> String {
481        format!("m_{}", s.replace(['/', '.', '-', ' '], "_"))
482    };
483
484    let center_id = sanitize(module_path);
485    diagram.push_str(&format!("    {}[\"<b>{}/</b>\"]\n", center_id, module_path));
486    diagram.push_str(&format!("    style {} fill:#a78bfa,color:#0d0d0d,stroke:#a78bfa\n", center_id));
487
488    // Track all nodes for clickable links
489    let mut all_node_paths: Vec<String> = vec![module_path.to_string()];
490
491    // Outgoing edges (this module depends on)
492    let mut out_sorted: Vec<_> = outgoing.into_iter().collect();
493    out_sorted.sort_by(|a, b| b.1.cmp(&a.1));
494    for (target, count) in out_sorted.iter().take(8) {
495        let target_id = sanitize(target);
496        diagram.push_str(&format!("    {}[\"{}/\"]\n", target_id, target));
497        diagram.push_str(&format!("    {} -->|{}| {}\n", center_id, count, target_id));
498        all_node_paths.push(target.clone());
499    }
500
501    // Incoming edges (modules that depend on this)
502    let mut in_sorted: Vec<_> = incoming.into_iter().collect();
503    in_sorted.sort_by(|a, b| b.1.cmp(&a.1));
504    for (source, count) in in_sorted.iter().take(8) {
505        let source_id = sanitize(source);
506        // Avoid re-declaring if already declared as outgoing target
507        if !out_sorted.iter().any(|(t, _)| t == source) {
508            diagram.push_str(&format!("    {}[\"{}/\"]\n", source_id, source));
509        }
510        diagram.push_str(&format!("    {} -->|{}| {}\n", source_id, count, center_id));
511        if !all_node_paths.contains(source) {
512            all_node_paths.push(source.clone());
513        }
514    }
515
516    // High-contrast styling
517    diagram.push_str("    classDef default fill:#1a1a2e,stroke:#a78bfa,color:#e0e0e0\n");
518
519    // Clickable nodes → wiki pages
520    for node_path in &all_node_paths {
521        let node_id = sanitize(node_path);
522        let slug = node_path.replace('/', "-");
523        diagram.push_str(&format!("    click {} \"/wiki/{}/\"\n", node_id, slug));
524    }
525
526    Some(diagram)
527}
528
529/// Build a circular dependencies section for a module.
530/// Detects cycles that include files within this module's path.
531fn build_circular_deps_section(deps_index: &DependencyIndex, module_path: &str) -> Option<String> {
532    let cycles = match deps_index.detect_circular_dependencies() {
533        Ok(c) => c,
534        Err(_) => return None,
535    };
536
537    if cycles.is_empty() {
538        return None;
539    }
540
541    // Collect all file IDs involved in cycles
542    let all_ids: Vec<i64> = cycles.iter().flatten().copied().collect();
543    let path_map = match deps_index.get_file_paths(&all_ids) {
544        Ok(m) => m,
545        Err(_) => return None,
546    };
547
548    let prefix = format!("{}/", module_path);
549
550    // Filter cycles that involve at least one file in this module
551    let mut relevant_cycles: Vec<Vec<String>> = Vec::new();
552    for cycle in &cycles {
553        let paths: Vec<String> = cycle.iter()
554            .filter_map(|id| path_map.get(id).cloned())
555            .collect();
556
557        if paths.iter().any(|p| p.starts_with(&prefix)) {
558            relevant_cycles.push(paths);
559        }
560    }
561
562    if relevant_cycles.is_empty() {
563        return None;
564    }
565
566    let mut content = String::new();
567    content.push_str(&format!(
568        "**{} circular {}** involving this module:\n\n",
569        relevant_cycles.len(),
570        if relevant_cycles.len() == 1 { "dependency" } else { "dependencies" }
571    ));
572
573    for (i, cycle) in relevant_cycles.iter().take(10).enumerate() {
574        let short_paths: Vec<String> = cycle.iter()
575            .map(|p| p.rsplit('/').next().unwrap_or(p).to_string())
576            .collect();
577        content.push_str(&format!("{}. {}\n", i + 1, short_paths.join(" → ")));
578    }
579
580    if relevant_cycles.len() > 10 {
581        content.push_str(&format!("\n... and {} more. Run `rfx analyze --circular` for full list.\n", relevant_cycles.len() - 10));
582    }
583
584    Some(content)
585}
586
587fn build_module_def(conn: &Connection, path: &str, tier: u8) -> Result<Option<ModuleDefinition>> {
588    let pattern = format!("{}/%", path);
589
590    let file_count: usize = conn.query_row(
591        "SELECT COUNT(*) FROM files WHERE path LIKE ?1 OR path = ?2",
592        rusqlite::params![&pattern, path],
593        |row| row.get(0),
594    )?;
595
596    if file_count == 0 {
597        return Ok(None);
598    }
599
600    let total_lines: usize = conn.query_row(
601        "SELECT COALESCE(SUM(line_count), 0) FROM files WHERE path LIKE ?1 OR path = ?2",
602        rusqlite::params![&pattern, path],
603        |row| row.get(0),
604    )?;
605
606    let mut stmt = conn.prepare(
607        "SELECT DISTINCT language FROM files WHERE (path LIKE ?1 OR path = ?2) AND language IS NOT NULL"
608    )?;
609    let languages: Vec<String> = stmt.query_map(rusqlite::params![&pattern, path], |row| row.get(0))?
610        .collect::<Result<Vec<_>, _>>()?;
611
612    Ok(Some(ModuleDefinition {
613        path: path.to_string(),
614        tier,
615        file_count,
616        total_lines,
617        languages,
618    }))
619}
620
621fn build_structure_section(
622    conn: &Connection,
623    module_path: &str,
624    child_modules: &[&ModuleDefinition],
625) -> Result<String> {
626    let pattern = format!("{}/%", module_path);
627
628    let mut content = String::new();
629
630    // Show sub-modules if this module has children — linked to their wiki pages
631    if !child_modules.is_empty() {
632        content.push_str("### Sub-modules\n\n");
633        for child in child_modules {
634            let short_name = child.path.strip_prefix(module_path)
635                .unwrap_or(&child.path)
636                .trim_start_matches('/');
637            let child_slug = child.path.replace('/', "-");
638            content.push_str(&format!(
639                "- [**{}/**](/wiki/{}/) — {} files, {} lines ({})\n",
640                short_name,
641                child_slug,
642                child.file_count,
643                child.total_lines,
644                child.languages.join(", "),
645            ));
646        }
647        content.push('\n');
648    }
649
650    // Group files by immediate subdirectory with line counts
651    let prefix_len = module_path.len() + 1;
652    let mut stmt = conn.prepare(
653        "SELECT path, language, COALESCE(line_count, 0) FROM files
654         WHERE path LIKE ?1
655         ORDER BY line_count DESC"
656    )?;
657
658    let files: Vec<(String, Option<String>, i64)> = stmt.query_map([&pattern], |row| {
659        Ok((row.get(0)?, row.get(1)?, row.get(2)?))
660    })?.collect::<Result<Vec<_>, _>>()?;
661
662    // Group by immediate subdirectory
663    let mut by_subdir: HashMap<String, (usize, i64)> = HashMap::new(); // subdir -> (file_count, total_lines)
664    let mut direct_files: Vec<(String, i64)> = Vec::new();
665
666    for (path, _, lines) in &files {
667        let rel = &path[prefix_len.min(path.len())..];
668        if let Some(slash_pos) = rel.find('/') {
669            let subdir = &rel[..slash_pos];
670            let entry = by_subdir.entry(subdir.to_string()).or_insert((0, 0));
671            entry.0 += 1;
672            entry.1 += lines;
673        } else {
674            direct_files.push((path.clone(), *lines));
675        }
676    }
677
678    // Language distribution
679    let mut by_lang: HashMap<String, usize> = HashMap::new();
680    for (_, lang, _) in &files {
681        let lang = lang.as_deref().unwrap_or("other");
682        *by_lang.entry(lang.to_string()).or_insert(0) += 1;
683    }
684
685    content.push_str("| Language | Files |\n|---|---|\n");
686    let mut lang_counts: Vec<_> = by_lang.into_iter().collect();
687    lang_counts.sort_by(|a, b| b.1.cmp(&a.1));
688    for (lang, count) in &lang_counts {
689        content.push_str(&format!("| {} | {} |\n", lang, count));
690    }
691
692    // Subdirectory breakdown
693    if !by_subdir.is_empty() {
694        let mut subdirs: Vec<_> = by_subdir.into_iter().collect();
695        subdirs.sort_by(|a, b| b.1.1.cmp(&a.1.1)); // sort by lines desc
696
697        content.push_str("\n### Directories\n\n");
698        content.push_str("| Directory | Files | Lines |\n|---|---|---|\n");
699        for (subdir, (count, lines)) in subdirs.iter().take(20) {
700            content.push_str(&format!("| {}/ | {} | {} |\n", subdir, count, lines));
701        }
702    }
703
704    // Top 10 largest files, with expandable overflow
705    content.push_str("\n### Largest Files\n\n");
706    let all_sorted: Vec<_> = files.iter()
707        .map(|(path, _, lines)| (path.as_str(), *lines))
708        .collect();
709    for (path, lines) in all_sorted.iter().take(10) {
710        let short = path.strip_prefix(&format!("{}/", module_path)).unwrap_or(path);
711        content.push_str(&format!("- `{}` ({} lines)\n", short, lines));
712    }
713
714    let total = files.len();
715    if total > 10 {
716        content.push_str(&format!(
717            "\n<details><summary><strong>Show {} more files</strong></summary>\n\n",
718            total - 10
719        ));
720        for (path, lines) in all_sorted.iter().skip(10) {
721            let short = path.strip_prefix(&format!("{}/", module_path)).unwrap_or(path);
722            content.push_str(&format!("- `{}` ({} lines)\n", short, lines));
723        }
724        content.push_str("\n</details>\n");
725    }
726
727    Ok(content)
728}
729
730fn build_dependencies_section(
731    conn: &Connection,
732    module_path: &str,
733    all_modules: &[ModuleDefinition],
734) -> Result<String> {
735    let pattern = format!("{}/%", module_path);
736    let mut stmt = conn.prepare(
737        "SELECT DISTINCT f2.path
738         FROM file_dependencies fd
739         JOIN files f1 ON fd.file_id = f1.id
740         JOIN files f2 ON fd.resolved_file_id = f2.id
741         WHERE f1.path LIKE ?1 AND f2.path NOT LIKE ?1
742         ORDER BY f2.path"
743    )?;
744
745    let deps: Vec<String> = stmt.query_map([&pattern], |row| row.get(0))?
746        .collect::<Result<Vec<_>, _>>()?;
747
748    if deps.is_empty() {
749        return Ok("No outgoing dependencies detected.".to_string());
750    }
751
752    // Group deps by target module
753    let mut by_module: HashMap<String, Vec<String>> = HashMap::new();
754    for dep in &deps {
755        let target_module = find_owning_module(dep, all_modules);
756        by_module.entry(target_module).or_default().push(dep.clone());
757    }
758
759    let mut groups: Vec<_> = by_module.into_iter().collect();
760    groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
761
762    let total_files = deps.len();
763    let total_modules = groups.len();
764
765    let mut content = format!(
766        "Depends on **{} files** across **{} modules**.\n\n",
767        total_files, total_modules
768    );
769
770    for (module, files) in &groups {
771        let module_slug = module.replace('/', "-");
772        content.push_str(&format!("**[{}/](@/wiki/{}.md)** ({} files):\n", module, module_slug, files.len()));
773        for f in files.iter().take(5) {
774            let short = f.rsplit('/').next().unwrap_or(f);
775            content.push_str(&format!("- `{}`\n", short));
776        }
777        if files.len() > 5 {
778            content.push_str(&format!("- ... and {} more\n", files.len() - 5));
779        }
780        content.push('\n');
781    }
782
783    Ok(content)
784}
785
786fn build_dependents_section(
787    conn: &Connection,
788    _deps_index: &DependencyIndex,
789    module_path: &str,
790    all_modules: &[ModuleDefinition],
791) -> Result<String> {
792    let pattern = format!("{}/%", module_path);
793    let mut stmt = conn.prepare(
794        "SELECT DISTINCT f1.path
795         FROM file_dependencies fd
796         JOIN files f1 ON fd.file_id = f1.id
797         JOIN files f2 ON fd.resolved_file_id = f2.id
798         WHERE f2.path LIKE ?1 AND f1.path NOT LIKE ?1
799         ORDER BY f1.path"
800    )?;
801
802    let dependents: Vec<String> = stmt.query_map([&pattern], |row| row.get(0))?
803        .collect::<Result<Vec<_>, _>>()?;
804
805    if dependents.is_empty() {
806        return Ok("No incoming dependencies detected.".to_string());
807    }
808
809    // Group by source module
810    let mut by_module: HashMap<String, Vec<String>> = HashMap::new();
811    for dep in &dependents {
812        let source_module = find_owning_module(dep, all_modules);
813        by_module.entry(source_module).or_default().push(dep.clone());
814    }
815
816    let mut groups: Vec<_> = by_module.into_iter().collect();
817    groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
818
819    let total_files = dependents.len();
820    let total_modules = groups.len();
821
822    let mut content = format!(
823        "Used by **{} files** across **{} modules**.\n\n",
824        total_files, total_modules
825    );
826
827    for (module, files) in &groups {
828        let module_slug = module.replace('/', "-");
829        content.push_str(&format!("**[{}/](@/wiki/{}.md)** ({} files):\n", module, module_slug, files.len()));
830        for f in files.iter().take(5) {
831            let short = f.rsplit('/').next().unwrap_or(f);
832            content.push_str(&format!("- `{}`\n", short));
833        }
834        if files.len() > 5 {
835            content.push_str(&format!("- ... and {} more\n", files.len() - 5));
836        }
837        content.push('\n');
838    }
839
840    Ok(content)
841}
842
843/// Language keywords and common variable names that are noise in "Key Symbols" rankings.
844/// These appear in thousands of files and tell users nothing about the module.
845const SYMBOL_BLOCKLIST: &[&str] = &[
846    // Multi-language keywords
847    "return", "this", "self", "super", "new", "null", "true", "false", "none",
848    "class", "function", "var", "let", "const", "static", "public", "private",
849    "protected", "abstract", "virtual", "override", "final", "async", "await",
850    "import", "export", "module", "package", "namespace", "use", "from", "as",
851    "if", "else", "for", "while", "do", "switch", "case", "default", "break",
852    "continue", "try", "catch", "throw", "throws", "finally", "yield",
853    "void", "int", "bool", "string", "float", "double", "char", "byte",
854    "struct", "enum", "trait", "impl", "interface", "type", "where",
855    // Common generic variable names
856    "data", "value", "name", "key", "item", "items", "list", "result",
857    "error", "err", "msg", "args", "opts", "params", "config", "options",
858    "index", "count", "size", "length", "path", "file", "line", "text",
859    "input", "output", "request", "response", "context", "state", "props",
860    "init", "main", "run", "get", "set", "add", "delete", "update", "create",
861    "test", "setup", "describe", "expect",
862];
863
864/// Symbol kinds considered high-value for "Key definitions" rankings.
865/// These represent meaningful domain abstractions, not individual variables.
866const PRIORITY_SYMBOL_KINDS: &[&str] = &[
867    "Function", "Struct", "Class", "Trait", "Interface",
868    "Enum", "Macro", "Type", "Constant",
869];
870
871/// Extract a doc comment preceding (or following, for Python) a symbol definition.
872///
873/// Walks backwards from `start_line` to collect contiguous comment lines, skipping
874/// attributes/decorators. For Python, walks forward to find triple-quoted docstrings.
875/// Returns the cleaned comment text with syntax prefixes stripped, or None.
876fn extract_doc_comment(source: &str, start_line: usize, language: &Language) -> Option<String> {
877    let lines: Vec<&str> = source.lines().collect();
878    if start_line == 0 || start_line > lines.len() {
879        return None;
880    }
881
882    // Python: walk forward from the definition line to find a docstring
883    if matches!(language, Language::Python) {
884        // Look at lines after the def/class line for a triple-quoted docstring
885        let search_start = start_line; // start_line is 1-indexed, so index = start_line - 1 is the def line
886        for i in search_start..lines.len().min(search_start + 3) {
887            let trimmed = lines[i].trim();
888            if trimmed.is_empty() {
889                continue;
890            }
891            // Check for triple-quoted docstring opening
892            if trimmed.starts_with("\"\"\"") || trimmed.starts_with("'''") {
893                let quote = &trimmed[..3];
894                // Single-line docstring: """text"""
895                if trimmed.len() > 6 && trimmed.ends_with(quote) {
896                    let inner = trimmed[3..trimmed.len() - 3].trim();
897                    if !inner.is_empty() {
898                        return Some(inner.to_string());
899                    }
900                }
901                // Multi-line docstring
902                let mut doc_lines = Vec::new();
903                let first_content = trimmed[3..].trim();
904                if !first_content.is_empty() {
905                    doc_lines.push(first_content.to_string());
906                }
907                for j in (i + 1)..lines.len() {
908                    let line = lines[j].trim();
909                    if line.contains(quote) {
910                        let before_close = line.trim_end_matches(quote).trim();
911                        if !before_close.is_empty() {
912                            doc_lines.push(before_close.to_string());
913                        }
914                        break;
915                    }
916                    doc_lines.push(line.to_string());
917                }
918                let result = doc_lines.join("\n").trim().to_string();
919                if !result.is_empty() {
920                    return Some(result);
921                }
922            }
923            break; // Non-empty, non-docstring line — no docstring
924        }
925        return None;
926    }
927
928    // All other languages: walk backwards from the line before the symbol
929    let mut idx = start_line.saturating_sub(2); // Convert to 0-indexed, then go one line up
930    let mut comment_lines: Vec<String> = Vec::new();
931
932    // Skip attributes/decorators walking backwards
933    loop {
934        if idx >= lines.len() {
935            break;
936        }
937        let trimmed = lines[idx].trim();
938        // Rust attributes: #[...] or #![...]
939        if trimmed.starts_with("#[") || trimmed.starts_with("#![") {
940            if idx == 0 { return None; }
941            idx -= 1;
942            continue;
943        }
944        // Java/Kotlin/Python-style decorators: @Something
945        if trimmed.starts_with('@') && trimmed.len() > 1 && trimmed[1..].starts_with(|c: char| c.is_alphabetic()) {
946            if idx == 0 { return None; }
947            idx -= 1;
948            continue;
949        }
950        // PHP attributes: #[Attribute]
951        if trimmed.starts_with("#[") {
952            if idx == 0 { return None; }
953            idx -= 1;
954            continue;
955        }
956        break;
957    }
958
959    // Determine comment style based on language
960    match language {
961        Language::Rust => {
962            // Rust: /// or //! line comments, or /** */ block comments
963            // Check for block comment ending on this line first
964            if idx < lines.len() && lines[idx].trim().ends_with("*/") {
965                return extract_block_comment(&lines, idx, "/**");
966            }
967            // Line comments: /// or //!
968            while idx < lines.len() {
969                let trimmed = lines[idx].trim();
970                if trimmed.starts_with("///") {
971                    let content = trimmed.trim_start_matches('/').trim();
972                    comment_lines.push(content.to_string());
973                } else if trimmed.starts_with("//!") {
974                    let content = trimmed[3..].trim().to_string();
975                    comment_lines.push(content);
976                } else {
977                    break;
978                }
979                if idx == 0 { break; }
980                idx -= 1;
981            }
982        }
983        Language::Go => {
984            // Go: // comment lines before func
985            while idx < lines.len() {
986                let trimmed = lines[idx].trim();
987                if trimmed.starts_with("//") {
988                    let content = trimmed[2..].trim().to_string();
989                    comment_lines.push(content);
990                } else {
991                    break;
992                }
993                if idx == 0 { break; }
994                idx -= 1;
995            }
996        }
997        Language::Ruby => {
998            // Ruby: # comment lines
999            while idx < lines.len() {
1000                let trimmed = lines[idx].trim();
1001                if trimmed.starts_with('#') && !trimmed.starts_with("#!") {
1002                    let content = trimmed[1..].trim().to_string();
1003                    comment_lines.push(content);
1004                } else {
1005                    break;
1006                }
1007                if idx == 0 { break; }
1008                idx -= 1;
1009            }
1010        }
1011        _ => {
1012            // JS/TS/Java/Kotlin/PHP/C#/C/C++/Zig: /** */ block or /// line comments
1013            if idx < lines.len() {
1014                let trimmed = lines[idx].trim();
1015                if trimmed.ends_with("*/") {
1016                    return extract_block_comment(&lines, idx, "/**");
1017                }
1018                // /// line comments (TypeScript, C#, etc.)
1019                if trimmed.starts_with("///") || trimmed.starts_with("//") {
1020                    while idx < lines.len() {
1021                        let t = lines[idx].trim();
1022                        if t.starts_with("///") {
1023                            comment_lines.push(t.trim_start_matches('/').trim().to_string());
1024                        } else if t.starts_with("//") && !t.starts_with("///") {
1025                            comment_lines.push(t[2..].trim().to_string());
1026                        } else {
1027                            break;
1028                        }
1029                        if idx == 0 { break; }
1030                        idx -= 1;
1031                    }
1032                }
1033            }
1034        }
1035    }
1036
1037    if comment_lines.is_empty() {
1038        return None;
1039    }
1040
1041    // Reverse because we collected bottom-up
1042    comment_lines.reverse();
1043    let result = comment_lines.join("\n").trim().to_string();
1044    if result.is_empty() { None } else { Some(result) }
1045}
1046
1047/// Extract a block comment (/** ... */) by walking backwards from the closing line.
1048fn extract_block_comment(lines: &[&str], end_idx: usize, open_marker: &str) -> Option<String> {
1049    let mut doc_lines: Vec<String> = Vec::new();
1050    let mut idx = end_idx;
1051
1052    loop {
1053        let trimmed = lines[idx].trim();
1054
1055        // Check if this line contains the opening marker
1056        if trimmed.starts_with(open_marker) || trimmed.starts_with("/*") {
1057            // Single-line block comment: /** text */
1058            let content = trimmed
1059                .trim_start_matches(open_marker)
1060                .trim_start_matches("/*")
1061                .trim_end_matches("*/")
1062                .trim_end_matches('*')
1063                .trim();
1064            if !content.is_empty() {
1065                doc_lines.push(content.to_string());
1066            }
1067            break;
1068        }
1069
1070        // Middle or end line of block comment
1071        let content = trimmed
1072            .trim_end_matches("*/")
1073            .trim_start_matches('*')
1074            .trim();
1075        if !content.is_empty() {
1076            doc_lines.push(content.to_string());
1077        }
1078
1079        if idx == 0 { break; }
1080        idx -= 1;
1081    }
1082
1083    doc_lines.reverse();
1084    let result = doc_lines.join("\n").trim().to_string();
1085    if result.is_empty() { None } else { Some(result) }
1086}
1087
1088/// HTML-escape text to prevent doc comments from being interpreted as markup.
1089fn html_escape(s: &str) -> String {
1090    s.replace('&', "&amp;")
1091     .replace('<', "&lt;")
1092     .replace('>', "&gt;")
1093}
1094
1095/// Render a single "By Kind" entry as a pure HTML `<li>` element.
1096/// Single-line docs are appended inline; multi-line docs use a `<details>` element.
1097fn render_by_kind_entry(content: &mut String, name: &str, short_path: &str, doc: Option<&str>) {
1098    match doc {
1099        Some(d) if d.lines().count() > 1 => {
1100            let first_line = html_escape(d.lines().next().unwrap_or(""));
1101            let body: String = d.lines()
1102                .map(|line| format!("<p>{}</p>", html_escape(line)))
1103                .collect::<Vec<_>>()
1104                .join("\n");
1105            content.push_str(&format!(
1106                "<li><code>{}</code> ({})\n<details><summary>{}</summary>\n<div class=\"doc-comment\">\n{}\n</div>\n</details>\n</li>\n",
1107                html_escape(name), html_escape(short_path), first_line, body
1108            ));
1109        }
1110        Some(d) => {
1111            content.push_str(&format!(
1112                "<li><code>{}</code> ({}) — <span class=\"doc-comment-inline\">{}</span></li>\n",
1113                html_escape(name), html_escape(short_path), html_escape(d)
1114            ));
1115        }
1116        None => {
1117            content.push_str(&format!(
1118                "<li><code>{}</code> ({})</li>\n",
1119                html_escape(name), html_escape(short_path)
1120            ));
1121        }
1122    }
1123}
1124
1125fn build_key_symbols_section(conn: &Connection, module_path: &str, query_engine: &QueryEngine) -> String {
1126    let pattern = format!("{}/%", module_path);
1127    let mut stmt = match conn.prepare(
1128        "SELECT path, language FROM files
1129         WHERE path LIKE ?1 AND language IS NOT NULL
1130         ORDER BY COALESCE(line_count, 0) DESC
1131         LIMIT 20"
1132    ) {
1133        Ok(s) => s,
1134        Err(_) => return "No symbols extracted.".to_string(),
1135    };
1136
1137    let files: Vec<(String, String)> = match stmt.query_map([&pattern], |row| {
1138        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1139    }) {
1140        Ok(rows) => rows.filter_map(|r| r.ok()).collect(),
1141        Err(_) => return "No symbols extracted.".to_string(),
1142    };
1143
1144    if files.is_empty() {
1145        return "No files in this module.".to_string();
1146    }
1147
1148    // Parse each file and collect symbols
1149    // kind -> [(name, path, size, doc_comment)]
1150    let mut by_kind: HashMap<String, Vec<(String, String, usize, Option<String>)>> = HashMap::new();
1151    let mut total_symbols = 0usize;
1152
1153    for (path, lang_str) in &files {
1154        let language = match Language::from_name(lang_str) {
1155            Some(l) => l,
1156            None => continue,
1157        };
1158
1159        // Read source from disk
1160        let source = match std::fs::read_to_string(path) {
1161            Ok(s) => s,
1162            Err(_) => continue,
1163        };
1164
1165        let symbols = match ParserFactory::parse(path, &source, language) {
1166            Ok(s) => s,
1167            Err(_) => continue,
1168        };
1169
1170        for sym in symbols {
1171            if let Some(name) = &sym.symbol {
1172                // Skip imports, exports, and unknown kinds
1173                match &sym.kind {
1174                    SymbolKind::Import | SymbolKind::Export | SymbolKind::Variable | SymbolKind::Unknown(_) => continue,
1175                    _ => {}
1176                }
1177
1178                let kind_name = format!("{}", sym.kind);
1179                let size = sym.span.end_line.saturating_sub(sym.span.start_line) + 1;
1180                let doc_comment = extract_doc_comment(&source, sym.span.start_line, &language);
1181                by_kind
1182                    .entry(kind_name)
1183                    .or_default()
1184                    .push((name.clone(), path.clone(), size, doc_comment));
1185                total_symbols += 1;
1186            }
1187        }
1188    }
1189
1190    if total_symbols == 0 {
1191        return "No symbols extracted.".to_string();
1192    }
1193
1194    let mut content = String::new();
1195
1196    // Build doc_comments lookup: symbol name -> doc comment
1197    let mut doc_comments: HashMap<String, String> = HashMap::new();
1198    for entries in by_kind.values() {
1199        for (name, _path, _size, doc) in entries {
1200            if let Some(d) = doc {
1201                doc_comments.entry(name.clone()).or_insert_with(|| d.clone());
1202            }
1203        }
1204    }
1205
1206    // --- Top symbols by codebase importance (above the fold) ---
1207    // Deduplicate symbol names, preferring priority kinds
1208    let mut unique_symbols: HashMap<String, (String, String)> = HashMap::new(); // name -> (kind, path)
1209    // First pass: insert priority-kind symbols
1210    for (kind_str, entries) in &by_kind {
1211        if PRIORITY_SYMBOL_KINDS.contains(&kind_str.as_str()) {
1212            for (name, path, _size, _doc) in entries {
1213                unique_symbols.entry(name.clone()).or_insert_with(|| (kind_str.clone(), path.clone()));
1214            }
1215        }
1216    }
1217    // Second pass: fill in remaining kinds (won't overwrite priority entries)
1218    for (kind_str, entries) in &by_kind {
1219        if !PRIORITY_SYMBOL_KINDS.contains(&kind_str.as_str()) {
1220            for (name, path, _size, _doc) in entries {
1221                unique_symbols.entry(name.clone()).or_insert_with(|| (kind_str.clone(), path.clone()));
1222            }
1223        }
1224    }
1225
1226    // Count references for priority-kind symbols only via the trigram index.
1227    // Filter out blocklisted keywords and short names, cap at 15 candidates.
1228    let mut candidates: Vec<(String, String, String, usize)> = Vec::new(); // (name, kind, path, span_size)
1229    for (name, (kind, path)) in &unique_symbols {
1230        // Only query priority-kind symbols (functions, structs, traits, etc.)
1231        if !PRIORITY_SYMBOL_KINDS.contains(&kind.as_str()) {
1232            continue;
1233        }
1234        // Skip short names (< 4 chars) — they're too generic
1235        if name.len() < 4 {
1236            continue;
1237        }
1238        // Skip blocklisted keywords and common variable names
1239        if SYMBOL_BLOCKLIST.contains(&name.to_lowercase().as_str()) {
1240            continue;
1241        }
1242        // Skip names that start with $ (PHP variables like $data, $type)
1243        if name.starts_with('$') {
1244            let stripped = &name[1..];
1245            if stripped.len() < 4 || SYMBOL_BLOCKLIST.contains(&stripped.to_lowercase().as_str()) {
1246                continue;
1247            }
1248        }
1249
1250        // Look up span size for this symbol (larger definitions are more important)
1251        let span_size = by_kind.get(kind)
1252            .and_then(|entries| entries.iter().find(|(n, _, _, _)| n == name))
1253            .map(|(_, _, size, _)| *size)
1254            .unwrap_or(1);
1255
1256        candidates.push((name.clone(), kind.clone(), path.clone(), span_size));
1257    }
1258
1259    // Sort by span size desc, cap at 15 before querying
1260    candidates.sort_by(|a, b| b.3.cmp(&a.3).then_with(|| a.0.cmp(&b.0)));
1261    candidates.truncate(15);
1262
1263    // Query reference counts and file paths for the capped candidates
1264    let mut ranked: Vec<(String, String, String, usize)> = Vec::new(); // (name, kind, path, ref_count)
1265    let mut ref_files: HashMap<String, Vec<String>> = HashMap::new(); // symbol name -> referencing file short names
1266    for (name, kind, path, _span_size) in &candidates {
1267        let filter = QueryFilter {
1268            paths_only: true,
1269            force: true,
1270            suppress_output: true,
1271            limit: None,
1272            ..Default::default()
1273        };
1274        let def_short = path.rsplit('/').next().unwrap_or(path);
1275        match query_engine.search_with_metadata(name, filter) {
1276            Ok(response) => {
1277                let ref_count = response.results.len();
1278                // Collect unique short filenames, excluding the definition file
1279                let mut files: Vec<String> = response.results.iter()
1280                    .map(|r| r.path.rsplit('/').next().unwrap_or(&r.path).to_string())
1281                    .filter(|f| f != def_short)
1282                    .collect();
1283                files.sort();
1284                files.dedup();
1285                ref_files.insert(name.clone(), files);
1286                ranked.push((name.clone(), kind.clone(), path.clone(), ref_count));
1287            }
1288            Err(_) => {
1289                ranked.push((name.clone(), kind.clone(), path.clone(), 0));
1290            }
1291        }
1292    }
1293
1294    // Sort by reference count desc
1295    ranked.sort_by(|a, b| b.3.cmp(&a.3).then_with(|| a.0.cmp(&b.0)));
1296
1297    if !ranked.is_empty() {
1298        content.push_str("<p><strong>Key definitions:</strong></p>\n<ul>\n");
1299        for (name, kind, path, ref_count) in ranked.iter().take(5) {
1300            let short = path.rsplit('/').next().unwrap_or(path);
1301            content.push_str("<li>\n");
1302            content.push_str(&format!(
1303                "<p><code>{}</code> ({}) in {} — referenced in {} {}</p>\n",
1304                html_escape(name), html_escape(kind), html_escape(short), ref_count,
1305                if *ref_count == 1 { "file" } else { "files" }
1306            ));
1307
1308            // Add doc comment if available
1309            if let Some(doc) = doc_comments.get(name.as_str()) {
1310                let first_line = html_escape(doc.lines().next().unwrap_or(""));
1311                let is_multiline = doc.lines().count() > 1;
1312                if is_multiline {
1313                    let body: String = doc.lines()
1314                        .map(|line| format!("<p>{}</p>", html_escape(line)))
1315                        .collect::<Vec<_>>()
1316                        .join("\n");
1317                    content.push_str(&format!(
1318                        "<details><summary>{}</summary>\n<div class=\"doc-comment\">\n{}\n</div>\n</details>\n",
1319                        first_line, body
1320                    ));
1321                } else {
1322                    content.push_str(&format!(
1323                        "<details><summary>{}</summary></details>\n",
1324                        first_line
1325                    ));
1326                }
1327            }
1328
1329            // Add reference file list (top 5 + overflow)
1330            if let Some(files) = ref_files.get(name.as_str()) {
1331                if !files.is_empty() {
1332                    let show: Vec<&str> = files.iter().take(5).map(|s| s.as_str()).collect();
1333                    let mut ref_line = format!("<ul><li class=\"ref-list\">Referenced by: {}", show.join(", "));
1334                    if files.len() > 5 {
1335                        ref_line.push_str(&format!(" +{} more", files.len() - 5));
1336                    }
1337                    ref_line.push_str("</li></ul>\n");
1338                    content.push_str(&ref_line);
1339                }
1340            }
1341
1342            content.push_str("</li>\n");
1343        }
1344        content.push_str("</ul>\n\n");
1345    }
1346
1347    // --- By Kind view (collapsible, showing ALL symbols) ---
1348    let display_order = [
1349        "Function", "Struct", "Class", "Trait", "Interface",
1350        "Enum", "Method", "Constant", "Type", "Macro",
1351        "Variable", "Module", "Namespace", "Property", "Attribute",
1352    ];
1353
1354    for kind in &display_order {
1355        let kind_str = kind.to_string();
1356        if let Some(entries) = by_kind.get_mut(&kind_str) {
1357            entries.sort_by(|a, b| b.2.cmp(&a.2));
1358            let count = entries.len();
1359            content.push_str(&format!("<details><summary><strong>{}</strong> ({})</summary>\n<ul>\n", kind, count));
1360            for (name, path, _size, doc) in entries.iter() {
1361                let short = path.rsplit('/').next().unwrap_or(path);
1362                render_by_kind_entry(&mut content, name, short, doc.as_deref());
1363            }
1364            content.push_str("</ul>\n</details>\n\n");
1365        }
1366    }
1367
1368    // Handle any kinds not in display_order
1369    for (kind, entries) in &mut by_kind {
1370        if display_order.contains(&kind.as_str()) {
1371            continue;
1372        }
1373        entries.sort_by(|a, b| b.2.cmp(&a.2));
1374        let count = entries.len();
1375        content.push_str(&format!("<details><summary><strong>{}</strong> ({})</summary>\n<ul>\n", kind, count));
1376        for (name, path, _size, doc) in entries.iter() {
1377            let short = path.rsplit('/').next().unwrap_or(path);
1378            render_by_kind_entry(&mut content, name, short, doc.as_deref());
1379        }
1380        content.push_str("</ul>\n</details>\n\n");
1381    }
1382
1383    if content.is_empty() {
1384        "No symbols extracted.".to_string()
1385    } else {
1386        content
1387    }
1388}
1389
1390fn build_metrics_section(module: &ModuleDefinition, conn: &Connection) -> Result<String> {
1391    let pattern = format!("{}/%", module.path);
1392
1393    // Average lines per file
1394    let avg_lines = if module.file_count > 0 {
1395        module.total_lines / module.file_count
1396    } else {
1397        0
1398    };
1399
1400    // Outgoing dependency count
1401    let outgoing: usize = conn.query_row(
1402        "SELECT COUNT(DISTINCT fd.resolved_file_id)
1403         FROM file_dependencies fd
1404         JOIN files f1 ON fd.file_id = f1.id
1405         JOIN files f2 ON fd.resolved_file_id = f2.id
1406         WHERE f1.path LIKE ?1 AND f2.path NOT LIKE ?1",
1407        [&pattern],
1408        |row| row.get(0),
1409    ).unwrap_or(0);
1410
1411    // Incoming dependency count
1412    let incoming: usize = conn.query_row(
1413        "SELECT COUNT(DISTINCT fd.file_id)
1414         FROM file_dependencies fd
1415         JOIN files f1 ON fd.file_id = f1.id
1416         JOIN files f2 ON fd.resolved_file_id = f2.id
1417         WHERE f2.path LIKE ?1 AND f1.path NOT LIKE ?1",
1418        [&pattern],
1419        |row| row.get(0),
1420    ).unwrap_or(0);
1421
1422    Ok(format!(
1423        "| Metric | Value |\n|---|---|\n\
1424         | Files | {} |\n\
1425         | Total lines | {} |\n\
1426         | Avg lines/file | {} |\n\
1427         | Languages | {} |\n\
1428         | Outgoing deps | {} |\n\
1429         | Incoming deps | {} |\n\
1430         | Tier | {} |",
1431        module.file_count,
1432        module.total_lines,
1433        avg_lines,
1434        module.languages.join(", "),
1435        outgoing,
1436        incoming,
1437        module.tier,
1438    ))
1439}
1440
1441/// Find the most-specific module that owns a given file path
1442fn find_owning_module(file_path: &str, modules: &[ModuleDefinition]) -> String {
1443    let mut best_match = String::new();
1444    let mut best_len = 0;
1445
1446    for module in modules {
1447        let prefix = format!("{}/", module.path);
1448        if file_path.starts_with(&prefix) && module.path.len() > best_len {
1449            best_match = module.path.clone();
1450            best_len = module.path.len();
1451        }
1452    }
1453
1454    if best_match.is_empty() {
1455        // Fall back to top-level directory
1456        file_path.split('/').next().unwrap_or("root").to_string()
1457    } else {
1458        best_match
1459    }
1460}
1461
1462fn build_recent_changes(diff: &super::diff::SnapshotDiff, module_path: &str) -> String {
1463    let prefix = format!("{}/", module_path);
1464    let mut content = String::new();
1465
1466    let added: Vec<_> = diff.files_added.iter()
1467        .filter(|f| f.path.starts_with(&prefix))
1468        .collect();
1469    let removed: Vec<_> = diff.files_removed.iter()
1470        .filter(|f| f.path.starts_with(&prefix))
1471        .collect();
1472    let modified: Vec<_> = diff.files_modified.iter()
1473        .filter(|f| f.path.starts_with(&prefix))
1474        .collect();
1475
1476    if added.is_empty() && removed.is_empty() && modified.is_empty() {
1477        return "No changes in this module since last snapshot.".to_string();
1478    }
1479
1480    if !added.is_empty() {
1481        content.push_str(&format!("**Added** ({}):\n", added.len()));
1482        for f in added.iter().take(10) {
1483            content.push_str(&format!("- `{}`\n", f.path));
1484        }
1485    }
1486    if !removed.is_empty() {
1487        content.push_str(&format!("**Removed** ({}):\n", removed.len()));
1488        for f in removed.iter().take(10) {
1489            content.push_str(&format!("- `{}`\n", f.path));
1490        }
1491    }
1492    if !modified.is_empty() {
1493        content.push_str(&format!("**Modified** ({}):\n", modified.len()));
1494        for f in modified.iter().take(10) {
1495            let delta = f.new_line_count as i64 - f.old_line_count as i64;
1496            content.push_str(&format!("- `{}` ({:+} lines)\n", f.path, delta));
1497        }
1498    }
1499
1500    content
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505    use super::*;
1506
1507    #[test]
1508    fn test_module_definition_serialization() {
1509        let module = ModuleDefinition {
1510            path: "src".to_string(),
1511            tier: 1,
1512            file_count: 50,
1513            total_lines: 5000,
1514            languages: vec!["Rust".to_string()],
1515        };
1516        let json = serde_json::to_string(&module).unwrap();
1517        assert!(json.contains("src"));
1518    }
1519
1520    #[test]
1521    fn test_render_wiki_page() {
1522        let page = WikiPage {
1523            module_path: "src".to_string(),
1524            title: "src/".to_string(),
1525            sections: WikiSections {
1526                summary: None,
1527                structure: "test structure".to_string(),
1528                dependencies: "test deps".to_string(),
1529                dependents: "test dependents".to_string(),
1530                dependency_diagram: None,
1531                circular_deps: None,
1532                key_symbols: "test symbols".to_string(),
1533                metrics: "test metrics".to_string(),
1534                recent_changes: None,
1535            },
1536        };
1537        let rendered = render_wiki_markdown(&[page]);
1538        assert_eq!(rendered.len(), 1);
1539        assert_eq!(rendered[0].0, "src.md");
1540        assert!(rendered[0].1.contains("# src/"));
1541    }
1542}