Skip to main content

graphyn_mcp/
context_builder.rs

1//! Formats graph query results into agent-readable text context.
2//!
3//! The output format is optimized for LLM consumption — structured, concise,
4//! and highlighting aliased imports as high-risk items.
5
6use graphyn_core::graph::GraphynGraph;
7use graphyn_core::ir::SymbolKind;
8use graphyn_core::query::QueryEdge;
9
10use std::collections::BTreeMap;
11
12// ── blast radius ─────────────────────────────────────────────
13
14pub fn format_blast_radius(
15    graph: &GraphynGraph,
16    symbol: &str,
17    file: Option<&str>,
18    depth: usize,
19    edges: &[QueryEdge],
20) -> String {
21    let mut out = String::new();
22
23    // header
24    let header = symbol_header(graph, symbol, file);
25    out.push_str(&header);
26    out.push_str(&format!(
27        "\nBlast radius ({} dependent(s), depth={}):\n",
28        edges.len(),
29        depth
30    ));
31
32    if edges.is_empty() {
33        out.push_str("\nNo dependents found — safe to modify.\n");
34        return out;
35    }
36
37    let (direct, aliased) = partition_by_alias(edges);
38
39    // direct dependents
40    if !direct.is_empty() {
41        out.push_str(&format!("\nDIRECT (imports/uses {} directly):\n", symbol));
42        for edge in &direct {
43            out.push_str(&format_blast_edge(graph, edge));
44        }
45    }
46
47    // aliased dependents — HIGH RISK
48    if !aliased.is_empty() {
49        out.push_str("\nALIASED (imports under different name — HIGH RISK):\n");
50        for edge in &aliased {
51            out.push_str(&format_blast_edge(graph, edge));
52        }
53    }
54
55    // property summary
56    let props = collect_property_summary(edges);
57    if !props.is_empty() {
58        out.push_str("\nProperties at risk if changed:\n");
59        for (prop, count) in &props {
60            let aliased_note = if is_aliased_only_property(edges, prop) {
61                " (aliased import only)"
62            } else {
63                ""
64            };
65            out.push_str(&format!(
66                "  .{:<16} → referenced in {} file(s){}\n",
67                prop, count, aliased_note
68            ));
69        }
70    }
71
72    out
73}
74
75fn format_blast_edge(graph: &GraphynGraph, edge: &QueryEdge) -> String {
76    let mut out = String::new();
77    out.push_str(&format!("  • {}:{}\n", edge.file, edge.line));
78
79    if let Some(alias) = &edge.alias {
80        out.push_str(&format!("    → imports as {} ← ALIAS\n", alias));
81    } else if let Some(sym) = graph.symbols.get(&edge.from) {
82        out.push_str(&format!("    → imports as {}\n", sym.name));
83    }
84
85    if !edge.properties_accessed.is_empty() {
86        let props: Vec<String> = edge
87            .properties_accessed
88            .iter()
89            .map(|p| format!(".{p}"))
90            .collect();
91        out.push_str(&format!("    → accesses: {}\n", props.join(", ")));
92    }
93
94    if !edge.context.is_empty() && edge.context != "import" && edge.context != "property access" {
95        let ctx = if edge.context.len() > 80 {
96            format!("{}…", &edge.context[..80])
97        } else {
98            edge.context.clone()
99        };
100        out.push_str(&format!("    → context: \"{}\"\n", ctx));
101    }
102
103    out
104}
105
106// ── dependencies ─────────────────────────────────────────────
107
108pub fn format_dependencies(
109    graph: &GraphynGraph,
110    symbol: &str,
111    file: Option<&str>,
112    depth: usize,
113    edges: &[QueryEdge],
114) -> String {
115    let mut out = String::new();
116
117    let header = symbol_header(graph, symbol, file);
118    out.push_str(&header);
119    out.push_str(&format!(
120        "\nDependencies ({} found, depth={}):\n",
121        edges.len(),
122        depth
123    ));
124
125    if edges.is_empty() {
126        out.push_str("\nNo dependencies found — this symbol is self-contained.\n");
127        return out;
128    }
129
130    for edge in edges {
131        let dep_name = graph
132            .symbols
133            .get(&edge.to)
134            .map(|s| s.name.clone())
135            .unwrap_or_else(|| edge.to.clone());
136        let dep_kind = graph
137            .symbols
138            .get(&edge.to)
139            .map(|s| format_kind(&s.kind).to_string())
140            .unwrap_or_default();
141
142        out.push_str(&format!(
143            "  • {} [{}] — {}:{}\n",
144            dep_name, dep_kind, edge.file, edge.line
145        ));
146        if let Some(alias) = &edge.alias {
147            out.push_str(&format!("    → via alias {}\n", alias));
148        }
149        if edge.hop > 1 {
150            out.push_str(&format!("    → (hop {})\n", edge.hop));
151        }
152    }
153
154    out
155}
156
157// ── symbol usages ────────────────────────────────────────────
158
159pub fn format_symbol_usages(
160    graph: &GraphynGraph,
161    symbol: &str,
162    file: Option<&str>,
163    edges: &[QueryEdge],
164) -> String {
165    let mut out = String::new();
166
167    let header = symbol_header(graph, symbol, file);
168    out.push_str(&header);
169    out.push_str(&format!(
170        "\nUsages ({} found, including aliases):\n",
171        edges.len()
172    ));
173
174    if edges.is_empty() {
175        out.push_str("\nNo usages found.\n");
176        return out;
177    }
178
179    for edge in edges {
180        out.push_str(&format!("  • {}:{}\n", edge.file, edge.line));
181        if let Some(alias) = &edge.alias {
182            out.push_str(&format!("    → imports as {} ← ALIAS\n", alias));
183        }
184        if !edge.properties_accessed.is_empty() {
185            let props: Vec<String> = edge
186                .properties_accessed
187                .iter()
188                .map(|p| format!(".{p}"))
189                .collect();
190            out.push_str(&format!("    → accesses: {}\n", props.join(", ")));
191        }
192        if !edge.context.is_empty() && edge.context != "import" && edge.context != "property access"
193        {
194            let ctx = if edge.context.len() > 80 {
195                format!("{}…", &edge.context[..80])
196            } else {
197                edge.context.clone()
198            };
199            out.push_str(&format!("    → context: \"{}\"\n", ctx));
200        }
201    }
202
203    out
204}
205
206// ── helpers ──────────────────────────────────────────────────
207
208fn symbol_header(graph: &GraphynGraph, symbol: &str, file: Option<&str>) -> String {
209    if let Some(ids) = graph.name_index.get(symbol) {
210        let target_id = if let Some(file) = file {
211            ids.iter().find(|id| {
212                graph
213                    .symbols
214                    .get(*id)
215                    .map(|s| s.file == file)
216                    .unwrap_or(false)
217            })
218        } else {
219            ids.first()
220        };
221
222        if let Some(id) = target_id {
223            if let Some(sym) = graph.symbols.get(id) {
224                return format!(
225                    "Symbol: {} [{}] — {}:{}",
226                    sym.name,
227                    format_kind(&sym.kind),
228                    sym.file,
229                    sym.line_start,
230                );
231            }
232        }
233    }
234    format!("Symbol: {}", symbol)
235}
236
237fn partition_by_alias(edges: &[QueryEdge]) -> (Vec<&QueryEdge>, Vec<&QueryEdge>) {
238    let mut direct = Vec::new();
239    let mut aliased = Vec::new();
240    for edge in edges {
241        if edge.alias.is_some() {
242            aliased.push(edge);
243        } else {
244            direct.push(edge);
245        }
246    }
247    (direct, aliased)
248}
249
250fn collect_property_summary(edges: &[QueryEdge]) -> Vec<(String, usize)> {
251    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
252    for edge in edges {
253        for prop in &edge.properties_accessed {
254            *counts.entry(prop.clone()).or_insert(0) += 1;
255        }
256    }
257    let mut sorted: Vec<_> = counts.into_iter().collect();
258    sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
259    sorted
260}
261
262fn is_aliased_only_property(edges: &[QueryEdge], property: &str) -> bool {
263    edges
264        .iter()
265        .filter(|e| e.properties_accessed.contains(&property.to_string()))
266        .all(|e| e.alias.is_some())
267}
268
269fn format_kind(kind: &SymbolKind) -> &'static str {
270    match kind {
271        SymbolKind::Class => "class",
272        SymbolKind::Interface => "interface",
273        SymbolKind::TypeAlias => "type",
274        SymbolKind::Function => "function",
275        SymbolKind::Method => "method",
276        SymbolKind::Property => "property",
277        SymbolKind::Variable => "variable",
278        SymbolKind::Module => "module",
279        SymbolKind::Enum => "enum",
280        SymbolKind::EnumVariant => "variant",
281    }
282}