1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ModuleDefinition {
27 pub path: String,
29 pub tier: u8,
31 pub file_count: usize,
33 pub total_lines: usize,
35 pub languages: Vec<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct WikiPage {
42 pub module_path: String,
43 pub title: String,
44 pub sections: WikiSections,
45}
46
47#[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#[derive(Debug, Clone)]
63pub struct ModuleDiscoveryConfig {
64 pub max_depth: u8,
66 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
79pub 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 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 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 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 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 modules.sort_by(|a, b| a.path.cmp(&b.path));
141
142 Ok(modules)
143}
144
145fn 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; 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
173pub 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 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 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 let summary = if !no_llm {
208 if let (Some(provider), Some(llm_cache)) = (provider, llm_cache) {
209 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
250pub 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
290pub struct WikiPageWithContext {
292 pub page: WikiPage,
293 pub narration_context: Option<String>,
295}
296
297pub 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 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 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 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 pages.sort_by(|a, b| a.page.module_path.cmp(&b.page.module_path));
385
386 Ok(pages)
387}
388
389pub 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
448fn 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 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 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 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 let mut all_node_paths: Vec<String> = vec![module_path.to_string()];
510
511 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 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 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 diagram.push_str(" classDef default fill:#1a1a2e,stroke:#a78bfa,color:#e0e0e0\n");
538
539 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
549fn 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 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 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 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 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 let mut by_subdir: HashMap<String, (usize, i64)> = HashMap::new(); 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 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 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)); 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 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 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 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
900const SYMBOL_BLOCKLIST: &[&str] = &[
903 "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 "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
1014const PRIORITY_SYMBOL_KINDS: &[&str] = &[
1017 "Function",
1018 "Struct",
1019 "Class",
1020 "Trait",
1021 "Interface",
1022 "Enum",
1023 "Macro",
1024 "Type",
1025 "Constant",
1026];
1027
1028fn 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 if matches!(language, Language::Python) {
1041 let search_start = start_line; 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 if trimmed.starts_with("\"\"\"") || trimmed.starts_with("'''") {
1050 let quote = &trimmed[..3];
1051 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 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; }
1082 return None;
1083 }
1084
1085 let mut idx = start_line.saturating_sub(2); let mut comment_lines: Vec<String> = Vec::new();
1088
1089 loop {
1091 if idx >= lines.len() {
1092 break;
1093 }
1094 let trimmed = lines[idx].trim();
1095 if trimmed.starts_with("#[") || trimmed.starts_with("#![") {
1097 if idx == 0 {
1098 return None;
1099 }
1100 idx -= 1;
1101 continue;
1102 }
1103 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 if trimmed.starts_with("#[") {
1116 if idx == 0 {
1117 return None;
1118 }
1119 idx -= 1;
1120 continue;
1121 }
1122 break;
1123 }
1124
1125 match language {
1127 Language::Rust => {
1128 if idx < lines.len() && lines[idx].trim().ends_with("*/") {
1131 return extract_block_comment(&lines, idx, "/**");
1132 }
1133 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 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 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 if idx < lines.len() {
1186 let trimmed = lines[idx].trim();
1187 if trimmed.ends_with("*/") {
1188 return extract_block_comment(&lines, idx, "/**");
1189 }
1190 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 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
1225fn 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 if trimmed.starts_with(open_marker) || trimmed.starts_with("/*") {
1235 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 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
1272fn html_escape(s: &str) -> String {
1274 s.replace('&', "&")
1275 .replace('<', "<")
1276 .replace('>', ">")
1277}
1278
1279fn 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 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 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 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 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 let mut unique_symbols: HashMap<String, (String, String)> = HashMap::new(); 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 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 let mut candidates: Vec<(String, String, String, usize)> = Vec::new(); for (name, (kind, path)) in &unique_symbols {
1433 if !PRIORITY_SYMBOL_KINDS.contains(&kind.as_str()) {
1435 continue;
1436 }
1437 if name.len() < 4 {
1439 continue;
1440 }
1441 if SYMBOL_BLOCKLIST.contains(&name.to_lowercase().as_str()) {
1443 continue;
1444 }
1445 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 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 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 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 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 let avg_lines = if module.file_count > 0 {
1626 module.total_lines / module.file_count
1627 } else {
1628 0
1629 };
1630
1631 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 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
1676fn 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 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}