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 { max_depth: 2, min_files: 1 }
73 }
74}
75
76pub fn detect_modules(cache: &CacheManager, config: &ModuleDiscoveryConfig) -> Result<Vec<ModuleDefinition>> {
84 let context = CodebaseContext::extract(cache)
85 .context("Failed to extract codebase context")?;
86
87 let db_path = cache.path().join("meta.db");
88 let conn = Connection::open(&db_path)?;
89
90 let mut modules = Vec::new();
91
92 for dir in &context.top_level_dirs {
94 let dir_path = dir.trim_end_matches('/');
95 if let Some(module) = build_module_def(&conn, dir_path, 1)? {
96 if module.file_count >= config.min_files {
97 modules.push(module);
98 }
99 }
100 }
101
102 if config.max_depth >= 2 {
104 let tier1_paths: Vec<String> = modules.iter().map(|m| m.path.clone()).collect();
105 for parent in &tier1_paths {
106 let sub_modules = discover_sub_modules(&conn, parent)?;
107 for sub_path in sub_modules {
108 if modules.iter().any(|m| m.path == sub_path) {
110 continue;
111 }
112 if let Some(module) = build_module_def(&conn, &sub_path, 2)? {
113 if module.file_count >= config.min_files {
114 modules.push(module);
115 }
116 }
117 }
118 }
119
120 for path in &context.common_paths {
122 let path_str = path.trim_end_matches('/');
123 if modules.iter().any(|m| m.path == path_str) {
124 continue;
125 }
126 if let Some(module) = build_module_def(&conn, path_str, 2)? {
127 if module.file_count >= config.min_files {
128 modules.push(module);
129 }
130 }
131 }
132 }
133
134 modules.sort_by(|a, b| a.path.cmp(&b.path));
136
137 Ok(modules)
138}
139
140fn discover_sub_modules(conn: &Connection, parent_path: &str) -> Result<Vec<String>> {
145 let pattern = format!("{}/%", parent_path);
146 let prefix_len = parent_path.len() + 1; let mut stmt = conn.prepare(
149 "SELECT
150 SUBSTR(path, 1, ?2 + INSTR(SUBSTR(path, ?2 + 1), '/') - 1) AS sub_dir,
151 COUNT(*) AS file_count
152 FROM files
153 WHERE path LIKE ?1
154 AND INSTR(SUBSTR(path, ?2 + 1), '/') > 0
155 GROUP BY sub_dir
156 HAVING file_count >= 3
157 ORDER BY file_count DESC"
158 )?;
159
160 let rows: Vec<String> = stmt.query_map(
161 rusqlite::params![pattern, prefix_len],
162 |row| row.get(0),
163 )?.filter_map(|r| r.ok()).collect();
164
165 Ok(rows)
166}
167
168pub fn generate_wiki_page(
170 cache: &CacheManager,
171 module: &ModuleDefinition,
172 all_modules: &[ModuleDefinition],
173 diff: Option<&super::diff::SnapshotDiff>,
174 no_llm: bool,
175 provider: Option<&dyn LlmProvider>,
176 llm_cache: Option<&LlmCache>,
177 snapshot_id: &str,
178) -> Result<WikiPage> {
179 let db_path = cache.path().join("meta.db");
180 let conn = Connection::open(&db_path)?;
181 let deps_index = DependencyIndex::new(cache.clone());
182 let query_engine = QueryEngine::new(cache.clone());
183
184 let prefix = format!("{}/", module.path);
186 let child_modules: Vec<&ModuleDefinition> = all_modules.iter()
187 .filter(|m| m.path.starts_with(&prefix) && m.path != module.path)
188 .collect();
189
190 let structure = build_structure_section(&conn, &module.path, &child_modules)?;
192 let dependencies = build_dependencies_section(&conn, &module.path, all_modules)?;
193 let dependents = build_dependents_section(&conn, &deps_index, &module.path, all_modules)?;
194 let dependency_diagram = build_dependency_diagram(&conn, &module.path, all_modules);
195 let circular_deps = build_circular_deps_section(&deps_index, &module.path);
196 let key_symbols = build_key_symbols_section(&conn, &module.path, &query_engine);
197 let metrics = build_metrics_section(module, &conn)?;
198 let recent_changes = diff.map(|d| build_recent_changes(d, &module.path));
199
200 let summary = if !no_llm {
202 if let (Some(provider), Some(llm_cache)) = (provider, llm_cache) {
203 let mut context = String::new();
205 context.push_str(&format!("Module: {}\n\n", module.path));
206 context.push_str(&format!("## Structure\n{}\n\n", structure));
207 context.push_str(&format!("## Dependencies\n{}\n\n", dependencies));
208 context.push_str(&format!("## Dependents\n{}\n\n", dependents));
209 context.push_str(&format!("## Key Symbols\n{}\n\n", key_symbols));
210 context.push_str(&format!("## Metrics\n{}\n", metrics));
211
212 narrate::narrate_section(
213 provider,
214 narrate::wiki_system_prompt(),
215 &context,
216 llm_cache,
217 snapshot_id,
218 &module.path,
219 )
220 } else {
221 None
222 }
223 } else {
224 None
225 };
226
227 Ok(WikiPage {
228 module_path: module.path.clone(),
229 title: format!("{}/", module.path),
230 sections: WikiSections {
231 summary,
232 structure,
233 dependencies,
234 dependents,
235 dependency_diagram,
236 circular_deps,
237 key_symbols,
238 metrics,
239 recent_changes,
240 },
241 })
242}
243
244pub fn generate_all_pages(
248 cache: &CacheManager,
249 diff: Option<&super::diff::SnapshotDiff>,
250 no_llm: bool,
251 snapshot_id: &str,
252 provider: Option<&dyn LlmProvider>,
253 llm_cache: Option<&LlmCache>,
254 discovery_config: &ModuleDiscoveryConfig,
255) -> Result<Vec<WikiPage>> {
256 let modules = detect_modules(cache, discovery_config)?;
257 let mut pages = Vec::new();
258
259 if provider.is_some() {
260 eprintln!("Generating wiki summaries...");
261 }
262
263 for module in &modules {
264 match generate_wiki_page(
265 cache,
266 module,
267 &modules,
268 diff,
269 no_llm,
270 provider,
271 llm_cache,
272 snapshot_id,
273 ) {
274 Ok(page) => pages.push(page),
275 Err(e) => {
276 log::warn!("Failed to generate wiki page for {}: {}", module.path, e);
277 }
278 }
279 }
280
281 Ok(pages)
282}
283
284pub struct WikiPageWithContext {
286 pub page: WikiPage,
287 pub narration_context: Option<String>,
289}
290
291pub fn generate_all_pages_structural(
296 cache: &CacheManager,
297 diff: Option<&super::diff::SnapshotDiff>,
298 discovery_config: &ModuleDiscoveryConfig,
299) -> Result<Vec<WikiPageWithContext>> {
300 let modules = detect_modules(cache, discovery_config)?;
301
302 let results: Vec<_> = modules.par_iter().map(|module| {
305 let db_path = cache.path().join("meta.db");
306 let conn = match Connection::open(&db_path) {
307 Ok(c) => c,
308 Err(e) => return Err(anyhow::anyhow!("Failed to open meta.db for {}: {}", module.path, e)),
309 };
310 let deps_index = DependencyIndex::new(cache.clone());
311 let query_engine = QueryEngine::new(cache.clone());
312
313 let prefix = format!("{}/", module.path);
314 let child_modules: Vec<&ModuleDefinition> = modules.iter()
315 .filter(|m| m.path.starts_with(&prefix) && m.path != module.path)
316 .collect();
317
318 let structure = build_structure_section(&conn, &module.path, &child_modules)?;
319 let dependencies = build_dependencies_section(&conn, &module.path, &modules)?;
320 let dependents = build_dependents_section(&conn, &deps_index, &module.path, &modules)?;
321 let dependency_diagram = build_dependency_diagram(&conn, &module.path, &modules);
322 let circular_deps = build_circular_deps_section(&deps_index, &module.path);
323 let key_symbols = build_key_symbols_section(&conn, &module.path, &query_engine);
324 let metrics = build_metrics_section(module, &conn)?;
325 let recent_changes = diff.map(|d| build_recent_changes(d, &module.path));
326
327 let mut context = String::new();
329 context.push_str(&format!("Module: {}\n\n", module.path));
330 context.push_str(&format!("## Structure\n{}\n\n", structure));
331 context.push_str(&format!("## Dependencies\n{}\n\n", dependencies));
332 context.push_str(&format!("## Dependents\n{}\n\n", dependents));
333 context.push_str(&format!("## Key Symbols\n{}\n\n", key_symbols));
334 context.push_str(&format!("## Metrics\n{}\n", metrics));
335
336 let narration_context = Some(context);
337
338 Ok(WikiPageWithContext {
339 page: WikiPage {
340 module_path: module.path.clone(),
341 title: format!("{}/", module.path),
342 sections: WikiSections {
343 summary: None,
344 structure,
345 dependencies,
346 dependents,
347 dependency_diagram,
348 circular_deps,
349 key_symbols,
350 metrics,
351 recent_changes,
352 },
353 },
354 narration_context,
355 })
356 }).collect();
357
358 let mut pages = Vec::new();
360 for result in results {
361 match result {
362 Ok(page) => pages.push(page),
363 Err(e) => log::warn!("Failed to generate wiki page: {}", e),
364 }
365 }
366
367 pages.sort_by(|a, b| a.page.module_path.cmp(&b.page.module_path));
369
370 Ok(pages)
371}
372
373pub fn render_wiki_markdown(pages: &[WikiPage]) -> Vec<(String, String)> {
375 pages.iter().map(|page| {
376 let filename = page.module_path.replace('/', "_") + ".md";
377 let mut md = String::new();
378
379 md.push_str(&format!("# {}\n\n", page.title));
380
381 if let Some(summary) = &page.sections.summary {
382 md.push_str(summary);
383 md.push_str("\n\n");
384 }
385
386 md.push_str("## Structure\n\n");
387 md.push_str(&page.sections.structure);
388 md.push_str("\n\n");
389
390 if let Some(diagram) = &page.sections.dependency_diagram {
391 md.push_str("## Dependency Diagram\n\n");
392 md.push_str("```mermaid\n");
393 md.push_str(diagram);
394 md.push_str("```\n\n");
395 }
396
397 md.push_str("## Dependencies\n\n");
398 md.push_str(&page.sections.dependencies);
399 md.push_str("\n\n");
400
401 md.push_str("## Dependents\n\n");
402 md.push_str(&page.sections.dependents);
403 md.push_str("\n\n");
404
405 if let Some(circular) = &page.sections.circular_deps {
406 md.push_str("## Circular Dependencies\n\n");
407 md.push_str(circular);
408 md.push_str("\n\n");
409 }
410
411 md.push_str("## Key Symbols\n\n");
412 md.push_str(&page.sections.key_symbols);
413 md.push_str("\n\n");
414
415 md.push_str("## Metrics\n\n");
416 md.push_str(&page.sections.metrics);
417 md.push_str("\n\n");
418
419 if let Some(changes) = &page.sections.recent_changes {
420 md.push_str("## Recent Changes\n\n");
421 md.push_str(changes);
422 md.push_str("\n\n");
423 }
424
425 (filename, md)
426 }).collect()
427}
428
429fn build_dependency_diagram(
434 conn: &Connection,
435 module_path: &str,
436 all_modules: &[ModuleDefinition],
437) -> Option<String> {
438 let pattern = format!("{}/%", module_path);
439
440 let mut outgoing: HashMap<String, usize> = HashMap::new();
442 if let Ok(mut stmt) = conn.prepare(
443 "SELECT f2.path FROM file_dependencies fd
444 JOIN files f1 ON fd.file_id = f1.id
445 JOIN files f2 ON fd.resolved_file_id = f2.id
446 WHERE f1.path LIKE ?1 AND f2.path NOT LIKE ?1"
447 ) {
448 if let Ok(rows) = stmt.query_map([&pattern], |row| row.get::<_, String>(0)) {
449 for dep_file in rows.flatten() {
450 let target = find_owning_module(&dep_file, all_modules);
451 *outgoing.entry(target).or_insert(0) += 1;
452 }
453 }
454 }
455
456 let mut incoming: HashMap<String, usize> = HashMap::new();
458 if let Ok(mut stmt) = conn.prepare(
459 "SELECT f1.path FROM file_dependencies fd
460 JOIN files f1 ON fd.file_id = f1.id
461 JOIN files f2 ON fd.resolved_file_id = f2.id
462 WHERE f2.path LIKE ?1 AND f1.path NOT LIKE ?1"
463 ) {
464 if let Ok(rows) = stmt.query_map([&pattern], |row| row.get::<_, String>(0)) {
465 for dep_file in rows.flatten() {
466 let source = find_owning_module(&dep_file, all_modules);
467 *incoming.entry(source).or_insert(0) += 1;
468 }
469 }
470 }
471
472 if outgoing.is_empty() && incoming.is_empty() {
473 return None;
474 }
475
476 let mut diagram = String::new();
477 diagram.push_str("graph LR\n");
478
479 let sanitize = |s: &str| -> String {
481 format!("m_{}", s.replace(['/', '.', '-', ' '], "_"))
482 };
483
484 let center_id = sanitize(module_path);
485 diagram.push_str(&format!(" {}[\"<b>{}/</b>\"]\n", center_id, module_path));
486 diagram.push_str(&format!(" style {} fill:#a78bfa,color:#0d0d0d,stroke:#a78bfa\n", center_id));
487
488 let mut all_node_paths: Vec<String> = vec![module_path.to_string()];
490
491 let mut out_sorted: Vec<_> = outgoing.into_iter().collect();
493 out_sorted.sort_by(|a, b| b.1.cmp(&a.1));
494 for (target, count) in out_sorted.iter().take(8) {
495 let target_id = sanitize(target);
496 diagram.push_str(&format!(" {}[\"{}/\"]\n", target_id, target));
497 diagram.push_str(&format!(" {} -->|{}| {}\n", center_id, count, target_id));
498 all_node_paths.push(target.clone());
499 }
500
501 let mut in_sorted: Vec<_> = incoming.into_iter().collect();
503 in_sorted.sort_by(|a, b| b.1.cmp(&a.1));
504 for (source, count) in in_sorted.iter().take(8) {
505 let source_id = sanitize(source);
506 if !out_sorted.iter().any(|(t, _)| t == source) {
508 diagram.push_str(&format!(" {}[\"{}/\"]\n", source_id, source));
509 }
510 diagram.push_str(&format!(" {} -->|{}| {}\n", source_id, count, center_id));
511 if !all_node_paths.contains(source) {
512 all_node_paths.push(source.clone());
513 }
514 }
515
516 diagram.push_str(" classDef default fill:#1a1a2e,stroke:#a78bfa,color:#e0e0e0\n");
518
519 for node_path in &all_node_paths {
521 let node_id = sanitize(node_path);
522 let slug = node_path.replace('/', "-");
523 diagram.push_str(&format!(" click {} \"/wiki/{}/\"\n", node_id, slug));
524 }
525
526 Some(diagram)
527}
528
529fn build_circular_deps_section(deps_index: &DependencyIndex, module_path: &str) -> Option<String> {
532 let cycles = match deps_index.detect_circular_dependencies() {
533 Ok(c) => c,
534 Err(_) => return None,
535 };
536
537 if cycles.is_empty() {
538 return None;
539 }
540
541 let all_ids: Vec<i64> = cycles.iter().flatten().copied().collect();
543 let path_map = match deps_index.get_file_paths(&all_ids) {
544 Ok(m) => m,
545 Err(_) => return None,
546 };
547
548 let prefix = format!("{}/", module_path);
549
550 let mut relevant_cycles: Vec<Vec<String>> = Vec::new();
552 for cycle in &cycles {
553 let paths: Vec<String> = cycle.iter()
554 .filter_map(|id| path_map.get(id).cloned())
555 .collect();
556
557 if paths.iter().any(|p| p.starts_with(&prefix)) {
558 relevant_cycles.push(paths);
559 }
560 }
561
562 if relevant_cycles.is_empty() {
563 return None;
564 }
565
566 let mut content = String::new();
567 content.push_str(&format!(
568 "**{} circular {}** involving this module:\n\n",
569 relevant_cycles.len(),
570 if relevant_cycles.len() == 1 { "dependency" } else { "dependencies" }
571 ));
572
573 for (i, cycle) in relevant_cycles.iter().take(10).enumerate() {
574 let short_paths: Vec<String> = cycle.iter()
575 .map(|p| p.rsplit('/').next().unwrap_or(p).to_string())
576 .collect();
577 content.push_str(&format!("{}. {}\n", i + 1, short_paths.join(" → ")));
578 }
579
580 if relevant_cycles.len() > 10 {
581 content.push_str(&format!("\n... and {} more. Run `rfx analyze --circular` for full list.\n", relevant_cycles.len() - 10));
582 }
583
584 Some(content)
585}
586
587fn build_module_def(conn: &Connection, path: &str, tier: u8) -> Result<Option<ModuleDefinition>> {
588 let pattern = format!("{}/%", path);
589
590 let file_count: usize = conn.query_row(
591 "SELECT COUNT(*) FROM files WHERE path LIKE ?1 OR path = ?2",
592 rusqlite::params![&pattern, path],
593 |row| row.get(0),
594 )?;
595
596 if file_count == 0 {
597 return Ok(None);
598 }
599
600 let total_lines: usize = conn.query_row(
601 "SELECT COALESCE(SUM(line_count), 0) FROM files WHERE path LIKE ?1 OR path = ?2",
602 rusqlite::params![&pattern, path],
603 |row| row.get(0),
604 )?;
605
606 let mut stmt = conn.prepare(
607 "SELECT DISTINCT language FROM files WHERE (path LIKE ?1 OR path = ?2) AND language IS NOT NULL"
608 )?;
609 let languages: Vec<String> = stmt.query_map(rusqlite::params![&pattern, path], |row| row.get(0))?
610 .collect::<Result<Vec<_>, _>>()?;
611
612 Ok(Some(ModuleDefinition {
613 path: path.to_string(),
614 tier,
615 file_count,
616 total_lines,
617 languages,
618 }))
619}
620
621fn build_structure_section(
622 conn: &Connection,
623 module_path: &str,
624 child_modules: &[&ModuleDefinition],
625) -> Result<String> {
626 let pattern = format!("{}/%", module_path);
627
628 let mut content = String::new();
629
630 if !child_modules.is_empty() {
632 content.push_str("### Sub-modules\n\n");
633 for child in child_modules {
634 let short_name = child.path.strip_prefix(module_path)
635 .unwrap_or(&child.path)
636 .trim_start_matches('/');
637 let child_slug = child.path.replace('/', "-");
638 content.push_str(&format!(
639 "- [**{}/**](/wiki/{}/) — {} files, {} lines ({})\n",
640 short_name,
641 child_slug,
642 child.file_count,
643 child.total_lines,
644 child.languages.join(", "),
645 ));
646 }
647 content.push('\n');
648 }
649
650 let prefix_len = module_path.len() + 1;
652 let mut stmt = conn.prepare(
653 "SELECT path, language, COALESCE(line_count, 0) FROM files
654 WHERE path LIKE ?1
655 ORDER BY line_count DESC"
656 )?;
657
658 let files: Vec<(String, Option<String>, i64)> = stmt.query_map([&pattern], |row| {
659 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
660 })?.collect::<Result<Vec<_>, _>>()?;
661
662 let mut by_subdir: HashMap<String, (usize, i64)> = HashMap::new(); let mut direct_files: Vec<(String, i64)> = Vec::new();
665
666 for (path, _, lines) in &files {
667 let rel = &path[prefix_len.min(path.len())..];
668 if let Some(slash_pos) = rel.find('/') {
669 let subdir = &rel[..slash_pos];
670 let entry = by_subdir.entry(subdir.to_string()).or_insert((0, 0));
671 entry.0 += 1;
672 entry.1 += lines;
673 } else {
674 direct_files.push((path.clone(), *lines));
675 }
676 }
677
678 let mut by_lang: HashMap<String, usize> = HashMap::new();
680 for (_, lang, _) in &files {
681 let lang = lang.as_deref().unwrap_or("other");
682 *by_lang.entry(lang.to_string()).or_insert(0) += 1;
683 }
684
685 content.push_str("| Language | Files |\n|---|---|\n");
686 let mut lang_counts: Vec<_> = by_lang.into_iter().collect();
687 lang_counts.sort_by(|a, b| b.1.cmp(&a.1));
688 for (lang, count) in &lang_counts {
689 content.push_str(&format!("| {} | {} |\n", lang, count));
690 }
691
692 if !by_subdir.is_empty() {
694 let mut subdirs: Vec<_> = by_subdir.into_iter().collect();
695 subdirs.sort_by(|a, b| b.1.1.cmp(&a.1.1)); content.push_str("\n### Directories\n\n");
698 content.push_str("| Directory | Files | Lines |\n|---|---|---|\n");
699 for (subdir, (count, lines)) in subdirs.iter().take(20) {
700 content.push_str(&format!("| {}/ | {} | {} |\n", subdir, count, lines));
701 }
702 }
703
704 content.push_str("\n### Largest Files\n\n");
706 let all_sorted: Vec<_> = files.iter()
707 .map(|(path, _, lines)| (path.as_str(), *lines))
708 .collect();
709 for (path, lines) in all_sorted.iter().take(10) {
710 let short = path.strip_prefix(&format!("{}/", module_path)).unwrap_or(path);
711 content.push_str(&format!("- `{}` ({} lines)\n", short, lines));
712 }
713
714 let total = files.len();
715 if total > 10 {
716 content.push_str(&format!(
717 "\n<details><summary><strong>Show {} more files</strong></summary>\n\n",
718 total - 10
719 ));
720 for (path, lines) in all_sorted.iter().skip(10) {
721 let short = path.strip_prefix(&format!("{}/", module_path)).unwrap_or(path);
722 content.push_str(&format!("- `{}` ({} lines)\n", short, lines));
723 }
724 content.push_str("\n</details>\n");
725 }
726
727 Ok(content)
728}
729
730fn build_dependencies_section(
731 conn: &Connection,
732 module_path: &str,
733 all_modules: &[ModuleDefinition],
734) -> Result<String> {
735 let pattern = format!("{}/%", module_path);
736 let mut stmt = conn.prepare(
737 "SELECT DISTINCT f2.path
738 FROM file_dependencies fd
739 JOIN files f1 ON fd.file_id = f1.id
740 JOIN files f2 ON fd.resolved_file_id = f2.id
741 WHERE f1.path LIKE ?1 AND f2.path NOT LIKE ?1
742 ORDER BY f2.path"
743 )?;
744
745 let deps: Vec<String> = stmt.query_map([&pattern], |row| row.get(0))?
746 .collect::<Result<Vec<_>, _>>()?;
747
748 if deps.is_empty() {
749 return Ok("No outgoing dependencies detected.".to_string());
750 }
751
752 let mut by_module: HashMap<String, Vec<String>> = HashMap::new();
754 for dep in &deps {
755 let target_module = find_owning_module(dep, all_modules);
756 by_module.entry(target_module).or_default().push(dep.clone());
757 }
758
759 let mut groups: Vec<_> = by_module.into_iter().collect();
760 groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
761
762 let total_files = deps.len();
763 let total_modules = groups.len();
764
765 let mut content = format!(
766 "Depends on **{} files** across **{} modules**.\n\n",
767 total_files, total_modules
768 );
769
770 for (module, files) in &groups {
771 let module_slug = module.replace('/', "-");
772 content.push_str(&format!("**[{}/](@/wiki/{}.md)** ({} files):\n", module, module_slug, files.len()));
773 for f in files.iter().take(5) {
774 let short = f.rsplit('/').next().unwrap_or(f);
775 content.push_str(&format!("- `{}`\n", short));
776 }
777 if files.len() > 5 {
778 content.push_str(&format!("- ... and {} more\n", files.len() - 5));
779 }
780 content.push('\n');
781 }
782
783 Ok(content)
784}
785
786fn build_dependents_section(
787 conn: &Connection,
788 _deps_index: &DependencyIndex,
789 module_path: &str,
790 all_modules: &[ModuleDefinition],
791) -> Result<String> {
792 let pattern = format!("{}/%", module_path);
793 let mut stmt = conn.prepare(
794 "SELECT DISTINCT f1.path
795 FROM file_dependencies fd
796 JOIN files f1 ON fd.file_id = f1.id
797 JOIN files f2 ON fd.resolved_file_id = f2.id
798 WHERE f2.path LIKE ?1 AND f1.path NOT LIKE ?1
799 ORDER BY f1.path"
800 )?;
801
802 let dependents: Vec<String> = stmt.query_map([&pattern], |row| row.get(0))?
803 .collect::<Result<Vec<_>, _>>()?;
804
805 if dependents.is_empty() {
806 return Ok("No incoming dependencies detected.".to_string());
807 }
808
809 let mut by_module: HashMap<String, Vec<String>> = HashMap::new();
811 for dep in &dependents {
812 let source_module = find_owning_module(dep, all_modules);
813 by_module.entry(source_module).or_default().push(dep.clone());
814 }
815
816 let mut groups: Vec<_> = by_module.into_iter().collect();
817 groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
818
819 let total_files = dependents.len();
820 let total_modules = groups.len();
821
822 let mut content = format!(
823 "Used by **{} files** across **{} modules**.\n\n",
824 total_files, total_modules
825 );
826
827 for (module, files) in &groups {
828 let module_slug = module.replace('/', "-");
829 content.push_str(&format!("**[{}/](@/wiki/{}.md)** ({} files):\n", module, module_slug, files.len()));
830 for f in files.iter().take(5) {
831 let short = f.rsplit('/').next().unwrap_or(f);
832 content.push_str(&format!("- `{}`\n", short));
833 }
834 if files.len() > 5 {
835 content.push_str(&format!("- ... and {} more\n", files.len() - 5));
836 }
837 content.push('\n');
838 }
839
840 Ok(content)
841}
842
843const SYMBOL_BLOCKLIST: &[&str] = &[
846 "return", "this", "self", "super", "new", "null", "true", "false", "none",
848 "class", "function", "var", "let", "const", "static", "public", "private",
849 "protected", "abstract", "virtual", "override", "final", "async", "await",
850 "import", "export", "module", "package", "namespace", "use", "from", "as",
851 "if", "else", "for", "while", "do", "switch", "case", "default", "break",
852 "continue", "try", "catch", "throw", "throws", "finally", "yield",
853 "void", "int", "bool", "string", "float", "double", "char", "byte",
854 "struct", "enum", "trait", "impl", "interface", "type", "where",
855 "data", "value", "name", "key", "item", "items", "list", "result",
857 "error", "err", "msg", "args", "opts", "params", "config", "options",
858 "index", "count", "size", "length", "path", "file", "line", "text",
859 "input", "output", "request", "response", "context", "state", "props",
860 "init", "main", "run", "get", "set", "add", "delete", "update", "create",
861 "test", "setup", "describe", "expect",
862];
863
864const PRIORITY_SYMBOL_KINDS: &[&str] = &[
867 "Function", "Struct", "Class", "Trait", "Interface",
868 "Enum", "Macro", "Type", "Constant",
869];
870
871fn extract_doc_comment(source: &str, start_line: usize, language: &Language) -> Option<String> {
877 let lines: Vec<&str> = source.lines().collect();
878 if start_line == 0 || start_line > lines.len() {
879 return None;
880 }
881
882 if matches!(language, Language::Python) {
884 let search_start = start_line; for i in search_start..lines.len().min(search_start + 3) {
887 let trimmed = lines[i].trim();
888 if trimmed.is_empty() {
889 continue;
890 }
891 if trimmed.starts_with("\"\"\"") || trimmed.starts_with("'''") {
893 let quote = &trimmed[..3];
894 if trimmed.len() > 6 && trimmed.ends_with(quote) {
896 let inner = trimmed[3..trimmed.len() - 3].trim();
897 if !inner.is_empty() {
898 return Some(inner.to_string());
899 }
900 }
901 let mut doc_lines = Vec::new();
903 let first_content = trimmed[3..].trim();
904 if !first_content.is_empty() {
905 doc_lines.push(first_content.to_string());
906 }
907 for j in (i + 1)..lines.len() {
908 let line = lines[j].trim();
909 if line.contains(quote) {
910 let before_close = line.trim_end_matches(quote).trim();
911 if !before_close.is_empty() {
912 doc_lines.push(before_close.to_string());
913 }
914 break;
915 }
916 doc_lines.push(line.to_string());
917 }
918 let result = doc_lines.join("\n").trim().to_string();
919 if !result.is_empty() {
920 return Some(result);
921 }
922 }
923 break; }
925 return None;
926 }
927
928 let mut idx = start_line.saturating_sub(2); let mut comment_lines: Vec<String> = Vec::new();
931
932 loop {
934 if idx >= lines.len() {
935 break;
936 }
937 let trimmed = lines[idx].trim();
938 if trimmed.starts_with("#[") || trimmed.starts_with("#![") {
940 if idx == 0 { return None; }
941 idx -= 1;
942 continue;
943 }
944 if trimmed.starts_with('@') && trimmed.len() > 1 && trimmed[1..].starts_with(|c: char| c.is_alphabetic()) {
946 if idx == 0 { return None; }
947 idx -= 1;
948 continue;
949 }
950 if trimmed.starts_with("#[") {
952 if idx == 0 { return None; }
953 idx -= 1;
954 continue;
955 }
956 break;
957 }
958
959 match language {
961 Language::Rust => {
962 if idx < lines.len() && lines[idx].trim().ends_with("*/") {
965 return extract_block_comment(&lines, idx, "/**");
966 }
967 while idx < lines.len() {
969 let trimmed = lines[idx].trim();
970 if trimmed.starts_with("///") {
971 let content = trimmed.trim_start_matches('/').trim();
972 comment_lines.push(content.to_string());
973 } else if trimmed.starts_with("//!") {
974 let content = trimmed[3..].trim().to_string();
975 comment_lines.push(content);
976 } else {
977 break;
978 }
979 if idx == 0 { break; }
980 idx -= 1;
981 }
982 }
983 Language::Go => {
984 while idx < lines.len() {
986 let trimmed = lines[idx].trim();
987 if trimmed.starts_with("//") {
988 let content = trimmed[2..].trim().to_string();
989 comment_lines.push(content);
990 } else {
991 break;
992 }
993 if idx == 0 { break; }
994 idx -= 1;
995 }
996 }
997 Language::Ruby => {
998 while idx < lines.len() {
1000 let trimmed = lines[idx].trim();
1001 if trimmed.starts_with('#') && !trimmed.starts_with("#!") {
1002 let content = trimmed[1..].trim().to_string();
1003 comment_lines.push(content);
1004 } else {
1005 break;
1006 }
1007 if idx == 0 { break; }
1008 idx -= 1;
1009 }
1010 }
1011 _ => {
1012 if idx < lines.len() {
1014 let trimmed = lines[idx].trim();
1015 if trimmed.ends_with("*/") {
1016 return extract_block_comment(&lines, idx, "/**");
1017 }
1018 if trimmed.starts_with("///") || trimmed.starts_with("//") {
1020 while idx < lines.len() {
1021 let t = lines[idx].trim();
1022 if t.starts_with("///") {
1023 comment_lines.push(t.trim_start_matches('/').trim().to_string());
1024 } else if t.starts_with("//") && !t.starts_with("///") {
1025 comment_lines.push(t[2..].trim().to_string());
1026 } else {
1027 break;
1028 }
1029 if idx == 0 { break; }
1030 idx -= 1;
1031 }
1032 }
1033 }
1034 }
1035 }
1036
1037 if comment_lines.is_empty() {
1038 return None;
1039 }
1040
1041 comment_lines.reverse();
1043 let result = comment_lines.join("\n").trim().to_string();
1044 if result.is_empty() { None } else { Some(result) }
1045}
1046
1047fn extract_block_comment(lines: &[&str], end_idx: usize, open_marker: &str) -> Option<String> {
1049 let mut doc_lines: Vec<String> = Vec::new();
1050 let mut idx = end_idx;
1051
1052 loop {
1053 let trimmed = lines[idx].trim();
1054
1055 if trimmed.starts_with(open_marker) || trimmed.starts_with("/*") {
1057 let content = trimmed
1059 .trim_start_matches(open_marker)
1060 .trim_start_matches("/*")
1061 .trim_end_matches("*/")
1062 .trim_end_matches('*')
1063 .trim();
1064 if !content.is_empty() {
1065 doc_lines.push(content.to_string());
1066 }
1067 break;
1068 }
1069
1070 let content = trimmed
1072 .trim_end_matches("*/")
1073 .trim_start_matches('*')
1074 .trim();
1075 if !content.is_empty() {
1076 doc_lines.push(content.to_string());
1077 }
1078
1079 if idx == 0 { break; }
1080 idx -= 1;
1081 }
1082
1083 doc_lines.reverse();
1084 let result = doc_lines.join("\n").trim().to_string();
1085 if result.is_empty() { None } else { Some(result) }
1086}
1087
1088fn html_escape(s: &str) -> String {
1090 s.replace('&', "&")
1091 .replace('<', "<")
1092 .replace('>', ">")
1093}
1094
1095fn render_by_kind_entry(content: &mut String, name: &str, short_path: &str, doc: Option<&str>) {
1098 match doc {
1099 Some(d) if d.lines().count() > 1 => {
1100 let first_line = html_escape(d.lines().next().unwrap_or(""));
1101 let body: String = d.lines()
1102 .map(|line| format!("<p>{}</p>", html_escape(line)))
1103 .collect::<Vec<_>>()
1104 .join("\n");
1105 content.push_str(&format!(
1106 "<li><code>{}</code> ({})\n<details><summary>{}</summary>\n<div class=\"doc-comment\">\n{}\n</div>\n</details>\n</li>\n",
1107 html_escape(name), html_escape(short_path), first_line, body
1108 ));
1109 }
1110 Some(d) => {
1111 content.push_str(&format!(
1112 "<li><code>{}</code> ({}) — <span class=\"doc-comment-inline\">{}</span></li>\n",
1113 html_escape(name), html_escape(short_path), html_escape(d)
1114 ));
1115 }
1116 None => {
1117 content.push_str(&format!(
1118 "<li><code>{}</code> ({})</li>\n",
1119 html_escape(name), html_escape(short_path)
1120 ));
1121 }
1122 }
1123}
1124
1125fn build_key_symbols_section(conn: &Connection, module_path: &str, query_engine: &QueryEngine) -> String {
1126 let pattern = format!("{}/%", module_path);
1127 let mut stmt = match conn.prepare(
1128 "SELECT path, language FROM files
1129 WHERE path LIKE ?1 AND language IS NOT NULL
1130 ORDER BY COALESCE(line_count, 0) DESC
1131 LIMIT 20"
1132 ) {
1133 Ok(s) => s,
1134 Err(_) => return "No symbols extracted.".to_string(),
1135 };
1136
1137 let files: Vec<(String, String)> = match stmt.query_map([&pattern], |row| {
1138 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1139 }) {
1140 Ok(rows) => rows.filter_map(|r| r.ok()).collect(),
1141 Err(_) => return "No symbols extracted.".to_string(),
1142 };
1143
1144 if files.is_empty() {
1145 return "No files in this module.".to_string();
1146 }
1147
1148 let mut by_kind: HashMap<String, Vec<(String, String, usize, Option<String>)>> = HashMap::new();
1151 let mut total_symbols = 0usize;
1152
1153 for (path, lang_str) in &files {
1154 let language = match Language::from_name(lang_str) {
1155 Some(l) => l,
1156 None => continue,
1157 };
1158
1159 let source = match std::fs::read_to_string(path) {
1161 Ok(s) => s,
1162 Err(_) => continue,
1163 };
1164
1165 let symbols = match ParserFactory::parse(path, &source, language) {
1166 Ok(s) => s,
1167 Err(_) => continue,
1168 };
1169
1170 for sym in symbols {
1171 if let Some(name) = &sym.symbol {
1172 match &sym.kind {
1174 SymbolKind::Import | SymbolKind::Export | SymbolKind::Variable | SymbolKind::Unknown(_) => continue,
1175 _ => {}
1176 }
1177
1178 let kind_name = format!("{}", sym.kind);
1179 let size = sym.span.end_line.saturating_sub(sym.span.start_line) + 1;
1180 let doc_comment = extract_doc_comment(&source, sym.span.start_line, &language);
1181 by_kind
1182 .entry(kind_name)
1183 .or_default()
1184 .push((name.clone(), path.clone(), size, doc_comment));
1185 total_symbols += 1;
1186 }
1187 }
1188 }
1189
1190 if total_symbols == 0 {
1191 return "No symbols extracted.".to_string();
1192 }
1193
1194 let mut content = String::new();
1195
1196 let mut doc_comments: HashMap<String, String> = HashMap::new();
1198 for entries in by_kind.values() {
1199 for (name, _path, _size, doc) in entries {
1200 if let Some(d) = doc {
1201 doc_comments.entry(name.clone()).or_insert_with(|| d.clone());
1202 }
1203 }
1204 }
1205
1206 let mut unique_symbols: HashMap<String, (String, String)> = HashMap::new(); for (kind_str, entries) in &by_kind {
1211 if PRIORITY_SYMBOL_KINDS.contains(&kind_str.as_str()) {
1212 for (name, path, _size, _doc) in entries {
1213 unique_symbols.entry(name.clone()).or_insert_with(|| (kind_str.clone(), path.clone()));
1214 }
1215 }
1216 }
1217 for (kind_str, entries) in &by_kind {
1219 if !PRIORITY_SYMBOL_KINDS.contains(&kind_str.as_str()) {
1220 for (name, path, _size, _doc) in entries {
1221 unique_symbols.entry(name.clone()).or_insert_with(|| (kind_str.clone(), path.clone()));
1222 }
1223 }
1224 }
1225
1226 let mut candidates: Vec<(String, String, String, usize)> = Vec::new(); for (name, (kind, path)) in &unique_symbols {
1230 if !PRIORITY_SYMBOL_KINDS.contains(&kind.as_str()) {
1232 continue;
1233 }
1234 if name.len() < 4 {
1236 continue;
1237 }
1238 if SYMBOL_BLOCKLIST.contains(&name.to_lowercase().as_str()) {
1240 continue;
1241 }
1242 if name.starts_with('$') {
1244 let stripped = &name[1..];
1245 if stripped.len() < 4 || SYMBOL_BLOCKLIST.contains(&stripped.to_lowercase().as_str()) {
1246 continue;
1247 }
1248 }
1249
1250 let span_size = by_kind.get(kind)
1252 .and_then(|entries| entries.iter().find(|(n, _, _, _)| n == name))
1253 .map(|(_, _, size, _)| *size)
1254 .unwrap_or(1);
1255
1256 candidates.push((name.clone(), kind.clone(), path.clone(), span_size));
1257 }
1258
1259 candidates.sort_by(|a, b| b.3.cmp(&a.3).then_with(|| a.0.cmp(&b.0)));
1261 candidates.truncate(15);
1262
1263 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 {
1267 let filter = QueryFilter {
1268 paths_only: true,
1269 force: true,
1270 suppress_output: true,
1271 limit: None,
1272 ..Default::default()
1273 };
1274 let def_short = path.rsplit('/').next().unwrap_or(path);
1275 match query_engine.search_with_metadata(name, filter) {
1276 Ok(response) => {
1277 let ref_count = response.results.len();
1278 let mut files: Vec<String> = response.results.iter()
1280 .map(|r| r.path.rsplit('/').next().unwrap_or(&r.path).to_string())
1281 .filter(|f| f != def_short)
1282 .collect();
1283 files.sort();
1284 files.dedup();
1285 ref_files.insert(name.clone(), files);
1286 ranked.push((name.clone(), kind.clone(), path.clone(), ref_count));
1287 }
1288 Err(_) => {
1289 ranked.push((name.clone(), kind.clone(), path.clone(), 0));
1290 }
1291 }
1292 }
1293
1294 ranked.sort_by(|a, b| b.3.cmp(&a.3).then_with(|| a.0.cmp(&b.0)));
1296
1297 if !ranked.is_empty() {
1298 content.push_str("<p><strong>Key definitions:</strong></p>\n<ul>\n");
1299 for (name, kind, path, ref_count) in ranked.iter().take(5) {
1300 let short = path.rsplit('/').next().unwrap_or(path);
1301 content.push_str("<li>\n");
1302 content.push_str(&format!(
1303 "<p><code>{}</code> ({}) in {} — referenced in {} {}</p>\n",
1304 html_escape(name), html_escape(kind), html_escape(short), ref_count,
1305 if *ref_count == 1 { "file" } else { "files" }
1306 ));
1307
1308 if let Some(doc) = doc_comments.get(name.as_str()) {
1310 let first_line = html_escape(doc.lines().next().unwrap_or(""));
1311 let is_multiline = doc.lines().count() > 1;
1312 if is_multiline {
1313 let body: String = doc.lines()
1314 .map(|line| format!("<p>{}</p>", html_escape(line)))
1315 .collect::<Vec<_>>()
1316 .join("\n");
1317 content.push_str(&format!(
1318 "<details><summary>{}</summary>\n<div class=\"doc-comment\">\n{}\n</div>\n</details>\n",
1319 first_line, body
1320 ));
1321 } else {
1322 content.push_str(&format!(
1323 "<details><summary>{}</summary></details>\n",
1324 first_line
1325 ));
1326 }
1327 }
1328
1329 if let Some(files) = ref_files.get(name.as_str()) {
1331 if !files.is_empty() {
1332 let show: Vec<&str> = files.iter().take(5).map(|s| s.as_str()).collect();
1333 let mut ref_line = format!("<ul><li class=\"ref-list\">Referenced by: {}", show.join(", "));
1334 if files.len() > 5 {
1335 ref_line.push_str(&format!(" +{} more", files.len() - 5));
1336 }
1337 ref_line.push_str("</li></ul>\n");
1338 content.push_str(&ref_line);
1339 }
1340 }
1341
1342 content.push_str("</li>\n");
1343 }
1344 content.push_str("</ul>\n\n");
1345 }
1346
1347 let display_order = [
1349 "Function", "Struct", "Class", "Trait", "Interface",
1350 "Enum", "Method", "Constant", "Type", "Macro",
1351 "Variable", "Module", "Namespace", "Property", "Attribute",
1352 ];
1353
1354 for kind in &display_order {
1355 let kind_str = kind.to_string();
1356 if let Some(entries) = by_kind.get_mut(&kind_str) {
1357 entries.sort_by(|a, b| b.2.cmp(&a.2));
1358 let count = entries.len();
1359 content.push_str(&format!("<details><summary><strong>{}</strong> ({})</summary>\n<ul>\n", kind, count));
1360 for (name, path, _size, doc) in entries.iter() {
1361 let short = path.rsplit('/').next().unwrap_or(path);
1362 render_by_kind_entry(&mut content, name, short, doc.as_deref());
1363 }
1364 content.push_str("</ul>\n</details>\n\n");
1365 }
1366 }
1367
1368 for (kind, entries) in &mut by_kind {
1370 if display_order.contains(&kind.as_str()) {
1371 continue;
1372 }
1373 entries.sort_by(|a, b| b.2.cmp(&a.2));
1374 let count = entries.len();
1375 content.push_str(&format!("<details><summary><strong>{}</strong> ({})</summary>\n<ul>\n", kind, count));
1376 for (name, path, _size, doc) in entries.iter() {
1377 let short = path.rsplit('/').next().unwrap_or(path);
1378 render_by_kind_entry(&mut content, name, short, doc.as_deref());
1379 }
1380 content.push_str("</ul>\n</details>\n\n");
1381 }
1382
1383 if content.is_empty() {
1384 "No symbols extracted.".to_string()
1385 } else {
1386 content
1387 }
1388}
1389
1390fn build_metrics_section(module: &ModuleDefinition, conn: &Connection) -> Result<String> {
1391 let pattern = format!("{}/%", module.path);
1392
1393 let avg_lines = if module.file_count > 0 {
1395 module.total_lines / module.file_count
1396 } else {
1397 0
1398 };
1399
1400 let outgoing: usize = conn.query_row(
1402 "SELECT COUNT(DISTINCT fd.resolved_file_id)
1403 FROM file_dependencies fd
1404 JOIN files f1 ON fd.file_id = f1.id
1405 JOIN files f2 ON fd.resolved_file_id = f2.id
1406 WHERE f1.path LIKE ?1 AND f2.path NOT LIKE ?1",
1407 [&pattern],
1408 |row| row.get(0),
1409 ).unwrap_or(0);
1410
1411 let incoming: usize = conn.query_row(
1413 "SELECT COUNT(DISTINCT fd.file_id)
1414 FROM file_dependencies fd
1415 JOIN files f1 ON fd.file_id = f1.id
1416 JOIN files f2 ON fd.resolved_file_id = f2.id
1417 WHERE f2.path LIKE ?1 AND f1.path NOT LIKE ?1",
1418 [&pattern],
1419 |row| row.get(0),
1420 ).unwrap_or(0);
1421
1422 Ok(format!(
1423 "| Metric | Value |\n|---|---|\n\
1424 | Files | {} |\n\
1425 | Total lines | {} |\n\
1426 | Avg lines/file | {} |\n\
1427 | Languages | {} |\n\
1428 | Outgoing deps | {} |\n\
1429 | Incoming deps | {} |\n\
1430 | Tier | {} |",
1431 module.file_count,
1432 module.total_lines,
1433 avg_lines,
1434 module.languages.join(", "),
1435 outgoing,
1436 incoming,
1437 module.tier,
1438 ))
1439}
1440
1441fn find_owning_module(file_path: &str, modules: &[ModuleDefinition]) -> String {
1443 let mut best_match = String::new();
1444 let mut best_len = 0;
1445
1446 for module in modules {
1447 let prefix = format!("{}/", module.path);
1448 if file_path.starts_with(&prefix) && module.path.len() > best_len {
1449 best_match = module.path.clone();
1450 best_len = module.path.len();
1451 }
1452 }
1453
1454 if best_match.is_empty() {
1455 file_path.split('/').next().unwrap_or("root").to_string()
1457 } else {
1458 best_match
1459 }
1460}
1461
1462fn build_recent_changes(diff: &super::diff::SnapshotDiff, module_path: &str) -> String {
1463 let prefix = format!("{}/", module_path);
1464 let mut content = String::new();
1465
1466 let added: Vec<_> = diff.files_added.iter()
1467 .filter(|f| f.path.starts_with(&prefix))
1468 .collect();
1469 let removed: Vec<_> = diff.files_removed.iter()
1470 .filter(|f| f.path.starts_with(&prefix))
1471 .collect();
1472 let modified: Vec<_> = diff.files_modified.iter()
1473 .filter(|f| f.path.starts_with(&prefix))
1474 .collect();
1475
1476 if added.is_empty() && removed.is_empty() && modified.is_empty() {
1477 return "No changes in this module since last snapshot.".to_string();
1478 }
1479
1480 if !added.is_empty() {
1481 content.push_str(&format!("**Added** ({}):\n", added.len()));
1482 for f in added.iter().take(10) {
1483 content.push_str(&format!("- `{}`\n", f.path));
1484 }
1485 }
1486 if !removed.is_empty() {
1487 content.push_str(&format!("**Removed** ({}):\n", removed.len()));
1488 for f in removed.iter().take(10) {
1489 content.push_str(&format!("- `{}`\n", f.path));
1490 }
1491 }
1492 if !modified.is_empty() {
1493 content.push_str(&format!("**Modified** ({}):\n", modified.len()));
1494 for f in modified.iter().take(10) {
1495 let delta = f.new_line_count as i64 - f.old_line_count as i64;
1496 content.push_str(&format!("- `{}` ({:+} lines)\n", f.path, delta));
1497 }
1498 }
1499
1500 content
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505 use super::*;
1506
1507 #[test]
1508 fn test_module_definition_serialization() {
1509 let module = ModuleDefinition {
1510 path: "src".to_string(),
1511 tier: 1,
1512 file_count: 50,
1513 total_lines: 5000,
1514 languages: vec!["Rust".to_string()],
1515 };
1516 let json = serde_json::to_string(&module).unwrap();
1517 assert!(json.contains("src"));
1518 }
1519
1520 #[test]
1521 fn test_render_wiki_page() {
1522 let page = WikiPage {
1523 module_path: "src".to_string(),
1524 title: "src/".to_string(),
1525 sections: WikiSections {
1526 summary: None,
1527 structure: "test structure".to_string(),
1528 dependencies: "test deps".to_string(),
1529 dependents: "test dependents".to_string(),
1530 dependency_diagram: None,
1531 circular_deps: None,
1532 key_symbols: "test symbols".to_string(),
1533 metrics: "test metrics".to_string(),
1534 recent_changes: None,
1535 },
1536 };
1537 let rendered = render_wiki_markdown(&[page]);
1538 assert_eq!(rendered.len(), 1);
1539 assert_eq!(rendered[0].0, "src.md");
1540 assert!(rendered[0].1.contains("# src/"));
1541 }
1542}