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, anyhow};
10use serde::Serialize;
11use sqry_core::graph::unified::node::NodeId;
12use sqry_core::graph::unified::traversal::EdgeClassification;
13use sqry_core::graph::unified::{
14    EdgeFilter, TraversalConfig, TraversalDirection, TraversalLimits, traverse,
15};
16use std::collections::{HashMap, HashSet};
17
18/// Impact analysis output
19#[derive(Debug, Serialize)]
20struct ImpactOutput {
21    /// Symbol being analyzed
22    symbol: String,
23    /// Direct dependents (depth 1)
24    direct: Vec<ImpactSymbol>,
25    /// Indirect dependents (depth > 1)
26    #[serde(skip_serializing_if = "Vec::is_empty")]
27    indirect: Vec<ImpactSymbol>,
28    /// Affected files
29    #[serde(skip_serializing_if = "Vec::is_empty")]
30    affected_files: Vec<String>,
31    /// Statistics
32    stats: ImpactStats,
33}
34
35#[derive(Debug, Serialize)]
36struct ImpactSymbol {
37    name: String,
38    qualified_name: String,
39    kind: String,
40    file: String,
41    line: u32,
42    /// How this symbol depends on the target
43    relation: String,
44    /// Depth from target symbol
45    depth: usize,
46}
47
48#[derive(Debug, Serialize)]
49struct ImpactStats {
50    direct_count: usize,
51    indirect_count: usize,
52    total_affected: usize,
53    affected_files_count: usize,
54    max_depth: usize,
55}
56
57/// Result of BFS traversal collecting dependents.
58struct BfsResult {
59    visited: HashSet<NodeId>,
60    node_depths: HashMap<NodeId, usize>,
61    node_relations: HashMap<NodeId, String>,
62    max_depth_reached: usize,
63}
64
65/// Perform BFS to collect all reverse dependents of a target node.
66///
67/// Uses the traversal kernel with incoming direction and dependency edges
68/// (calls, imports, references, inheritance). Converts the kernel's
69/// `TraversalResult` into the `BfsResult` expected by downstream code.
70///
71/// # Dispatch path (DB18)
72///
73/// `impact` is a **NodeId-anchored multi-hop BFS** under the Phase 3C
74/// dispatch taxonomy; it does not route through sqry-db's name-keyed
75/// queries. The target is resolved to a single `NodeId` in
76/// [`run_impact`] via substring / qualified-name matching *before* this
77/// traversal starts.
78///
79/// # Frontier invariant
80///
81/// Traversal broadens strictly through edges physically adjacent to
82/// already-visited `NodeId`s (kernel `traverse` with `edges_from` in
83/// the `Incoming` direction). It never re-resolves a name at depth ≥ 1,
84/// preserving the same-name frontier invariant: a user who seeds on
85/// `AlphaMarker::helper` cannot pull in unrelated `BetaMarker::helper`
86/// dependents. The single-seed `target_node_id` lookup in
87/// [`run_impact`] guarantees only one canonical anchor per invocation.
88fn collect_dependents_bfs(
89    graph: &sqry_core::graph::unified::concurrent::CodeGraph,
90    target_node_id: NodeId,
91    effective_max_depth: usize,
92) -> BfsResult {
93    let snapshot = graph.snapshot();
94
95    let config = TraversalConfig {
96        direction: TraversalDirection::Incoming,
97        edge_filter: EdgeFilter::dependency_edges(),
98        limits: TraversalLimits {
99            max_depth: u32::try_from(effective_max_depth).unwrap_or(u32::MAX),
100            max_nodes: None,
101            max_edges: None,
102            max_paths: None,
103        },
104    };
105
106    let result = traverse(&snapshot, &[target_node_id], &config, None);
107
108    let mut visited: HashSet<NodeId> = HashSet::new();
109    let mut node_depths: HashMap<NodeId, usize> = HashMap::new();
110    let mut node_relations: HashMap<NodeId, String> = HashMap::new();
111    let mut actual_max_depth: usize = 0;
112
113    for (idx, mat_node) in result.nodes.iter().enumerate() {
114        // Skip the target node itself — we only want dependents
115        if mat_node.node_id == target_node_id {
116            continue;
117        }
118
119        visited.insert(mat_node.node_id);
120
121        // Find the minimum depth edge leading to this node to determine its depth
122        let depth = result
123            .edges
124            .iter()
125            .filter(|e| e.source_idx == idx || e.target_idx == idx)
126            .map(|e| e.depth as usize)
127            .min()
128            .unwrap_or(1);
129
130        node_depths.insert(mat_node.node_id, depth);
131        actual_max_depth = actual_max_depth.max(depth);
132
133        // Determine relation type from the first edge classification reaching this node
134        let relation = result
135            .edges
136            .iter()
137            .find(|e| e.source_idx == idx || e.target_idx == idx)
138            .map(|e| classify_relation(&e.classification))
139            .unwrap_or_default();
140
141        node_relations.insert(mat_node.node_id, relation);
142    }
143
144    BfsResult {
145        visited,
146        node_depths,
147        node_relations,
148        max_depth_reached: actual_max_depth,
149    }
150}
151
152/// Map an `EdgeClassification` to a human-readable relation label.
153#[allow(clippy::trivially_copy_pass_by_ref)] // API consistency with other command handlers
154fn classify_relation(classification: &EdgeClassification) -> String {
155    match classification {
156        EdgeClassification::Call { .. } => "calls".to_string(),
157        EdgeClassification::Import { .. } => "imports".to_string(),
158        EdgeClassification::Reference => "references".to_string(),
159        EdgeClassification::Inherits => "inherits".to_string(),
160        EdgeClassification::Implements => "implements".to_string(),
161        EdgeClassification::Export { .. } => "exports".to_string(),
162        EdgeClassification::Contains => "contains".to_string(),
163        EdgeClassification::Defines => "defines".to_string(),
164        EdgeClassification::TypeOf => "type_of".to_string(),
165        EdgeClassification::DatabaseAccess => "database_access".to_string(),
166        EdgeClassification::ServiceInteraction => "service_interaction".to_string(),
167    }
168}
169
170/// Categorized impact symbols after BFS traversal.
171struct CategorizedImpact {
172    direct: Vec<ImpactSymbol>,
173    indirect: Vec<ImpactSymbol>,
174    affected_files: HashSet<String>,
175}
176
177/// Build categorized impact symbols from BFS results.
178fn build_impact_symbols(
179    graph: &sqry_core::graph::unified::concurrent::CodeGraph,
180    bfs: &BfsResult,
181    include_indirect: bool,
182    include_files: bool,
183) -> CategorizedImpact {
184    let strings = graph.strings();
185    let files = graph.files();
186    let mut direct: Vec<ImpactSymbol> = Vec::new();
187    let mut indirect: Vec<ImpactSymbol> = Vec::new();
188    let mut affected_files: HashSet<String> = HashSet::new();
189
190    for &node_id in &bfs.visited {
191        if let Some(entry) = graph.nodes().get(node_id) {
192            let depth = *bfs.node_depths.get(&node_id).unwrap_or(&0);
193            let relation = bfs
194                .node_relations
195                .get(&node_id)
196                .cloned()
197                .unwrap_or_default();
198
199            let name = strings
200                .resolve(entry.name)
201                .map(|s| s.to_string())
202                .unwrap_or_default();
203            let qualified_name = entry
204                .qualified_name
205                .and_then(|id| strings.resolve(id))
206                .map_or_else(|| name.clone(), |s| s.to_string());
207
208            let file_path = files
209                .resolve(entry.file)
210                .map(|p| p.display().to_string())
211                .unwrap_or_default();
212
213            let impact_sym = ImpactSymbol {
214                name,
215                qualified_name,
216                kind: format!("{:?}", entry.kind),
217                file: file_path.clone(),
218                line: entry.start_line,
219                relation,
220                depth,
221            };
222
223            if include_files {
224                affected_files.insert(file_path);
225            }
226
227            if depth == 1 {
228                direct.push(impact_sym);
229            } else if include_indirect {
230                indirect.push(impact_sym);
231            }
232        }
233    }
234
235    CategorizedImpact {
236        direct,
237        indirect,
238        affected_files,
239    }
240}
241
242/// Run the impact command.
243///
244/// # Errors
245/// Returns an error if the graph cannot be loaded or symbol cannot be found.
246pub fn run_impact(
247    cli: &Cli,
248    symbol: &str,
249    path: Option<&str>,
250    max_depth: usize,
251    max_results: usize,
252    include_indirect: bool,
253    include_files: bool,
254) -> Result<()> {
255    let mut streams = OutputStreams::new();
256
257    // Find index
258    let search_path = path.map_or_else(
259        || std::env::current_dir().unwrap_or_default(),
260        std::path::PathBuf::from,
261    );
262
263    let index_location = find_nearest_index(&search_path);
264    let Some(ref loc) = index_location else {
265        streams
266            .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
267        return Ok(());
268    };
269
270    // Load graph
271    let config = GraphLoadConfig::default();
272    let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli)
273        .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
274
275    let strings = graph.strings();
276
277    // Find the target symbol by iterating over nodes
278    let target_node_id = graph
279        .nodes()
280        .iter()
281        .find(|(_, entry)| {
282            // Check qualified name
283            if let Some(qn_id) = entry.qualified_name
284                && let Some(qn) = strings.resolve(qn_id)
285                && (qn.as_ref() == symbol || qn.contains(symbol))
286            {
287                return true;
288            }
289            // Check simple name
290            if let Some(name) = strings.resolve(entry.name)
291                && name.as_ref() == symbol
292            {
293                return true;
294            }
295            false
296        })
297        .map(|(id, _)| id)
298        .ok_or_else(|| anyhow!("Symbol '{symbol}' not found in graph"))?;
299
300    // BFS to find all dependents (reverse dependency traversal)
301    let effective_max_depth = if include_indirect { max_depth } else { 1 };
302    let bfs = collect_dependents_bfs(&graph, target_node_id, effective_max_depth);
303
304    // Build categorized output
305    let mut impact = build_impact_symbols(&graph, &bfs, include_indirect, include_files);
306
307    // Sort for determinism
308    impact
309        .direct
310        .sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
311    impact.indirect.sort_by(|a, b| {
312        a.depth
313            .cmp(&b.depth)
314            .then(a.qualified_name.cmp(&b.qualified_name))
315    });
316
317    // Apply limit
318    impact.direct.truncate(max_results);
319    impact
320        .indirect
321        .truncate(max_results.saturating_sub(impact.direct.len()));
322
323    let mut files_vec: Vec<String> = impact.affected_files.into_iter().collect();
324    files_vec.sort();
325
326    let stats = ImpactStats {
327        direct_count: impact.direct.len(),
328        indirect_count: impact.indirect.len(),
329        total_affected: impact.direct.len() + impact.indirect.len(),
330        affected_files_count: files_vec.len(),
331        max_depth: bfs.max_depth_reached,
332    };
333
334    let output = ImpactOutput {
335        symbol: symbol.to_string(),
336        direct: impact.direct,
337        indirect: impact.indirect,
338        affected_files: if include_files { files_vec } else { Vec::new() },
339        stats,
340    };
341
342    // Output
343    if cli.json {
344        let json = serde_json::to_string_pretty(&output).context("Failed to serialize to JSON")?;
345        streams.write_result(&json)?;
346    } else {
347        let text = format_impact_text(&output);
348        streams.write_result(&text)?;
349    }
350
351    Ok(())
352}
353
354/// Format direct dependents section for text output.
355fn format_direct_dependents(lines: &mut Vec<String>, direct: &[ImpactSymbol]) {
356    if !direct.is_empty() {
357        lines.push("Direct dependents:".to_string());
358        for sym in direct {
359            lines.push(format!(
360                "  {} [{}] ({} this)",
361                sym.qualified_name, sym.kind, sym.relation
362            ));
363            lines.push(format!("    {}:{}", sym.file, sym.line));
364        }
365    }
366}
367
368/// Format indirect dependents section for text output.
369fn format_indirect_dependents(lines: &mut Vec<String>, indirect: &[ImpactSymbol]) {
370    if !indirect.is_empty() {
371        lines.push(String::new());
372        lines.push("Indirect dependents:".to_string());
373        for sym in indirect {
374            lines.push(format!(
375                "  {} [{}] depth={} ({} chain)",
376                sym.qualified_name, sym.kind, sym.depth, sym.relation
377            ));
378            lines.push(format!("    {}:{}", sym.file, sym.line));
379        }
380    }
381}
382
383fn format_impact_text(output: &ImpactOutput) -> String {
384    let mut lines = Vec::new();
385
386    lines.push(format!("Impact analysis for: {}", output.symbol));
387    lines.push(format!(
388        "Total affected: {} ({} direct, {} indirect)",
389        output.stats.total_affected, output.stats.direct_count, output.stats.indirect_count
390    ));
391    if output.stats.affected_files_count > 0 {
392        lines.push(format!(
393            "Affected files: {}",
394            output.stats.affected_files_count
395        ));
396    }
397    lines.push(String::new());
398
399    if output.direct.is_empty() && output.indirect.is_empty() {
400        lines.push("No dependents found. This symbol appears to be unused.".to_string());
401    } else {
402        format_direct_dependents(&mut lines, &output.direct);
403        format_indirect_dependents(&mut lines, &output.indirect);
404    }
405
406    if !output.affected_files.is_empty() {
407        lines.push(String::new());
408        lines.push("Affected files:".to_string());
409        for file in &output.affected_files {
410            lines.push(format!("  {file}"));
411        }
412    }
413
414    lines.join("\n")
415}