1use anyhow::Result;
7use rusqlite::Connection;
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10
11use crate::cache::CacheManager;
12use crate::dependency::DependencyIndex;
13
14use super::wiki;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub enum MapZoom {
19 Repo,
21 Module(String),
23}
24
25#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
27pub enum MapFormat {
28 Mermaid,
29 D2,
30}
31
32impl std::str::FromStr for MapFormat {
33 type Err = anyhow::Error;
34 fn from_str(s: &str) -> Result<Self> {
35 match s.to_lowercase().as_str() {
36 "mermaid" => Ok(MapFormat::Mermaid),
37 "d2" => Ok(MapFormat::D2),
38 _ => anyhow::bail!("Unknown map format: {}. Supported: mermaid, d2", s),
39 }
40 }
41}
42
43pub fn generate_map(cache: &CacheManager, zoom: &MapZoom, format: MapFormat) -> Result<String> {
45 match zoom {
46 MapZoom::Repo => generate_repo_map(cache, format),
47 MapZoom::Module(module) => generate_module_map(cache, module, format),
48 }
49}
50
51fn generate_repo_map(cache: &CacheManager, format: MapFormat) -> Result<String> {
52 let db_path = cache.path().join("meta.db");
53 let conn = Connection::open(&db_path)?;
54
55 let modules = wiki::detect_modules(cache, &wiki::ModuleDiscoveryConfig::default())?;
57
58 let module_info: Vec<(String, usize)> = modules
60 .iter()
61 .map(|m| (m.path.clone(), m.file_count))
62 .collect();
63
64 let mut stmt = conn.prepare(
66 "SELECT f1.path, f2.path
67 FROM file_dependencies fd
68 JOIN files f1 ON fd.file_id = f1.id
69 JOIN files f2 ON fd.resolved_file_id = f2.id
70 WHERE fd.resolved_file_id IS NOT NULL",
71 )?;
72
73 let file_edges: Vec<(String, String)> = stmt
74 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
75 .collect::<Result<Vec<_>, _>>()?;
76
77 let mut module_edges: HashMap<(String, String), usize> = HashMap::new();
79 for (src_file, tgt_file) in &file_edges {
80 let src_module = find_owning_module(src_file, &modules);
81 let tgt_module = find_owning_module(tgt_file, &modules);
82
83 if src_module != tgt_module {
84 *module_edges.entry((src_module, tgt_module)).or_insert(0) += 1;
85 }
86 }
87
88 let mut edges: Vec<(String, String, usize)> = module_edges
89 .into_iter()
90 .map(|((s, t), c)| (s, t, c))
91 .collect();
92 edges.sort_by(|a, b| b.2.cmp(&a.2));
93
94 let deps_index = DependencyIndex::new(cache.clone());
96 let hotspots = deps_index.find_hotspots(Some(10), 5).unwrap_or_default();
97 let hotspot_modules: HashSet<String> = hotspots
98 .iter()
99 .filter_map(|(id, _)| {
100 deps_index
101 .get_file_paths(&[*id])
102 .ok()
103 .and_then(|paths| paths.get(id).cloned())
104 .map(|p| find_owning_module(&p, &modules))
105 })
106 .collect();
107
108 match format {
109 MapFormat::Mermaid => render_mermaid_repo(&module_info, &edges, &hotspot_modules),
110 MapFormat::D2 => render_d2_repo(&module_info, &edges, &hotspot_modules),
111 }
112}
113
114fn find_owning_module(file_path: &str, modules: &[wiki::ModuleDefinition]) -> String {
116 let mut best_match = String::new();
117 let mut best_len = 0;
118
119 for module in modules {
120 let prefix = format!("{}/", module.path);
121 if file_path.starts_with(&prefix) && module.path.len() > best_len {
122 best_match = module.path.clone();
123 best_len = module.path.len();
124 }
125 }
126
127 if best_match.is_empty() {
128 file_path.split('/').next().unwrap_or("root").to_string()
129 } else {
130 best_match
131 }
132}
133
134fn generate_module_map(
135 cache: &CacheManager,
136 module_path: &str,
137 format: MapFormat,
138) -> Result<String> {
139 let db_path = cache.path().join("meta.db");
140 let conn = Connection::open(&db_path)?;
141 let pattern = format!("{}/%", module_path);
142
143 let mut stmt = conn.prepare("SELECT id, path FROM files WHERE path LIKE ?1 ORDER BY path")?;
145 let files: Vec<(i64, String)> = stmt
146 .query_map([&pattern], |row| Ok((row.get(0)?, row.get(1)?)))?
147 .collect::<Result<Vec<_>, _>>()?;
148
149 let mut stmt = conn.prepare(
151 "SELECT f1.path, f2.path
152 FROM file_dependencies fd
153 JOIN files f1 ON fd.file_id = f1.id
154 JOIN files f2 ON fd.resolved_file_id = f2.id
155 WHERE f1.path LIKE ?1 AND f2.path LIKE ?1
156 AND fd.resolved_file_id IS NOT NULL",
157 )?;
158 let edges: Vec<(String, String)> = stmt
159 .query_map([&pattern], |row| Ok((row.get(0)?, row.get(1)?)))?
160 .collect::<Result<Vec<_>, _>>()?;
161
162 match format {
163 MapFormat::Mermaid => render_mermaid_module(module_path, &files, &edges),
164 MapFormat::D2 => render_d2_module(module_path, &files, &edges),
165 }
166}
167
168fn sanitize_id(s: &str) -> String {
171 format!("m_{}", s.replace(['/', '.', '-', ' '], "_"))
172}
173
174fn render_mermaid_repo(
175 modules: &[(String, usize)],
176 edges: &[(String, String, usize)],
177 hotspot_modules: &HashSet<String>,
178) -> Result<String> {
179 let mut out = String::from("graph LR\n");
180
181 let connected: HashSet<&str> = edges
183 .iter()
184 .flat_map(|(s, t, _)| [s.as_str(), t.as_str()])
185 .collect();
186
187 for (module, count) in modules {
188 if !connected.contains(module.as_str()) {
189 continue;
190 }
191 let id = sanitize_id(module);
192 out.push_str(&format!(" {}[\"{}/ ({} files)\"]\n", id, module, count));
193 }
194
195 out.push('\n');
196
197 let mut thick_edge_indices: Vec<usize> = Vec::new();
199 for (i, (src, tgt, count)) in edges.iter().enumerate() {
200 let src_id = sanitize_id(src);
201 let tgt_id = sanitize_id(tgt);
202 out.push_str(&format!(" {} -->|{}| {}\n", src_id, count, tgt_id));
203 if *count > 5 {
204 thick_edge_indices.push(i);
205 }
206 }
207
208 for idx in &thick_edge_indices {
210 out.push_str(&format!(
211 " linkStyle {} stroke-width:3px,stroke:#a78bfa\n",
212 idx
213 ));
214 }
215
216 out.push_str("\n classDef default fill:#1a1a2e,stroke:#a78bfa,color:#e0e0e0\n");
218 out.push_str(" classDef hotspot fill:#2a1030,stroke:#f472b6,color:#f472b6\n");
219 if !hotspot_modules.is_empty() {
220 for module in hotspot_modules {
221 if !connected.contains(module.as_str()) {
222 continue;
223 }
224 let id = sanitize_id(module);
225 out.push_str(&format!(" class {} hotspot\n", id));
226 }
227 }
228
229 for (module, _) in modules {
231 if !connected.contains(module.as_str()) {
232 continue;
233 }
234 let id = sanitize_id(module);
235 let slug = module.replace('/', "-");
236 out.push_str(&format!(" click {} \"/wiki/{}/\"\n", id, slug));
237 }
238
239 Ok(out)
240}
241
242pub fn generate_layered_map(cache: &CacheManager, format: MapFormat) -> Result<String> {
244 let db_path = cache.path().join("meta.db");
245 let conn = Connection::open(&db_path)?;
246 let modules = wiki::detect_modules(cache, &wiki::ModuleDiscoveryConfig::default())?;
247
248 let module_info: Vec<(String, usize, u8)> = modules
249 .iter()
250 .map(|m| (m.path.clone(), m.file_count, m.tier))
251 .collect();
252
253 let mut stmt = conn.prepare(
255 "SELECT f1.path, f2.path
256 FROM file_dependencies fd
257 JOIN files f1 ON fd.file_id = f1.id
258 JOIN files f2 ON fd.resolved_file_id = f2.id
259 WHERE fd.resolved_file_id IS NOT NULL",
260 )?;
261 let file_edges: Vec<(String, String)> = stmt
262 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
263 .collect::<Result<Vec<_>, _>>()?;
264
265 let mut module_edges: HashMap<(String, String), usize> = HashMap::new();
266 for (src_file, tgt_file) in &file_edges {
267 let src_module = find_owning_module(src_file, &modules);
268 let tgt_module = find_owning_module(tgt_file, &modules);
269 if src_module != tgt_module {
270 *module_edges.entry((src_module, tgt_module)).or_insert(0) += 1;
271 }
272 }
273
274 let mut edges: Vec<(String, String, usize)> = module_edges
275 .into_iter()
276 .map(|((s, t), c)| (s, t, c))
277 .collect();
278 edges.sort_by(|a, b| b.2.cmp(&a.2));
279
280 let deps_index = DependencyIndex::new(cache.clone());
281 let hotspots = deps_index.find_hotspots(Some(10), 5).unwrap_or_default();
282 let hotspot_modules: HashSet<String> = hotspots
283 .iter()
284 .filter_map(|(id, _)| {
285 deps_index
286 .get_file_paths(&[*id])
287 .ok()
288 .and_then(|paths| paths.get(id).cloned())
289 .map(|p| find_owning_module(&p, &modules))
290 })
291 .collect();
292
293 match format {
294 MapFormat::Mermaid => render_mermaid_layered(&module_info, &edges, &hotspot_modules),
295 MapFormat::D2 => render_d2_repo(
296 &module_info
297 .iter()
298 .map(|(p, c, _)| (p.clone(), *c))
299 .collect::<Vec<_>>(),
300 &edges,
301 &hotspot_modules,
302 ),
303 }
304}
305
306fn render_mermaid_layered(
307 modules: &[(String, usize, u8)],
308 edges: &[(String, String, usize)],
309 hotspot_modules: &HashSet<String>,
310) -> Result<String> {
311 let mut out = String::from("flowchart TB\n");
312
313 let connected: HashSet<&str> = edges
315 .iter()
316 .flat_map(|(s, t, _)| [s.as_str(), t.as_str()])
317 .collect();
318
319 let tier1: Vec<&(String, usize, u8)> = modules.iter().filter(|m| m.2 == 1).collect();
321 let tier2: Vec<&(String, usize, u8)> = modules.iter().filter(|m| m.2 == 2).collect();
322
323 let mut proxy_map: HashMap<String, String> = HashMap::new();
327
328 for t1 in &tier1 {
329 if !connected.contains(t1.0.as_str()) {
330 continue;
331 }
332 let t1_id = sanitize_id(&t1.0);
333 let children: Vec<&&(String, usize, u8)> = tier2
334 .iter()
335 .filter(|t2| {
336 t2.0.starts_with(&format!("{}/", t1.0)) && connected.contains(t2.0.as_str())
337 })
338 .collect();
339
340 if children.is_empty() {
341 out.push_str(&format!(" {}[\"{}/ ({} files)\"]\n", t1_id, t1.0, t1.1));
343 } else {
344 let proxy_id = format!("{}_self", t1_id);
346 proxy_map.insert(t1.0.clone(), proxy_id.clone());
347
348 out.push_str(&format!(" subgraph {} [\"{}/ \"]\n", t1_id, t1.0));
349 out.push_str(&format!(
350 " {}[\"{}/ ({} files)\"]\n",
351 proxy_id, t1.0, t1.1
352 ));
353 for child in &children {
354 let child_id = sanitize_id(&child.0);
355 let short = child
356 .0
357 .strip_prefix(&format!("{}/", t1.0))
358 .unwrap_or(&child.0);
359 out.push_str(&format!(
360 " {}[\"{}/ ({} files)\"]\n",
361 child_id, short, child.1
362 ));
363 }
364 out.push_str(" end\n");
365 }
366 }
367
368 for t2 in &tier2 {
370 if !connected.contains(t2.0.as_str()) {
371 continue;
372 }
373 let has_parent = tier1
374 .iter()
375 .any(|t1| t2.0.starts_with(&format!("{}/", t1.0)));
376 if !has_parent {
377 let id = sanitize_id(&t2.0);
378 out.push_str(&format!(" {}[\"{}/ ({} files)\"]\n", id, t2.0, t2.1));
379 }
380 }
381
382 out.push('\n');
383
384 let mut thick_edge_indices: Vec<usize> = Vec::new();
387 for (i, (src, tgt, count)) in edges.iter().enumerate() {
388 let src_id = proxy_map
389 .get(src)
390 .cloned()
391 .unwrap_or_else(|| sanitize_id(src));
392 let tgt_id = proxy_map
393 .get(tgt)
394 .cloned()
395 .unwrap_or_else(|| sanitize_id(tgt));
396 out.push_str(&format!(" {} -->|{}| {}\n", src_id, count, tgt_id));
397 if *count > 5 {
398 thick_edge_indices.push(i);
399 }
400 }
401
402 for idx in &thick_edge_indices {
404 out.push_str(&format!(
405 " linkStyle {} stroke-width:3px,stroke:#a78bfa\n",
406 idx
407 ));
408 }
409
410 out.push_str("\n classDef default fill:#1a1a2e,stroke:#a78bfa,color:#e0e0e0\n");
412 out.push_str(" classDef hotspot fill:#2a1030,stroke:#f472b6,color:#f472b6\n");
413 for module in hotspot_modules {
414 if !connected.contains(module.as_str()) {
415 continue;
416 }
417 let id = proxy_map
418 .get(module)
419 .cloned()
420 .unwrap_or_else(|| sanitize_id(module));
421 out.push_str(&format!(" class {} hotspot\n", id));
422 }
423
424 for (module, _, _) in modules {
426 if !connected.contains(module.as_str()) {
427 continue;
428 }
429 let id = proxy_map
430 .get(module)
431 .cloned()
432 .unwrap_or_else(|| sanitize_id(module));
433 let slug = module.replace('/', "-");
434 out.push_str(&format!(" click {} \"/wiki/{}/\"\n", id, slug));
435 }
436
437 Ok(out)
438}
439
440fn render_d2_repo(
441 modules: &[(String, usize)],
442 edges: &[(String, String, usize)],
443 hotspot_modules: &HashSet<String>,
444) -> Result<String> {
445 let mut out = String::new();
446
447 for (module, count) in modules {
448 let id = sanitize_id(module);
449 out.push_str(&format!("{}: \"{}/ ({} files)\"\n", id, module, count));
450 if hotspot_modules.contains(module) {
451 out.push_str(&format!("{}.style.fill: \"#ff6b6b\"\n", id));
452 }
453 }
454
455 out.push('\n');
456
457 for (src, tgt, count) in edges {
458 let src_id = sanitize_id(src);
459 let tgt_id = sanitize_id(tgt);
460 out.push_str(&format!("{} -> {}: {}\n", src_id, tgt_id, count));
461 }
462
463 Ok(out)
464}
465
466fn render_mermaid_module(
467 module_path: &str,
468 files: &[(i64, String)],
469 edges: &[(String, String)],
470) -> Result<String> {
471 let mut out = format!("graph LR\n subgraph {}\n", module_path);
472
473 for (_, path) in files {
474 let id = sanitize_id(path);
475 let short_name = path.rsplit('/').next().unwrap_or(path);
476 out.push_str(&format!(" {}[\"{}\"]\n", id, short_name));
477 }
478
479 for (src, tgt) in edges {
480 let src_id = sanitize_id(src);
481 let tgt_id = sanitize_id(tgt);
482 out.push_str(&format!(" {} --> {}\n", src_id, tgt_id));
483 }
484
485 out.push_str(" end\n");
486
487 Ok(out)
488}
489
490fn render_d2_module(
491 module_path: &str,
492 files: &[(i64, String)],
493 edges: &[(String, String)],
494) -> Result<String> {
495 let mut out = format!("{}: {{\n", sanitize_id(module_path));
496
497 for (_, path) in files {
498 let id = sanitize_id(path);
499 let short_name = path.rsplit('/').next().unwrap_or(path);
500 out.push_str(&format!(" {}: \"{}\"\n", id, short_name));
501 }
502
503 for (src, tgt) in edges {
504 let src_id = sanitize_id(src);
505 let tgt_id = sanitize_id(tgt);
506 out.push_str(&format!(" {} -> {}\n", src_id, tgt_id));
507 }
508
509 out.push_str("}\n");
510
511 Ok(out)
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
519 fn test_sanitize_id() {
520 assert_eq!(sanitize_id("src/parsers"), "m_src_parsers");
521 assert_eq!(sanitize_id("my-module.rs"), "m_my_module_rs");
522 }
523
524 #[test]
525 fn test_mermaid_repo_output() {
526 let modules = vec![("src".to_string(), 50), ("tests".to_string(), 10)];
527 let edges = vec![("src".to_string(), "tests".to_string(), 3)];
528 let hotspots = HashSet::new();
529
530 let result = render_mermaid_repo(&modules, &edges, &hotspots).unwrap();
531 assert!(result.contains("graph LR"));
532 assert!(result.contains("src"));
533 assert!(result.contains("tests"));
534 assert!(result.contains("-->"));
535 }
536
537 #[test]
538 fn test_d2_repo_output() {
539 let modules = vec![("src".to_string(), 50)];
540 let edges = vec![];
541 let hotspots = HashSet::from(["src".to_string()]);
542
543 let result = render_d2_repo(&modules, &edges, &hotspots).unwrap();
544 assert!(result.contains("src:"));
545 assert!(result.contains("#ff6b6b"));
546 }
547
548 #[test]
549 fn test_mermaid_repo_filters_orphans() {
550 let modules = vec![
551 ("src".to_string(), 50),
552 ("tests".to_string(), 10),
553 ("docs".to_string(), 5), ("scripts".to_string(), 2), ];
556 let edges = vec![("src".to_string(), "tests".to_string(), 3)];
557 let hotspots = HashSet::from(["docs".to_string()]);
558
559 let result = render_mermaid_repo(&modules, &edges, &hotspots).unwrap();
560
561 assert!(
563 result.contains("m_src["),
564 "connected module 'src' should be in output"
565 );
566 assert!(
567 result.contains("m_tests["),
568 "connected module 'tests' should be in output"
569 );
570
571 assert!(
573 !result.contains("m_docs"),
574 "orphan 'docs' should not be in output"
575 );
576 assert!(
577 !result.contains("m_scripts"),
578 "orphan 'scripts' should not be in output"
579 );
580
581 assert!(
583 !result.contains("class m_docs hotspot"),
584 "orphan hotspot should not be styled"
585 );
586
587 assert!(
589 !result.contains("click m_docs"),
590 "orphan should not have click handler"
591 );
592 assert!(
593 !result.contains("click m_scripts"),
594 "orphan should not have click handler"
595 );
596 }
597
598 #[test]
599 fn test_mermaid_layered_proxy_nodes() {
600 let modules = vec![
601 ("src".to_string(), 80, 1u8),
602 ("src/parsers".to_string(), 15, 2u8),
603 ("tests".to_string(), 10, 1u8),
604 ];
605 let edges = vec![
606 ("src/parsers".to_string(), "src".to_string(), 16),
607 ("src".to_string(), "tests".to_string(), 3),
608 ];
609 let hotspots = HashSet::from(["src".to_string()]);
610
611 let result = render_mermaid_layered(&modules, &edges, &hotspots).unwrap();
612
613 assert!(
615 result.contains("subgraph m_src ["),
616 "Tier 1 with children should be a subgraph"
617 );
618
619 assert!(
621 result.contains("m_src_self["),
622 "subgraph should contain proxy node"
623 );
624
625 assert!(
627 result.contains("m_src_self"),
628 "edges should reference proxy node"
629 );
630 assert!(
631 !result.contains(" -->|16| m_src\n"),
632 "edges should NOT target bare subgraph ID"
633 );
634
635 assert!(
637 result.contains("class m_src_self hotspot"),
638 "hotspot class should target proxy node"
639 );
640
641 assert!(
643 result.contains("click m_src_self"),
644 "click handler should target proxy node"
645 );
646
647 assert!(
649 result.contains("m_tests["),
650 "standalone Tier 1 should be a regular node"
651 );
652 assert!(
653 !result.contains("subgraph m_tests"),
654 "standalone Tier 1 should not be a subgraph"
655 );
656 }
657
658 #[test]
659 fn test_find_owning_module() {
660 let modules = vec![
661 wiki::ModuleDefinition {
662 path: "src".to_string(),
663 tier: 1,
664 file_count: 80,
665 total_lines: 50000,
666 languages: vec!["Rust".to_string()],
667 },
668 wiki::ModuleDefinition {
669 path: "src/parsers".to_string(),
670 tier: 2,
671 file_count: 15,
672 total_lines: 8000,
673 languages: vec!["Rust".to_string()],
674 },
675 ];
676
677 assert_eq!(
678 find_owning_module("src/parsers/rust.rs", &modules),
679 "src/parsers"
680 );
681 assert_eq!(find_owning_module("src/main.rs", &modules), "src");
682 assert_eq!(
683 find_owning_module("tests/integration.rs", &modules),
684 "tests"
685 );
686 }
687}