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(
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    // Use detect_modules() for consistent sub-module resolution
60    let modules = wiki::detect_modules(cache, &wiki::ModuleDiscoveryConfig::default())?;
61
62    // Build module info for node labels
63    let module_info: Vec<(String, usize)> = modules.iter()
64        .map(|m| (m.path.clone(), m.file_count))
65        .collect();
66
67    // Get all file-level dependency edges
68    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    // Aggregate file-level edges to module-level edges
81    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    // Get hotspots for highlighting
97    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
113/// Find the most-specific module that owns a given file path
114fn 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    // Get files in this module
139    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    // Get intra-module edges
147    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
165/// Create a Mermaid-safe node ID with a prefix to avoid reserved word collisions.
166/// Mermaid v11 can choke on IDs that match internal keywords or contain certain patterns.
167fn 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    // Only emit modules that participate in at least one edge
179    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    // Track thick edges for linkStyle directives
194    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    // Apply thick stroke to high-count edges via linkStyle
205    for idx in &thick_edge_indices {
206        out.push_str(&format!("  linkStyle {} stroke-width:3px,stroke:#a78bfa\n", idx));
207    }
208
209    // High-contrast styling for dark theme
210    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    // Clickable nodes → wiki pages (only connected modules)
223    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
235/// Generate a layered (top-to-bottom) architecture diagram with Tier 1 subgraphs containing Tier 2 children
236pub 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    // Get module-level edges
249    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    // Only emit modules that participate in at least one edge
302    let connected: HashSet<&str> = edges.iter()
303        .flat_map(|(s, t, _)| [s.as_str(), t.as_str()])
304        .collect();
305
306    // Group Tier 2 modules under their Tier 1 parent
307    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    // Build proxy map: Tier 1 modules that become subgraphs get an inner proxy node.
311    // Mermaid v11 cannot target subgraph IDs with edges, classDef, or click handlers,
312    // so we create a real node inside the subgraph to receive those interactions.
313    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            // Standalone Tier 1 node (no subgraph needed)
326            out.push_str(&format!("  {}[\"{}/ ({} files)\"]\n", t1_id, t1.0, t1.1));
327        } else {
328            // Subgraph with proxy node for edges/styling/clicks
329            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    // Orphan Tier 2 modules (no matching Tier 1 parent)
344    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    // Track thick edges for linkStyle directives
358    // Resolve edge endpoints through proxy_map so edges target proxy nodes, not subgraphs
359    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    // Apply thick stroke to high-count edges via linkStyle
374    for idx in &thick_edge_indices {
375        out.push_str(&format!("  linkStyle {} stroke-width:3px,stroke:#a78bfa\n", idx));
376    }
377
378    // Styling — apply classDef to proxy nodes, not subgraph containers
379    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    // Clickable nodes — apply click to proxy nodes, not subgraph containers
392    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),       // orphan — no edges
520            ("scripts".to_string(), 2),    // orphan — no edges
521        ];
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        // Connected modules are present
528        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        // Orphan modules are excluded
532        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        // Hotspot styling for orphan should not appear
536        assert!(!result.contains("class m_docs hotspot"), "orphan hotspot should not be styled");
537
538        // Click handlers for orphans should not appear
539        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        // Subgraph for src should exist (it has children)
559        assert!(result.contains("subgraph m_src ["), "Tier 1 with children should be a subgraph");
560
561        // Proxy node inside the subgraph
562        assert!(result.contains("m_src_self["), "subgraph should contain proxy node");
563
564        // Edges should target proxy node, not subgraph ID
565        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        // classDef should target proxy node
569        assert!(result.contains("class m_src_self hotspot"), "hotspot class should target proxy node");
570
571        // click should target proxy node
572        assert!(result.contains("click m_src_self"), "click handler should target proxy node");
573
574        // tests is standalone Tier 1 (no children), should be a regular node
575        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}