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