Skip to main content

sqry_cli/commands/
impact.rs

1//! Impact command implementation
2//!
3//! Provides CLI interface for analyzing what would break if a symbol changes.
4
5use crate::args::Cli;
6use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli};
7use crate::index_discovery::find_nearest_index;
8use crate::output::OutputStreams;
9use anyhow::{Context, Result};
10use serde::Serialize;
11use sqry_core::graph::unified::node::NodeId;
12use sqry_core::graph::unified::resolution::{AmbiguousSymbolError, SymbolResolveError};
13use sqry_core::graph::unified::traversal::EdgeClassification;
14use sqry_core::graph::unified::{
15    EdgeFilter, FileScope, TraversalConfig, TraversalDirection, TraversalLimits, traverse,
16};
17use std::collections::{HashMap, HashSet};
18
19/// Stable CLI exit code surfaced when a symbol resolution is ambiguous.
20///
21/// Distinct from `1` (general error) and `2` (not-found / validation) so
22/// scripts can branch on the ambiguity case without parsing stderr.
23pub const AMBIGUOUS_SYMBOL_EXIT_CODE: i32 = 4;
24
25/// Stable CLI exit code surfaced when a symbol cannot be located in the
26/// graph.
27pub const SYMBOL_NOT_FOUND_EXIT_CODE: i32 = 2;
28
29/// Stable error code for the `sqry::ambiguous_symbol` envelope.
30pub const AMBIGUOUS_SYMBOL_ERROR_CODE: &str = "sqry::ambiguous_symbol";
31
32/// Stable error code for the `sqry::symbol_not_found` envelope.
33pub const SYMBOL_NOT_FOUND_ERROR_CODE: &str = "sqry::symbol_not_found";
34
35/// JSON envelope serialized for the `sqry::ambiguous_symbol` error.
36///
37/// Mirrors the shape used by the MCP boundary so a single response shape
38/// flows through every wire format. Kept private because callers should
39/// route through [`emit_ambiguous_symbol_error`].
40#[derive(Debug, Serialize)]
41struct AmbiguousSymbolEnvelope<'a> {
42    code: &'static str,
43    message: String,
44    candidates: &'a [sqry_core::graph::unified::resolution::AmbiguousSymbolCandidate],
45    truncated: bool,
46}
47
48#[derive(Debug, Serialize)]
49struct AmbiguousSymbolWireWrapper<'a> {
50    error: AmbiguousSymbolEnvelope<'a>,
51}
52
53/// Emit the `sqry::ambiguous_symbol` error envelope on the active output
54/// streams and return the canonical CLI exit code.
55///
56/// JSON output is written to stdout (the same channel as the success
57/// payload) so `--json` consumers can pipe through `jq`. Human output is
58/// written to stderr and lists candidates one per line.
59pub(crate) fn emit_ambiguous_symbol_error(
60    streams: &mut OutputStreams,
61    err: &AmbiguousSymbolError,
62    json_output: bool,
63) -> i32 {
64    let message = format!(
65        "Symbol '{}' is ambiguous; specify the qualified name",
66        err.name
67    );
68    if json_output {
69        let envelope = AmbiguousSymbolWireWrapper {
70            error: AmbiguousSymbolEnvelope {
71                code: AMBIGUOUS_SYMBOL_ERROR_CODE,
72                message,
73                candidates: &err.candidates,
74                truncated: err.truncated,
75            },
76        };
77        let json = serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| {
78            format!(
79                "{{\"error\":{{\"code\":\"{AMBIGUOUS_SYMBOL_ERROR_CODE}\",\"message\":\"{}\"}}}}",
80                err.name
81            )
82        });
83        let _ = streams.write_result(&json);
84    } else {
85        let mut lines = vec![format!("Error: {message}.")];
86        if err.truncated {
87            lines.push(format!(
88                "Showing first {} candidates (more matched):",
89                err.candidates.len()
90            ));
91        } else {
92            lines.push("Candidates:".to_string());
93        }
94        for candidate in &err.candidates {
95            lines.push(format!(
96                "  - {} [{}] ({}:{}:{})",
97                candidate.qualified_name,
98                candidate.kind,
99                candidate.file_path,
100                candidate.start_line,
101                candidate.start_column
102            ));
103        }
104        let _ = streams.write_diagnostic(&lines.join("\n"));
105    }
106    AMBIGUOUS_SYMBOL_EXIT_CODE
107}
108
109/// Emit the `sqry::symbol_not_found` envelope on the active output streams
110/// and return the canonical CLI exit code.
111pub(crate) fn emit_symbol_not_found(
112    streams: &mut OutputStreams,
113    name: &str,
114    json_output: bool,
115) -> i32 {
116    let message = format!("Symbol '{name}' not found in graph");
117    if json_output {
118        let envelope = serde_json::json!({
119            "error": {
120                "code": SYMBOL_NOT_FOUND_ERROR_CODE,
121                "message": message,
122            }
123        });
124        let json = serde_json::to_string_pretty(&envelope)
125            .unwrap_or_else(|_| format!("{{\"error\":{{\"code\":\"{SYMBOL_NOT_FOUND_ERROR_CODE}\",\"message\":\"{name}\"}}}}"));
126        let _ = streams.write_result(&json);
127    } else {
128        let _ = streams.write_diagnostic(&format!("Error: {message}."));
129    }
130    SYMBOL_NOT_FOUND_EXIT_CODE
131}
132
133/// Impact analysis output
134#[derive(Debug, Serialize)]
135struct ImpactOutput {
136    /// Symbol being analyzed
137    symbol: String,
138    /// Direct dependents (depth 1)
139    direct: Vec<ImpactSymbol>,
140    /// Indirect dependents (depth > 1)
141    #[serde(skip_serializing_if = "Vec::is_empty")]
142    indirect: Vec<ImpactSymbol>,
143    /// Affected files
144    #[serde(skip_serializing_if = "Vec::is_empty")]
145    affected_files: Vec<String>,
146    /// Statistics
147    stats: ImpactStats,
148}
149
150#[derive(Debug, Serialize)]
151struct ImpactSymbol {
152    name: String,
153    qualified_name: String,
154    kind: String,
155    file: String,
156    line: u32,
157    /// How this symbol depends on the target
158    relation: String,
159    /// Depth from target symbol
160    depth: usize,
161}
162
163#[derive(Debug, Serialize)]
164struct ImpactStats {
165    direct_count: usize,
166    indirect_count: usize,
167    total_affected: usize,
168    affected_files_count: usize,
169    max_depth: usize,
170}
171
172/// Result of BFS traversal collecting dependents.
173struct BfsResult {
174    visited: HashSet<NodeId>,
175    node_depths: HashMap<NodeId, usize>,
176    node_relations: HashMap<NodeId, String>,
177    max_depth_reached: usize,
178}
179
180/// Perform BFS to collect all reverse dependents of a target node.
181///
182/// Uses the traversal kernel with incoming direction and dependency edges
183/// (calls, imports, references, inheritance). Converts the kernel's
184/// `TraversalResult` into the `BfsResult` expected by downstream code.
185///
186/// # Dispatch path (DB18)
187///
188/// `impact` is a **NodeId-anchored multi-hop BFS** under the Phase 3C
189/// dispatch taxonomy; it does not route through sqry-db's name-keyed
190/// queries. The target is resolved to a single `NodeId` in
191/// [`run_impact`] via substring / qualified-name matching *before* this
192/// traversal starts.
193///
194/// # Frontier invariant
195///
196/// Traversal broadens strictly through edges physically adjacent to
197/// already-visited `NodeId`s (kernel `traverse` with `edges_from` in
198/// the `Incoming` direction). It never re-resolves a name at depth ≥ 1,
199/// preserving the same-name frontier invariant: a user who seeds on
200/// `AlphaMarker::helper` cannot pull in unrelated `BetaMarker::helper`
201/// dependents. The single-seed `target_node_id` lookup in
202/// [`run_impact`] guarantees only one canonical anchor per invocation.
203fn collect_dependents_bfs(
204    graph: &sqry_core::graph::unified::concurrent::CodeGraph,
205    target_node_id: NodeId,
206    effective_max_depth: usize,
207) -> BfsResult {
208    let snapshot = graph.snapshot();
209
210    let config = TraversalConfig {
211        direction: TraversalDirection::Incoming,
212        edge_filter: EdgeFilter::dependency_edges(),
213        limits: TraversalLimits {
214            max_depth: u32::try_from(effective_max_depth).unwrap_or(u32::MAX),
215            max_nodes: None,
216            max_edges: None,
217            max_paths: None,
218        },
219    };
220
221    let result = traverse(&snapshot, &[target_node_id], &config, None);
222
223    let mut visited: HashSet<NodeId> = HashSet::new();
224    let mut node_depths: HashMap<NodeId, usize> = HashMap::new();
225    let mut node_relations: HashMap<NodeId, String> = HashMap::new();
226    let mut actual_max_depth: usize = 0;
227
228    for (idx, mat_node) in result.nodes.iter().enumerate() {
229        // Skip the target node itself — we only want dependents
230        if mat_node.node_id == target_node_id {
231            continue;
232        }
233
234        visited.insert(mat_node.node_id);
235
236        // Find the minimum depth edge leading to this node to determine its depth
237        let depth = result
238            .edges
239            .iter()
240            .filter(|e| e.source_idx == idx || e.target_idx == idx)
241            .map(|e| e.depth as usize)
242            .min()
243            .unwrap_or(1);
244
245        node_depths.insert(mat_node.node_id, depth);
246        actual_max_depth = actual_max_depth.max(depth);
247
248        // Determine relation type from the first edge classification reaching this node
249        let relation = result
250            .edges
251            .iter()
252            .find(|e| e.source_idx == idx || e.target_idx == idx)
253            .map(|e| classify_relation(&e.classification))
254            .unwrap_or_default();
255
256        node_relations.insert(mat_node.node_id, relation);
257    }
258
259    BfsResult {
260        visited,
261        node_depths,
262        node_relations,
263        max_depth_reached: actual_max_depth,
264    }
265}
266
267/// Map an `EdgeClassification` to a human-readable relation label.
268#[allow(clippy::trivially_copy_pass_by_ref)] // API consistency with other command handlers
269fn classify_relation(classification: &EdgeClassification) -> String {
270    match classification {
271        EdgeClassification::Call { .. } => "calls".to_string(),
272        EdgeClassification::Import { .. } => "imports".to_string(),
273        EdgeClassification::Reference => "references".to_string(),
274        EdgeClassification::Inherits => "inherits".to_string(),
275        EdgeClassification::Implements => "implements".to_string(),
276        EdgeClassification::Export { .. } => "exports".to_string(),
277        EdgeClassification::Contains => "contains".to_string(),
278        EdgeClassification::Defines => "defines".to_string(),
279        EdgeClassification::TypeOf => "type_of".to_string(),
280        EdgeClassification::DatabaseAccess => "database_access".to_string(),
281        EdgeClassification::ServiceInteraction => "service_interaction".to_string(),
282    }
283}
284
285/// Categorized impact symbols after BFS traversal.
286struct CategorizedImpact {
287    direct: Vec<ImpactSymbol>,
288    indirect: Vec<ImpactSymbol>,
289    affected_files: HashSet<String>,
290}
291
292/// Build categorized impact symbols from BFS results.
293fn build_impact_symbols(
294    graph: &sqry_core::graph::unified::concurrent::CodeGraph,
295    bfs: &BfsResult,
296    include_indirect: bool,
297    include_files: bool,
298) -> CategorizedImpact {
299    let strings = graph.strings();
300    let files = graph.files();
301    let mut direct: Vec<ImpactSymbol> = Vec::new();
302    let mut indirect: Vec<ImpactSymbol> = Vec::new();
303    let mut affected_files: HashSet<String> = HashSet::new();
304
305    for &node_id in &bfs.visited {
306        if let Some(entry) = graph.nodes().get(node_id) {
307            let depth = *bfs.node_depths.get(&node_id).unwrap_or(&0);
308            let relation = bfs
309                .node_relations
310                .get(&node_id)
311                .cloned()
312                .unwrap_or_default();
313
314            let name = strings
315                .resolve(entry.name)
316                .map(|s| s.to_string())
317                .unwrap_or_default();
318            let qualified_name = entry
319                .qualified_name
320                .and_then(|id| strings.resolve(id))
321                .map_or_else(|| name.clone(), |s| s.to_string());
322
323            let file_path = files
324                .resolve(entry.file)
325                .map(|p| p.display().to_string())
326                .unwrap_or_default();
327
328            let impact_sym = ImpactSymbol {
329                name,
330                qualified_name,
331                kind: format!("{:?}", entry.kind),
332                file: file_path.clone(),
333                line: entry.start_line,
334                relation,
335                depth,
336            };
337
338            if include_files {
339                affected_files.insert(file_path);
340            }
341
342            if depth == 1 {
343                direct.push(impact_sym);
344            } else if include_indirect {
345                indirect.push(impact_sym);
346            }
347        }
348    }
349
350    CategorizedImpact {
351        direct,
352        indirect,
353        affected_files,
354    }
355}
356
357/// Run the impact command.
358///
359/// # Errors
360/// Returns an error if the graph cannot be loaded or symbol cannot be found.
361pub fn run_impact(
362    cli: &Cli,
363    symbol: &str,
364    path: Option<&str>,
365    max_depth: usize,
366    max_results: usize,
367    include_indirect: bool,
368    include_files: bool,
369) -> Result<()> {
370    let mut streams = OutputStreams::new();
371
372    // Find index
373    let search_path = path.map_or_else(
374        || std::env::current_dir().unwrap_or_default(),
375        std::path::PathBuf::from,
376    );
377
378    let index_location = find_nearest_index(&search_path);
379    let Some(ref loc) = index_location else {
380        streams
381            .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
382        return Ok(());
383    };
384
385    // Load graph
386    let config = GraphLoadConfig::default();
387    let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli)
388        .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
389
390    // Resolve the target symbol via the shared ambiguity-aware resolver.
391    // The legacy `nodes().iter().find()` substring scan was the bug
392    // surfaced in `verivus-oss/sqry#77` / `#156`: it silently picked the
393    // first match (or returned "not found in graph" when nothing matched
394    // by name) and gave the user no way to disambiguate. The shared
395    // resolver returns a typed [`SymbolResolveError`] with the full
396    // candidate list which we render through the
397    // `sqry::ambiguous_symbol` envelope.
398    let snapshot = graph.snapshot();
399    let target_node_id =
400        match snapshot.resolve_global_symbol_ambiguity_aware(symbol, FileScope::Any) {
401            Ok(node_id) => node_id,
402            Err(SymbolResolveError::Ambiguous(err)) => {
403                let exit_code = emit_ambiguous_symbol_error(&mut streams, &err, cli.json);
404                std::process::exit(exit_code);
405            }
406            Err(SymbolResolveError::NotFound { name }) => {
407                let exit_code = emit_symbol_not_found(&mut streams, &name, cli.json);
408                std::process::exit(exit_code);
409            }
410        };
411
412    // BFS to find all dependents (reverse dependency traversal)
413    let effective_max_depth = if include_indirect { max_depth } else { 1 };
414    let bfs = collect_dependents_bfs(&graph, target_node_id, effective_max_depth);
415
416    // Build categorized output
417    let mut impact = build_impact_symbols(&graph, &bfs, include_indirect, include_files);
418
419    // Sort for determinism
420    impact
421        .direct
422        .sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
423    impact.indirect.sort_by(|a, b| {
424        a.depth
425            .cmp(&b.depth)
426            .then(a.qualified_name.cmp(&b.qualified_name))
427    });
428
429    // Apply limit
430    impact.direct.truncate(max_results);
431    impact
432        .indirect
433        .truncate(max_results.saturating_sub(impact.direct.len()));
434
435    let mut files_vec: Vec<String> = impact.affected_files.into_iter().collect();
436    files_vec.sort();
437
438    let stats = ImpactStats {
439        direct_count: impact.direct.len(),
440        indirect_count: impact.indirect.len(),
441        total_affected: impact.direct.len() + impact.indirect.len(),
442        affected_files_count: files_vec.len(),
443        max_depth: bfs.max_depth_reached,
444    };
445
446    let output = ImpactOutput {
447        symbol: symbol.to_string(),
448        direct: impact.direct,
449        indirect: impact.indirect,
450        affected_files: if include_files { files_vec } else { Vec::new() },
451        stats,
452    };
453
454    // Output
455    if cli.json {
456        let json = serde_json::to_string_pretty(&output).context("Failed to serialize to JSON")?;
457        streams.write_result(&json)?;
458    } else {
459        let text = format_impact_text(&output);
460        streams.write_result(&text)?;
461    }
462
463    Ok(())
464}
465
466/// Format direct dependents section for text output.
467fn format_direct_dependents(lines: &mut Vec<String>, direct: &[ImpactSymbol]) {
468    if !direct.is_empty() {
469        lines.push("Direct dependents:".to_string());
470        for sym in direct {
471            lines.push(format!(
472                "  {} [{}] ({} this)",
473                sym.qualified_name, sym.kind, sym.relation
474            ));
475            lines.push(format!("    {}:{}", sym.file, sym.line));
476        }
477    }
478}
479
480/// Format indirect dependents section for text output.
481fn format_indirect_dependents(lines: &mut Vec<String>, indirect: &[ImpactSymbol]) {
482    if !indirect.is_empty() {
483        lines.push(String::new());
484        lines.push("Indirect dependents:".to_string());
485        for sym in indirect {
486            lines.push(format!(
487                "  {} [{}] depth={} ({} chain)",
488                sym.qualified_name, sym.kind, sym.depth, sym.relation
489            ));
490            lines.push(format!("    {}:{}", sym.file, sym.line));
491        }
492    }
493}
494
495fn format_impact_text(output: &ImpactOutput) -> String {
496    let mut lines = Vec::new();
497
498    lines.push(format!("Impact analysis for: {}", output.symbol));
499    lines.push(format!(
500        "Total affected: {} ({} direct, {} indirect)",
501        output.stats.total_affected, output.stats.direct_count, output.stats.indirect_count
502    ));
503    if output.stats.affected_files_count > 0 {
504        lines.push(format!(
505            "Affected files: {}",
506            output.stats.affected_files_count
507        ));
508    }
509    lines.push(String::new());
510
511    if output.direct.is_empty() && output.indirect.is_empty() {
512        lines.push("No dependents found. This symbol appears to be unused.".to_string());
513    } else {
514        format_direct_dependents(&mut lines, &output.direct);
515        format_indirect_dependents(&mut lines, &output.indirect);
516    }
517
518    if !output.affected_files.is_empty() {
519        lines.push(String::new());
520        lines.push("Affected files:".to_string());
521        for file in &output.affected_files {
522            lines.push(format!("  {file}"));
523        }
524    }
525
526    lines.join("\n")
527}