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