Skip to main content

infigraph_core/
sequence.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2
3use anyhow::Result;
4
5use crate::graph::GraphQuery;
6
7/// Generate a Mermaid sequenceDiagram from the call graph starting at `entry_symbol_id`.
8///
9/// Participants = unique files. Messages = CALLS edges. BFS bounded by `depth`.
10/// Skips self-calls (same file calling same file) unless they cross functions.
11pub fn generate_sequence_mermaid(
12    gq: &GraphQuery,
13    entry_symbol_id: &str,
14    depth: u32,
15) -> Result<String> {
16    // BFS over outgoing CALLS edges only (directed: entry → callees)
17    let mut visited: HashSet<String> = HashSet::new();
18    let mut queue: VecDeque<(String, u32)> = VecDeque::new();
19    // edges in BFS order: (caller_id, callee_id)
20    let mut edges: Vec<(String, String)> = Vec::new();
21
22    queue.push_back((entry_symbol_id.to_string(), 0));
23    visited.insert(entry_symbol_id.to_string());
24
25    while let Some((id, hop)) = queue.pop_front() {
26        if hop >= depth {
27            continue;
28        }
29        let esc = id.replace('\'', "\\'");
30        let q = format!("MATCH (a:Symbol)-[:CALLS]->(b:Symbol) WHERE a.id = '{esc}' RETURN b.id");
31        if let Ok(rows) = gq.raw_query(&q) {
32            for row in &rows {
33                if let Some(callee_id) = row.first() {
34                    edges.push((id.clone(), callee_id.clone()));
35                    if visited.insert(callee_id.clone()) {
36                        queue.push_back((callee_id.clone(), hop + 1));
37                    }
38                }
39            }
40        }
41    }
42
43    if edges.is_empty() {
44        return Ok(format!(
45            "sequenceDiagram\n    note over {}: no outgoing calls found\n",
46            participant_name(entry_symbol_id)
47        ));
48    }
49
50    // Fetch name + file for all visited symbols
51    let mut sym_info: HashMap<String, (String, String)> = HashMap::new(); // id -> (name, file)
52    for id in &visited {
53        let esc = id.replace('\'', "\\'");
54        let q = format!("MATCH (s:Symbol) WHERE s.id = '{esc}' RETURN s.name, s.file");
55        if let Ok(rows) = gq.raw_query(&q) {
56            if let Some(row) = rows.first() {
57                if row.len() >= 2 {
58                    sym_info.insert(id.clone(), (row[0].clone(), row[1].clone()));
59                }
60            }
61        }
62    }
63
64    // Collect unique participants (files), preserving encounter order
65    let mut participants: Vec<String> = Vec::new();
66    let mut seen_parts: HashSet<String> = HashSet::new();
67
68    // Entry symbol's file first
69    if let Some((_, file)) = sym_info.get(entry_symbol_id) {
70        let p = file_to_participant(file);
71        if seen_parts.insert(p.clone()) {
72            participants.push(p);
73        }
74    }
75    for (caller, callee) in &edges {
76        for id in [caller, callee] {
77            if let Some((_, file)) = sym_info.get(id) {
78                let p = file_to_participant(file);
79                if seen_parts.insert(p.clone()) {
80                    participants.push(p);
81                }
82            }
83        }
84    }
85
86    let mut out = String::from("sequenceDiagram\n");
87    for p in &participants {
88        out.push_str(&format!("    participant {p}\n"));
89    }
90    out.push('\n');
91
92    for (caller_id, callee_id) in &edges {
93        let caller_file = sym_info
94            .get(caller_id)
95            .map(|(_, f)| f.as_str())
96            .unwrap_or(caller_id);
97        let callee_file = sym_info
98            .get(callee_id)
99            .map(|(_, f)| f.as_str())
100            .unwrap_or(callee_id);
101        let caller_part = file_to_participant(caller_file);
102        let callee_part = file_to_participant(callee_file);
103        let callee_name = sym_info
104            .get(callee_id)
105            .map(|(n, _)| n.as_str())
106            .unwrap_or(callee_id);
107        out.push_str(&format!(
108            "    {caller_part}->>{callee_part}: {callee_name}()\n"
109        ));
110    }
111
112    Ok(out)
113}
114
115/// Shorten a file path to a readable participant label.
116/// `crates/infigraph-core/src/graph/store.rs` → `store`
117fn file_to_participant(file: &str) -> String {
118    let stem = std::path::Path::new(file)
119        .file_stem()
120        .and_then(|s| s.to_str())
121        .unwrap_or(file);
122    // Mermaid participant names can't have spaces or dots
123    stem.replace([' ', '.', '-'], "_")
124}
125
126fn participant_name(symbol_id: &str) -> String {
127    file_to_participant(symbol_id.split("::").next().unwrap_or(symbol_id))
128}