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(
45 cache: &CacheManager,
46 zoom: &MapZoom,
47 format: MapFormat,
48) -> Result<String> {
49 match zoom {
50 MapZoom::Repo => generate_repo_map(cache, format),
51 MapZoom::Module(module) => generate_module_map(cache, module, format),
52 }
53}
54
55fn generate_repo_map(cache: &CacheManager, format: MapFormat) -> Result<String> {
56 let db_path = cache.path().join("meta.db");
57 let conn = Connection::open(&db_path)?;
58
59 let modules = wiki::detect_modules(cache, &wiki::ModuleDiscoveryConfig::default())?;
61
62 let module_info: Vec<(String, usize)> = modules.iter()
64 .map(|m| (m.path.clone(), m.file_count))
65 .collect();
66
67 let mut stmt = conn.prepare(
69 "SELECT f1.path, f2.path
70 FROM file_dependencies fd
71 JOIN files f1 ON fd.file_id = f1.id
72 JOIN files f2 ON fd.resolved_file_id = f2.id
73 WHERE fd.resolved_file_id IS NOT NULL"
74 )?;
75
76 let file_edges: Vec<(String, String)> = stmt.query_map([], |row| {
77 Ok((row.get(0)?, row.get(1)?))
78 })?.collect::<Result<Vec<_>, _>>()?;
79
80 let mut module_edges: HashMap<(String, String), usize> = HashMap::new();
82 for (src_file, tgt_file) in &file_edges {
83 let src_module = find_owning_module(src_file, &modules);
84 let tgt_module = find_owning_module(tgt_file, &modules);
85
86 if src_module != tgt_module {
87 *module_edges.entry((src_module, tgt_module)).or_insert(0) += 1;
88 }
89 }
90
91 let mut edges: Vec<(String, String, usize)> = module_edges.into_iter()
92 .map(|((s, t), c)| (s, t, c))
93 .collect();
94 edges.sort_by(|a, b| b.2.cmp(&a.2));
95
96 let deps_index = DependencyIndex::new(cache.clone());
98 let hotspots = deps_index.find_hotspots(Some(10), 5).unwrap_or_default();
99 let hotspot_modules: HashSet<String> = hotspots.iter()
100 .filter_map(|(id, _)| {
101 deps_index.get_file_paths(&[*id]).ok()
102 .and_then(|paths| paths.get(id).cloned())
103 .map(|p| find_owning_module(&p, &modules))
104 })
105 .collect();
106
107 match format {
108 MapFormat::Mermaid => render_mermaid_repo(&module_info, &edges, &hotspot_modules),
109 MapFormat::D2 => render_d2_repo(&module_info, &edges, &hotspot_modules),
110 }
111}
112
113fn find_owning_module(file_path: &str, modules: &[wiki::ModuleDefinition]) -> String {
115 let mut best_match = String::new();
116 let mut best_len = 0;
117
118 for module in modules {
119 let prefix = format!("{}/", module.path);
120 if file_path.starts_with(&prefix) && module.path.len() > best_len {
121 best_match = module.path.clone();
122 best_len = module.path.len();
123 }
124 }
125
126 if best_match.is_empty() {
127 file_path.split('/').next().unwrap_or("root").to_string()
128 } else {
129 best_match
130 }
131}
132
133fn generate_module_map(cache: &CacheManager, module_path: &str, format: MapFormat) -> Result<String> {
134 let db_path = cache.path().join("meta.db");
135 let conn = Connection::open(&db_path)?;
136 let pattern = format!("{}/%", module_path);
137
138 let mut stmt = conn.prepare(
140 "SELECT id, path FROM files WHERE path LIKE ?1 ORDER BY path"
141 )?;
142 let files: Vec<(i64, String)> = stmt.query_map([&pattern], |row| {
143 Ok((row.get(0)?, row.get(1)?))
144 })?.collect::<Result<Vec<_>, _>>()?;
145
146 let mut stmt = conn.prepare(
148 "SELECT f1.path, f2.path
149 FROM file_dependencies fd
150 JOIN files f1 ON fd.file_id = f1.id
151 JOIN files f2 ON fd.resolved_file_id = f2.id
152 WHERE f1.path LIKE ?1 AND f2.path LIKE ?1
153 AND fd.resolved_file_id IS NOT NULL"
154 )?;
155 let edges: Vec<(String, String)> = stmt.query_map([&pattern], |row| {
156 Ok((row.get(0)?, row.get(1)?))
157 })?.collect::<Result<Vec<_>, _>>()?;
158
159 match format {
160 MapFormat::Mermaid => render_mermaid_module(module_path, &files, &edges),
161 MapFormat::D2 => render_d2_module(module_path, &files, &edges),
162 }
163}
164
165fn sanitize_id(s: &str) -> String {
168 format!("m_{}", s.replace(['/', '.', '-', ' '], "_"))
169}
170
171fn render_mermaid_repo(
172 modules: &[(String, usize)],
173 edges: &[(String, String, usize)],
174 hotspot_modules: &HashSet<String>,
175) -> Result<String> {
176 let mut out = String::from("graph LR\n");
177
178 let connected: HashSet<&str> = edges.iter()
180 .flat_map(|(s, t, _)| [s.as_str(), t.as_str()])
181 .collect();
182
183 for (module, count) in modules {
184 if !connected.contains(module.as_str()) {
185 continue;
186 }
187 let id = sanitize_id(module);
188 out.push_str(&format!(" {}[\"{}/ ({} files)\"]\n", id, module, count));
189 }
190
191 out.push('\n');
192
193 let mut thick_edge_indices: Vec<usize> = Vec::new();
195 for (i, (src, tgt, count)) in edges.iter().enumerate() {
196 let src_id = sanitize_id(src);
197 let tgt_id = sanitize_id(tgt);
198 out.push_str(&format!(" {} -->|{}| {}\n", src_id, count, tgt_id));
199 if *count > 5 {
200 thick_edge_indices.push(i);
201 }
202 }
203
204 for idx in &thick_edge_indices {
206 out.push_str(&format!(" linkStyle {} stroke-width:3px,stroke:#a78bfa\n", idx));
207 }
208
209 out.push_str("\n classDef default fill:#1a1a2e,stroke:#a78bfa,color:#e0e0e0\n");
211 out.push_str(" classDef hotspot fill:#2a1030,stroke:#f472b6,color:#f472b6\n");
212 if !hotspot_modules.is_empty() {
213 for module in hotspot_modules {
214 if !connected.contains(module.as_str()) {
215 continue;
216 }
217 let id = sanitize_id(module);
218 out.push_str(&format!(" class {} hotspot\n", id));
219 }
220 }
221
222 for (module, _) in modules {
224 if !connected.contains(module.as_str()) {
225 continue;
226 }
227 let id = sanitize_id(module);
228 let slug = module.replace('/', "-");
229 out.push_str(&format!(" click {} \"/wiki/{}/\"\n", id, slug));
230 }
231
232 Ok(out)
233}
234
235pub fn generate_layered_map(
237 cache: &CacheManager,
238 format: MapFormat,
239) -> Result<String> {
240 let db_path = cache.path().join("meta.db");
241 let conn = Connection::open(&db_path)?;
242 let modules = wiki::detect_modules(cache, &wiki::ModuleDiscoveryConfig::default())?;
243
244 let module_info: Vec<(String, usize, u8)> = modules.iter()
245 .map(|m| (m.path.clone(), m.file_count, m.tier))
246 .collect();
247
248 let mut stmt = conn.prepare(
250 "SELECT f1.path, f2.path
251 FROM file_dependencies fd
252 JOIN files f1 ON fd.file_id = f1.id
253 JOIN files f2 ON fd.resolved_file_id = f2.id
254 WHERE fd.resolved_file_id IS NOT NULL"
255 )?;
256 let file_edges: Vec<(String, String)> = stmt.query_map([], |row| {
257 Ok((row.get(0)?, row.get(1)?))
258 })?.collect::<Result<Vec<_>, _>>()?;
259
260 let mut module_edges: HashMap<(String, String), usize> = HashMap::new();
261 for (src_file, tgt_file) in &file_edges {
262 let src_module = find_owning_module(src_file, &modules);
263 let tgt_module = find_owning_module(tgt_file, &modules);
264 if src_module != tgt_module {
265 *module_edges.entry((src_module, tgt_module)).or_insert(0) += 1;
266 }
267 }
268
269 let mut edges: Vec<(String, String, usize)> = module_edges.into_iter()
270 .map(|((s, t), c)| (s, t, c))
271 .collect();
272 edges.sort_by(|a, b| b.2.cmp(&a.2));
273
274 let deps_index = DependencyIndex::new(cache.clone());
275 let hotspots = deps_index.find_hotspots(Some(10), 5).unwrap_or_default();
276 let hotspot_modules: HashSet<String> = hotspots.iter()
277 .filter_map(|(id, _)| {
278 deps_index.get_file_paths(&[*id]).ok()
279 .and_then(|paths| paths.get(id).cloned())
280 .map(|p| find_owning_module(&p, &modules))
281 })
282 .collect();
283
284 match format {
285 MapFormat::Mermaid => render_mermaid_layered(&module_info, &edges, &hotspot_modules),
286 MapFormat::D2 => render_d2_repo(
287 &module_info.iter().map(|(p, c, _)| (p.clone(), *c)).collect::<Vec<_>>(),
288 &edges,
289 &hotspot_modules,
290 ),
291 }
292}
293
294fn render_mermaid_layered(
295 modules: &[(String, usize, u8)],
296 edges: &[(String, String, usize)],
297 hotspot_modules: &HashSet<String>,
298) -> Result<String> {
299 let mut out = String::from("flowchart TB\n");
300
301 let connected: HashSet<&str> = edges.iter()
303 .flat_map(|(s, t, _)| [s.as_str(), t.as_str()])
304 .collect();
305
306 let tier1: Vec<&(String, usize, u8)> = modules.iter().filter(|m| m.2 == 1).collect();
308 let tier2: Vec<&(String, usize, u8)> = modules.iter().filter(|m| m.2 == 2).collect();
309
310 let mut proxy_map: HashMap<String, String> = HashMap::new();
314
315 for t1 in &tier1 {
316 if !connected.contains(t1.0.as_str()) {
317 continue;
318 }
319 let t1_id = sanitize_id(&t1.0);
320 let children: Vec<&&(String, usize, u8)> = tier2.iter()
321 .filter(|t2| t2.0.starts_with(&format!("{}/", t1.0)) && connected.contains(t2.0.as_str()))
322 .collect();
323
324 if children.is_empty() {
325 out.push_str(&format!(" {}[\"{}/ ({} files)\"]\n", t1_id, t1.0, t1.1));
327 } else {
328 let proxy_id = format!("{}_self", t1_id);
330 proxy_map.insert(t1.0.clone(), proxy_id.clone());
331
332 out.push_str(&format!(" subgraph {} [\"{}/ \"]\n", t1_id, t1.0));
333 out.push_str(&format!(" {}[\"{}/ ({} files)\"]\n", proxy_id, t1.0, t1.1));
334 for child in &children {
335 let child_id = sanitize_id(&child.0);
336 let short = child.0.strip_prefix(&format!("{}/", t1.0)).unwrap_or(&child.0);
337 out.push_str(&format!(" {}[\"{}/ ({} files)\"]\n", child_id, short, child.1));
338 }
339 out.push_str(" end\n");
340 }
341 }
342
343 for t2 in &tier2 {
345 if !connected.contains(t2.0.as_str()) {
346 continue;
347 }
348 let has_parent = tier1.iter().any(|t1| t2.0.starts_with(&format!("{}/", t1.0)));
349 if !has_parent {
350 let id = sanitize_id(&t2.0);
351 out.push_str(&format!(" {}[\"{}/ ({} files)\"]\n", id, t2.0, t2.1));
352 }
353 }
354
355 out.push('\n');
356
357 let mut thick_edge_indices: Vec<usize> = Vec::new();
360 for (i, (src, tgt, count)) in edges.iter().enumerate() {
361 let src_id = proxy_map.get(src)
362 .cloned()
363 .unwrap_or_else(|| sanitize_id(src));
364 let tgt_id = proxy_map.get(tgt)
365 .cloned()
366 .unwrap_or_else(|| sanitize_id(tgt));
367 out.push_str(&format!(" {} -->|{}| {}\n", src_id, count, tgt_id));
368 if *count > 5 {
369 thick_edge_indices.push(i);
370 }
371 }
372
373 for idx in &thick_edge_indices {
375 out.push_str(&format!(" linkStyle {} stroke-width:3px,stroke:#a78bfa\n", idx));
376 }
377
378 out.push_str("\n classDef default fill:#1a1a2e,stroke:#a78bfa,color:#e0e0e0\n");
380 out.push_str(" classDef hotspot fill:#2a1030,stroke:#f472b6,color:#f472b6\n");
381 for module in hotspot_modules {
382 if !connected.contains(module.as_str()) {
383 continue;
384 }
385 let id = proxy_map.get(module)
386 .cloned()
387 .unwrap_or_else(|| sanitize_id(module));
388 out.push_str(&format!(" class {} hotspot\n", id));
389 }
390
391 for (module, _, _) in modules {
393 if !connected.contains(module.as_str()) {
394 continue;
395 }
396 let id = proxy_map.get(module)
397 .cloned()
398 .unwrap_or_else(|| sanitize_id(module));
399 let slug = module.replace('/', "-");
400 out.push_str(&format!(" click {} \"/wiki/{}/\"\n", id, slug));
401 }
402
403 Ok(out)
404}
405
406fn render_d2_repo(
407 modules: &[(String, usize)],
408 edges: &[(String, String, usize)],
409 hotspot_modules: &HashSet<String>,
410) -> Result<String> {
411 let mut out = String::new();
412
413 for (module, count) in modules {
414 let id = sanitize_id(module);
415 out.push_str(&format!("{}: \"{}/ ({} files)\"\n", id, module, count));
416 if hotspot_modules.contains(module) {
417 out.push_str(&format!("{}.style.fill: \"#ff6b6b\"\n", id));
418 }
419 }
420
421 out.push('\n');
422
423 for (src, tgt, count) in edges {
424 let src_id = sanitize_id(src);
425 let tgt_id = sanitize_id(tgt);
426 out.push_str(&format!("{} -> {}: {}\n", src_id, tgt_id, count));
427 }
428
429 Ok(out)
430}
431
432fn render_mermaid_module(
433 module_path: &str,
434 files: &[(i64, String)],
435 edges: &[(String, String)],
436) -> Result<String> {
437 let mut out = format!("graph LR\n subgraph {}\n", module_path);
438
439 for (_, path) in files {
440 let id = sanitize_id(path);
441 let short_name = path.rsplit('/').next().unwrap_or(path);
442 out.push_str(&format!(" {}[\"{}\"]\n", id, short_name));
443 }
444
445 for (src, tgt) in edges {
446 let src_id = sanitize_id(src);
447 let tgt_id = sanitize_id(tgt);
448 out.push_str(&format!(" {} --> {}\n", src_id, tgt_id));
449 }
450
451 out.push_str(" end\n");
452
453 Ok(out)
454}
455
456fn render_d2_module(
457 module_path: &str,
458 files: &[(i64, String)],
459 edges: &[(String, String)],
460) -> Result<String> {
461 let mut out = format!("{}: {{\n", sanitize_id(module_path));
462
463 for (_, path) in files {
464 let id = sanitize_id(path);
465 let short_name = path.rsplit('/').next().unwrap_or(path);
466 out.push_str(&format!(" {}: \"{}\"\n", id, short_name));
467 }
468
469 for (src, tgt) in edges {
470 let src_id = sanitize_id(src);
471 let tgt_id = sanitize_id(tgt);
472 out.push_str(&format!(" {} -> {}\n", src_id, tgt_id));
473 }
474
475 out.push_str("}\n");
476
477 Ok(out)
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 #[test]
485 fn test_sanitize_id() {
486 assert_eq!(sanitize_id("src/parsers"), "m_src_parsers");
487 assert_eq!(sanitize_id("my-module.rs"), "m_my_module_rs");
488 }
489
490 #[test]
491 fn test_mermaid_repo_output() {
492 let modules = vec![("src".to_string(), 50), ("tests".to_string(), 10)];
493 let edges = vec![("src".to_string(), "tests".to_string(), 3)];
494 let hotspots = HashSet::new();
495
496 let result = render_mermaid_repo(&modules, &edges, &hotspots).unwrap();
497 assert!(result.contains("graph LR"));
498 assert!(result.contains("src"));
499 assert!(result.contains("tests"));
500 assert!(result.contains("-->"));
501 }
502
503 #[test]
504 fn test_d2_repo_output() {
505 let modules = vec![("src".to_string(), 50)];
506 let edges = vec![];
507 let hotspots = HashSet::from(["src".to_string()]);
508
509 let result = render_d2_repo(&modules, &edges, &hotspots).unwrap();
510 assert!(result.contains("src:"));
511 assert!(result.contains("#ff6b6b"));
512 }
513
514 #[test]
515 fn test_mermaid_repo_filters_orphans() {
516 let modules = vec![
517 ("src".to_string(), 50),
518 ("tests".to_string(), 10),
519 ("docs".to_string(), 5), ("scripts".to_string(), 2), ];
522 let edges = vec![("src".to_string(), "tests".to_string(), 3)];
523 let hotspots = HashSet::from(["docs".to_string()]);
524
525 let result = render_mermaid_repo(&modules, &edges, &hotspots).unwrap();
526
527 assert!(result.contains("m_src["), "connected module 'src' should be in output");
529 assert!(result.contains("m_tests["), "connected module 'tests' should be in output");
530
531 assert!(!result.contains("m_docs"), "orphan 'docs' should not be in output");
533 assert!(!result.contains("m_scripts"), "orphan 'scripts' should not be in output");
534
535 assert!(!result.contains("class m_docs hotspot"), "orphan hotspot should not be styled");
537
538 assert!(!result.contains("click m_docs"), "orphan should not have click handler");
540 assert!(!result.contains("click m_scripts"), "orphan should not have click handler");
541 }
542
543 #[test]
544 fn test_mermaid_layered_proxy_nodes() {
545 let modules = vec![
546 ("src".to_string(), 80, 1u8),
547 ("src/parsers".to_string(), 15, 2u8),
548 ("tests".to_string(), 10, 1u8),
549 ];
550 let edges = vec![
551 ("src/parsers".to_string(), "src".to_string(), 16),
552 ("src".to_string(), "tests".to_string(), 3),
553 ];
554 let hotspots = HashSet::from(["src".to_string()]);
555
556 let result = render_mermaid_layered(&modules, &edges, &hotspots).unwrap();
557
558 assert!(result.contains("subgraph m_src ["), "Tier 1 with children should be a subgraph");
560
561 assert!(result.contains("m_src_self["), "subgraph should contain proxy node");
563
564 assert!(result.contains("m_src_self"), "edges should reference proxy node");
566 assert!(!result.contains(" -->|16| m_src\n"), "edges should NOT target bare subgraph ID");
567
568 assert!(result.contains("class m_src_self hotspot"), "hotspot class should target proxy node");
570
571 assert!(result.contains("click m_src_self"), "click handler should target proxy node");
573
574 assert!(result.contains("m_tests["), "standalone Tier 1 should be a regular node");
576 assert!(!result.contains("subgraph m_tests"), "standalone Tier 1 should not be a subgraph");
577 }
578
579 #[test]
580 fn test_find_owning_module() {
581 let modules = vec![
582 wiki::ModuleDefinition {
583 path: "src".to_string(),
584 tier: 1,
585 file_count: 80,
586 total_lines: 50000,
587 languages: vec!["Rust".to_string()],
588 },
589 wiki::ModuleDefinition {
590 path: "src/parsers".to_string(),
591 tier: 2,
592 file_count: 15,
593 total_lines: 8000,
594 languages: vec!["Rust".to_string()],
595 },
596 ];
597
598 assert_eq!(find_owning_module("src/parsers/rust.rs", &modules), "src/parsers");
599 assert_eq!(find_owning_module("src/main.rs", &modules), "src");
600 assert_eq!(find_owning_module("tests/integration.rs", &modules), "tests");
601 }
602}