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.
70fn collect_dependents_bfs(
71    graph: &sqry_core::graph::unified::concurrent::CodeGraph,
72    target_node_id: NodeId,
73    effective_max_depth: usize,
74) -> BfsResult {
75    let snapshot = graph.snapshot();
76
77    let config = TraversalConfig {
78        direction: TraversalDirection::Incoming,
79        edge_filter: EdgeFilter::dependency_edges(),
80        limits: TraversalLimits {
81            max_depth: u32::try_from(effective_max_depth).unwrap_or(u32::MAX),
82            max_nodes: None,
83            max_edges: None,
84            max_paths: None,
85        },
86    };
87
88    let result = traverse(&snapshot, &[target_node_id], &config, None);
89
90    let mut visited: HashSet<NodeId> = HashSet::new();
91    let mut node_depths: HashMap<NodeId, usize> = HashMap::new();
92    let mut node_relations: HashMap<NodeId, String> = HashMap::new();
93    let mut actual_max_depth: usize = 0;
94
95    for (idx, mat_node) in result.nodes.iter().enumerate() {
96        // Skip the target node itself — we only want dependents
97        if mat_node.node_id == target_node_id {
98            continue;
99        }
100
101        visited.insert(mat_node.node_id);
102
103        // Find the minimum depth edge leading to this node to determine its depth
104        let depth = result
105            .edges
106            .iter()
107            .filter(|e| e.source_idx == idx || e.target_idx == idx)
108            .map(|e| e.depth as usize)
109            .min()
110            .unwrap_or(1);
111
112        node_depths.insert(mat_node.node_id, depth);
113        actual_max_depth = actual_max_depth.max(depth);
114
115        // Determine relation type from the first edge classification reaching this node
116        let relation = result
117            .edges
118            .iter()
119            .find(|e| e.source_idx == idx || e.target_idx == idx)
120            .map(|e| classify_relation(&e.classification))
121            .unwrap_or_default();
122
123        node_relations.insert(mat_node.node_id, relation);
124    }
125
126    BfsResult {
127        visited,
128        node_depths,
129        node_relations,
130        max_depth_reached: actual_max_depth,
131    }
132}
133
134/// Map an `EdgeClassification` to a human-readable relation label.
135#[allow(clippy::trivially_copy_pass_by_ref)] // API consistency with other command handlers
136fn classify_relation(classification: &EdgeClassification) -> String {
137    match classification {
138        EdgeClassification::Call { .. } => "calls".to_string(),
139        EdgeClassification::Import { .. } => "imports".to_string(),
140        EdgeClassification::Reference => "references".to_string(),
141        EdgeClassification::Inherits => "inherits".to_string(),
142        EdgeClassification::Implements => "implements".to_string(),
143        EdgeClassification::Export { .. } => "exports".to_string(),
144        EdgeClassification::Contains => "contains".to_string(),
145        EdgeClassification::Defines => "defines".to_string(),
146        EdgeClassification::TypeOf => "type_of".to_string(),
147        EdgeClassification::DatabaseAccess => "database_access".to_string(),
148        EdgeClassification::ServiceInteraction => "service_interaction".to_string(),
149    }
150}
151
152/// Categorized impact symbols after BFS traversal.
153struct CategorizedImpact {
154    direct: Vec<ImpactSymbol>,
155    indirect: Vec<ImpactSymbol>,
156    affected_files: HashSet<String>,
157}
158
159/// Build categorized impact symbols from BFS results.
160fn build_impact_symbols(
161    graph: &sqry_core::graph::unified::concurrent::CodeGraph,
162    bfs: &BfsResult,
163    include_indirect: bool,
164    include_files: bool,
165) -> CategorizedImpact {
166    let strings = graph.strings();
167    let files = graph.files();
168    let mut direct: Vec<ImpactSymbol> = Vec::new();
169    let mut indirect: Vec<ImpactSymbol> = Vec::new();
170    let mut affected_files: HashSet<String> = HashSet::new();
171
172    for &node_id in &bfs.visited {
173        if let Some(entry) = graph.nodes().get(node_id) {
174            let depth = *bfs.node_depths.get(&node_id).unwrap_or(&0);
175            let relation = bfs
176                .node_relations
177                .get(&node_id)
178                .cloned()
179                .unwrap_or_default();
180
181            let name = strings
182                .resolve(entry.name)
183                .map(|s| s.to_string())
184                .unwrap_or_default();
185            let qualified_name = entry
186                .qualified_name
187                .and_then(|id| strings.resolve(id))
188                .map_or_else(|| name.clone(), |s| s.to_string());
189
190            let file_path = files
191                .resolve(entry.file)
192                .map(|p| p.display().to_string())
193                .unwrap_or_default();
194
195            let impact_sym = ImpactSymbol {
196                name,
197                qualified_name,
198                kind: format!("{:?}", entry.kind),
199                file: file_path.clone(),
200                line: entry.start_line,
201                relation,
202                depth,
203            };
204
205            if include_files {
206                affected_files.insert(file_path);
207            }
208
209            if depth == 1 {
210                direct.push(impact_sym);
211            } else if include_indirect {
212                indirect.push(impact_sym);
213            }
214        }
215    }
216
217    CategorizedImpact {
218        direct,
219        indirect,
220        affected_files,
221    }
222}
223
224/// Run the impact command.
225///
226/// # Errors
227/// Returns an error if the graph cannot be loaded or symbol cannot be found.
228pub fn run_impact(
229    cli: &Cli,
230    symbol: &str,
231    path: Option<&str>,
232    max_depth: usize,
233    max_results: usize,
234    include_indirect: bool,
235    include_files: bool,
236) -> Result<()> {
237    let mut streams = OutputStreams::new();
238
239    // Find index
240    let search_path = path.map_or_else(
241        || std::env::current_dir().unwrap_or_default(),
242        std::path::PathBuf::from,
243    );
244
245    let index_location = find_nearest_index(&search_path);
246    let Some(ref loc) = index_location else {
247        streams
248            .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
249        return Ok(());
250    };
251
252    // Load graph
253    let config = GraphLoadConfig::default();
254    let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli)
255        .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
256
257    let strings = graph.strings();
258
259    // Find the target symbol by iterating over nodes
260    let target_node_id = graph
261        .nodes()
262        .iter()
263        .find(|(_, entry)| {
264            // Check qualified name
265            if let Some(qn_id) = entry.qualified_name
266                && let Some(qn) = strings.resolve(qn_id)
267                && (qn.as_ref() == symbol || qn.contains(symbol))
268            {
269                return true;
270            }
271            // Check simple name
272            if let Some(name) = strings.resolve(entry.name)
273                && name.as_ref() == symbol
274            {
275                return true;
276            }
277            false
278        })
279        .map(|(id, _)| id)
280        .ok_or_else(|| anyhow!("Symbol '{symbol}' not found in graph"))?;
281
282    // BFS to find all dependents (reverse dependency traversal)
283    let effective_max_depth = if include_indirect { max_depth } else { 1 };
284    let bfs = collect_dependents_bfs(&graph, target_node_id, effective_max_depth);
285
286    // Build categorized output
287    let mut impact = build_impact_symbols(&graph, &bfs, include_indirect, include_files);
288
289    // Sort for determinism
290    impact
291        .direct
292        .sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
293    impact.indirect.sort_by(|a, b| {
294        a.depth
295            .cmp(&b.depth)
296            .then(a.qualified_name.cmp(&b.qualified_name))
297    });
298
299    // Apply limit
300    impact.direct.truncate(max_results);
301    impact
302        .indirect
303        .truncate(max_results.saturating_sub(impact.direct.len()));
304
305    let mut files_vec: Vec<String> = impact.affected_files.into_iter().collect();
306    files_vec.sort();
307
308    let stats = ImpactStats {
309        direct_count: impact.direct.len(),
310        indirect_count: impact.indirect.len(),
311        total_affected: impact.direct.len() + impact.indirect.len(),
312        affected_files_count: files_vec.len(),
313        max_depth: bfs.max_depth_reached,
314    };
315
316    let output = ImpactOutput {
317        symbol: symbol.to_string(),
318        direct: impact.direct,
319        indirect: impact.indirect,
320        affected_files: if include_files { files_vec } else { Vec::new() },
321        stats,
322    };
323
324    // Output
325    if cli.json {
326        let json = serde_json::to_string_pretty(&output).context("Failed to serialize to JSON")?;
327        streams.write_result(&json)?;
328    } else {
329        let text = format_impact_text(&output);
330        streams.write_result(&text)?;
331    }
332
333    Ok(())
334}
335
336/// Format direct dependents section for text output.
337fn format_direct_dependents(lines: &mut Vec<String>, direct: &[ImpactSymbol]) {
338    if !direct.is_empty() {
339        lines.push("Direct dependents:".to_string());
340        for sym in direct {
341            lines.push(format!(
342                "  {} [{}] ({} this)",
343                sym.qualified_name, sym.kind, sym.relation
344            ));
345            lines.push(format!("    {}:{}", sym.file, sym.line));
346        }
347    }
348}
349
350/// Format indirect dependents section for text output.
351fn format_indirect_dependents(lines: &mut Vec<String>, indirect: &[ImpactSymbol]) {
352    if !indirect.is_empty() {
353        lines.push(String::new());
354        lines.push("Indirect dependents:".to_string());
355        for sym in indirect {
356            lines.push(format!(
357                "  {} [{}] depth={} ({} chain)",
358                sym.qualified_name, sym.kind, sym.depth, sym.relation
359            ));
360            lines.push(format!("    {}:{}", sym.file, sym.line));
361        }
362    }
363}
364
365fn format_impact_text(output: &ImpactOutput) -> String {
366    let mut lines = Vec::new();
367
368    lines.push(format!("Impact analysis for: {}", output.symbol));
369    lines.push(format!(
370        "Total affected: {} ({} direct, {} indirect)",
371        output.stats.total_affected, output.stats.direct_count, output.stats.indirect_count
372    ));
373    if output.stats.affected_files_count > 0 {
374        lines.push(format!(
375            "Affected files: {}",
376            output.stats.affected_files_count
377        ));
378    }
379    lines.push(String::new());
380
381    if output.direct.is_empty() && output.indirect.is_empty() {
382        lines.push("No dependents found. This symbol appears to be unused.".to_string());
383    } else {
384        format_direct_dependents(&mut lines, &output.direct);
385        format_indirect_dependents(&mut lines, &output.indirect);
386    }
387
388    if !output.affected_files.is_empty() {
389        lines.push(String::new());
390        lines.push("Affected files:".to_string());
391        for file in &output.affected_files {
392            lines.push(format!("  {file}"));
393        }
394    }
395
396    lines.join("\n")
397}