Skip to main content

reflex/pulse/
map.rs

1//! Architecture map generation
2//!
3//! Produces dependency diagrams in mermaid or d2 format.
4//! Uses detect_modules() for consistent sub-module resolution across all Pulse surfaces.
5
6use 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/// Zoom level for the architecture map
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub enum MapZoom {
19    /// Whole-repo view: modules as nodes
20    Repo,
21    /// Single module view: files within module as nodes
22    Module(String),
23}
24
25/// Output format for the map
26#[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
43/// Generate an architecture map
44pub 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    // Use detect_modules() for consistent sub-module resolution
56    let modules = wiki::detect_modules(cache, &wiki::ModuleDiscoveryConfig::default())?;
57
58    // Build module info for node labels
59    let module_info: Vec<(String, usize)> = modules
60        .iter()
61        .map(|m| (m.path.clone(), m.file_count))
62        .collect();
63
64    // Get all file-level dependency edges
65    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    // Aggregate file-level edges to module-level edges
78    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    // Get hotspots for highlighting
95    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
114/// Find the most-specific module that owns a given file path
115fn 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    // Get files in this module
144    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    // Get intra-module edges
150    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
168/// Create a Mermaid-safe node ID with a prefix to avoid reserved word collisions.
169/// Mermaid v11 can choke on IDs that match internal keywords or contain certain patterns.
170fn 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    // Only emit modules that participate in at least one edge
182    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    // Track thick edges for linkStyle directives
198    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    // Apply thick stroke to high-count edges via linkStyle
209    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    // High-contrast styling for dark theme
217    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    // Clickable nodes → wiki pages (only connected modules)
230    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
242/// Generate a layered (top-to-bottom) architecture diagram with Tier 1 subgraphs containing Tier 2 children
243pub 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    // Get module-level edges
254    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    // Only emit modules that participate in at least one edge
314    let connected: HashSet<&str> = edges
315        .iter()
316        .flat_map(|(s, t, _)| [s.as_str(), t.as_str()])
317        .collect();
318
319    // Group Tier 2 modules under their Tier 1 parent
320    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    // Build proxy map: Tier 1 modules that become subgraphs get an inner proxy node.
324    // Mermaid v11 cannot target subgraph IDs with edges, classDef, or click handlers,
325    // so we create a real node inside the subgraph to receive those interactions.
326    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            // Standalone Tier 1 node (no subgraph needed)
342            out.push_str(&format!("  {}[\"{}/ ({} files)\"]\n", t1_id, t1.0, t1.1));
343        } else {
344            // Subgraph with proxy node for edges/styling/clicks
345            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    // Orphan Tier 2 modules (no matching Tier 1 parent)
369    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    // Track thick edges for linkStyle directives
385    // Resolve edge endpoints through proxy_map so edges target proxy nodes, not subgraphs
386    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    // Apply thick stroke to high-count edges via linkStyle
403    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    // Styling — apply classDef to proxy nodes, not subgraph containers
411    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    // Clickable nodes — apply click to proxy nodes, not subgraph containers
425    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),    // orphan — no edges
554            ("scripts".to_string(), 2), // orphan — no edges
555        ];
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        // Connected modules are present
562        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        // Orphan modules are excluded
572        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        // Hotspot styling for orphan should not appear
582        assert!(
583            !result.contains("class m_docs hotspot"),
584            "orphan hotspot should not be styled"
585        );
586
587        // Click handlers for orphans should not appear
588        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        // Subgraph for src should exist (it has children)
614        assert!(
615            result.contains("subgraph m_src ["),
616            "Tier 1 with children should be a subgraph"
617        );
618
619        // Proxy node inside the subgraph
620        assert!(
621            result.contains("m_src_self["),
622            "subgraph should contain proxy node"
623        );
624
625        // Edges should target proxy node, not subgraph ID
626        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        // classDef should target proxy node
636        assert!(
637            result.contains("class m_src_self hotspot"),
638            "hotspot class should target proxy node"
639        );
640
641        // click should target proxy node
642        assert!(
643            result.contains("click m_src_self"),
644            "click handler should target proxy node"
645        );
646
647        // tests is standalone Tier 1 (no children), should be a regular node
648        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}