1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ModuleDefinition {
29 pub path: String,
31 pub tier: u8,
33 pub file_count: usize,
35 pub total_lines: usize,
37 pub languages: Vec<String>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct WikiPage {
44 pub module_path: String,
45 pub title: String,
46 pub sections: WikiSections,
47}
48
49#[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#[derive(Debug, Clone)]
65pub struct ModuleDiscoveryConfig {
66 pub max_depth: u8,
68 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
81pub 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 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 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 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 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 modules.sort_by(|a, b| a.path.cmp(&b.path));
143
144 Ok(modules)
145}
146
147fn 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; 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#[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 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 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 let summary = if !no_llm {
211 if let (Some(provider), Some(llm_cache)) = (provider, llm_cache) {
212 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
253pub 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
293pub struct WikiPageWithContext {
295 pub page: WikiPage,
296 pub narration_context: Option<String>,
298}
299
300pub 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 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 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 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 pages.sort_by(|a, b| a.page.module_path.cmp(&b.page.module_path));
388
389 Ok(pages)
390}
391
392pub 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
451fn 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 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 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 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 let mut all_node_paths: Vec<String> = vec![module_path.to_string()];
511
512 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 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 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 diagram.push_str(" classDef default fill:#1a1a2e,stroke:#a78bfa,color:#e0e0e0\n");
539
540 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
550fn 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 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 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 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 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 let mut by_subdir: HashMap<String, (usize, i64)> = HashMap::new(); 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 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 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)); 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 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 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 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
901const SYMBOL_BLOCKLIST: &[&str] = &[
904 "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 "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
1015const PRIORITY_SYMBOL_KINDS: &[&str] = &[
1018 "Function",
1019 "Struct",
1020 "Class",
1021 "Trait",
1022 "Interface",
1023 "Enum",
1024 "Macro",
1025 "Type",
1026 "Constant",
1027];
1028
1029fn 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 if matches!(language, Language::Python) {
1042 let search_start = start_line; 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 if trimmed.starts_with("\"\"\"") || trimmed.starts_with("'''") {
1051 let quote = &trimmed[..3];
1052 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 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; }
1083 return None;
1084 }
1085
1086 let mut idx = start_line.saturating_sub(2); let mut comment_lines: Vec<String> = Vec::new();
1089
1090 loop {
1092 if idx >= lines.len() {
1093 break;
1094 }
1095 let trimmed = lines[idx].trim();
1096 if trimmed.starts_with("#[") || trimmed.starts_with("#![") {
1098 if idx == 0 {
1099 return None;
1100 }
1101 idx -= 1;
1102 continue;
1103 }
1104 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 if trimmed.starts_with("#[") {
1117 if idx == 0 {
1118 return None;
1119 }
1120 idx -= 1;
1121 continue;
1122 }
1123 break;
1124 }
1125
1126 match language {
1128 Language::Rust => {
1129 if idx < lines.len() && lines[idx].trim().ends_with("*/") {
1132 return extract_block_comment(&lines, idx, "/**");
1133 }
1134 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 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 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 if idx < lines.len() {
1187 let trimmed = lines[idx].trim();
1188 if trimmed.ends_with("*/") {
1189 return extract_block_comment(&lines, idx, "/**");
1190 }
1191 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 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
1226fn 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 if trimmed.starts_with(open_marker) || trimmed.starts_with("/*") {
1236 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 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
1273fn html_escape(s: &str) -> String {
1275 s.replace('&', "&")
1276 .replace('<', "<")
1277 .replace('>', ">")
1278}
1279
1280fn 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 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 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 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 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 let mut unique_symbols: HashMap<String, (String, String)> = HashMap::new(); 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 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 let mut candidates: Vec<(String, String, String, usize)> = Vec::new(); for (name, (kind, path)) in &unique_symbols {
1434 if !PRIORITY_SYMBOL_KINDS.contains(&kind.as_str()) {
1436 continue;
1437 }
1438 if name.len() < 4 {
1440 continue;
1441 }
1442 if SYMBOL_BLOCKLIST.contains(&name.to_lowercase().as_str()) {
1444 continue;
1445 }
1446 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 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 candidates.sort_by(|a, b| b.3.cmp(&a.3).then_with(|| a.0.cmp(&b.0)));
1465 candidates.truncate(15);
1466
1467 let mut ranked: Vec<(String, String, String, usize)> = Vec::new(); let mut ref_files: HashMap<String, Vec<String>> = HashMap::new(); 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 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 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 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 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 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 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 let avg_lines = module
1626 .total_lines
1627 .checked_div(module.file_count)
1628 .unwrap_or(0);
1629
1630 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 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
1675fn 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 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}