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