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};
7use crate::index_discovery::find_nearest_index;
8use crate::output::OutputStreams;
9use anyhow::{Context, Result, anyhow};
10use serde::Serialize;
11use sqry_core::graph::unified::edge::EdgeKind;
12use sqry_core::graph::unified::node::NodeId;
13use std::collections::{HashMap, HashSet, VecDeque};
14
15/// Impact analysis output
16#[derive(Debug, Serialize)]
17struct ImpactOutput {
18    /// Symbol being analyzed
19    symbol: String,
20    /// Direct dependents (depth 1)
21    direct: Vec<ImpactSymbol>,
22    /// Indirect dependents (depth > 1)
23    #[serde(skip_serializing_if = "Vec::is_empty")]
24    indirect: Vec<ImpactSymbol>,
25    /// Affected files
26    #[serde(skip_serializing_if = "Vec::is_empty")]
27    affected_files: Vec<String>,
28    /// Statistics
29    stats: ImpactStats,
30}
31
32#[derive(Debug, Serialize)]
33struct ImpactSymbol {
34    name: String,
35    qualified_name: String,
36    kind: String,
37    file: String,
38    line: u32,
39    /// How this symbol depends on the target
40    relation: String,
41    /// Depth from target symbol
42    depth: usize,
43}
44
45#[derive(Debug, Serialize)]
46struct ImpactStats {
47    direct_count: usize,
48    indirect_count: usize,
49    total_affected: usize,
50    affected_files_count: usize,
51    max_depth: usize,
52}
53
54/// Result of BFS traversal collecting dependents.
55struct BfsResult {
56    visited: HashSet<NodeId>,
57    node_depths: HashMap<NodeId, usize>,
58    node_relations: HashMap<NodeId, String>,
59    max_depth_reached: usize,
60}
61
62/// Perform BFS to collect all reverse dependents of a target node.
63///
64/// Traverses incoming edges (callers, importers, references, inheritors, implementors)
65/// up to `effective_max_depth` levels deep.
66fn collect_dependents_bfs(
67    graph: &sqry_core::graph::unified::concurrent::CodeGraph,
68    target_node_id: NodeId,
69    effective_max_depth: usize,
70) -> BfsResult {
71    let mut visited: HashSet<NodeId> = HashSet::new();
72    let mut node_depths: HashMap<NodeId, usize> = HashMap::new();
73    let mut node_relations: HashMap<NodeId, String> = HashMap::new();
74    let mut queue: VecDeque<(NodeId, usize)> = VecDeque::new();
75
76    visited.insert(target_node_id);
77    node_depths.insert(target_node_id, 0);
78    queue.push_back((target_node_id, 0));
79
80    let mut actual_max_depth = 0;
81
82    while let Some((node_id, depth)) = queue.pop_front() {
83        if depth >= effective_max_depth {
84            continue;
85        }
86
87        actual_max_depth = actual_max_depth.max(depth);
88
89        // Find all nodes that depend on this node (incoming edges = callers/importers)
90        for edge_ref in graph.edges().edges_to(node_id) {
91            let relation = match &edge_ref.kind {
92                EdgeKind::Calls { .. } => "calls",
93                EdgeKind::Imports { .. } => "imports",
94                EdgeKind::References => "references",
95                EdgeKind::Inherits => "inherits",
96                EdgeKind::Implements => "implements",
97                _ => continue, // Skip non-dependency edges
98            };
99
100            if !visited.contains(&edge_ref.source) {
101                visited.insert(edge_ref.source);
102                node_depths.insert(edge_ref.source, depth + 1);
103                node_relations.insert(edge_ref.source, relation.to_string());
104                queue.push_back((edge_ref.source, depth + 1));
105            }
106        }
107    }
108
109    // Remove target from visited (we only want dependents)
110    visited.remove(&target_node_id);
111
112    BfsResult {
113        visited,
114        node_depths,
115        node_relations,
116        max_depth_reached: actual_max_depth,
117    }
118}
119
120/// Categorized impact symbols after BFS traversal.
121struct CategorizedImpact {
122    direct: Vec<ImpactSymbol>,
123    indirect: Vec<ImpactSymbol>,
124    affected_files: HashSet<String>,
125}
126
127/// Build categorized impact symbols from BFS results.
128fn build_impact_symbols(
129    graph: &sqry_core::graph::unified::concurrent::CodeGraph,
130    bfs: &BfsResult,
131    include_indirect: bool,
132    include_files: bool,
133) -> CategorizedImpact {
134    let strings = graph.strings();
135    let files = graph.files();
136    let mut direct: Vec<ImpactSymbol> = Vec::new();
137    let mut indirect: Vec<ImpactSymbol> = Vec::new();
138    let mut affected_files: HashSet<String> = HashSet::new();
139
140    for &node_id in &bfs.visited {
141        if let Some(entry) = graph.nodes().get(node_id) {
142            let depth = *bfs.node_depths.get(&node_id).unwrap_or(&0);
143            let relation = bfs
144                .node_relations
145                .get(&node_id)
146                .cloned()
147                .unwrap_or_default();
148
149            let name = strings
150                .resolve(entry.name)
151                .map(|s| s.to_string())
152                .unwrap_or_default();
153            let qualified_name = entry
154                .qualified_name
155                .and_then(|id| strings.resolve(id))
156                .map_or_else(|| name.clone(), |s| s.to_string());
157
158            let file_path = files
159                .resolve(entry.file)
160                .map(|p| p.display().to_string())
161                .unwrap_or_default();
162
163            let impact_sym = ImpactSymbol {
164                name,
165                qualified_name,
166                kind: format!("{:?}", entry.kind),
167                file: file_path.clone(),
168                line: entry.start_line,
169                relation,
170                depth,
171            };
172
173            if include_files {
174                affected_files.insert(file_path);
175            }
176
177            if depth == 1 {
178                direct.push(impact_sym);
179            } else if include_indirect {
180                indirect.push(impact_sym);
181            }
182        }
183    }
184
185    CategorizedImpact {
186        direct,
187        indirect,
188        affected_files,
189    }
190}
191
192/// Run the impact command.
193///
194/// # Errors
195/// Returns an error if the graph cannot be loaded or symbol cannot be found.
196pub fn run_impact(
197    cli: &Cli,
198    symbol: &str,
199    path: Option<&str>,
200    max_depth: usize,
201    max_results: usize,
202    include_indirect: bool,
203    include_files: bool,
204) -> Result<()> {
205    let mut streams = OutputStreams::new();
206
207    // Find index
208    let search_path = path.map_or_else(
209        || std::env::current_dir().unwrap_or_default(),
210        std::path::PathBuf::from,
211    );
212
213    let index_location = find_nearest_index(&search_path);
214    let Some(ref loc) = index_location else {
215        streams
216            .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
217        return Ok(());
218    };
219
220    // Load graph
221    let config = GraphLoadConfig::default();
222    let graph = load_unified_graph(&loc.index_root, &config)
223        .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
224
225    let strings = graph.strings();
226
227    // Find the target symbol by iterating over nodes
228    let target_node_id = graph
229        .nodes()
230        .iter()
231        .find(|(_, entry)| {
232            // Check qualified name
233            if let Some(qn_id) = entry.qualified_name
234                && let Some(qn) = strings.resolve(qn_id)
235                && (qn.as_ref() == symbol || qn.contains(symbol))
236            {
237                return true;
238            }
239            // Check simple name
240            if let Some(name) = strings.resolve(entry.name)
241                && name.as_ref() == symbol
242            {
243                return true;
244            }
245            false
246        })
247        .map(|(id, _)| id)
248        .ok_or_else(|| anyhow!("Symbol '{symbol}' not found in graph"))?;
249
250    // BFS to find all dependents (reverse dependency traversal)
251    let effective_max_depth = if include_indirect { max_depth } else { 1 };
252    let bfs = collect_dependents_bfs(&graph, target_node_id, effective_max_depth);
253
254    // Build categorized output
255    let mut impact = build_impact_symbols(&graph, &bfs, include_indirect, include_files);
256
257    // Sort for determinism
258    impact
259        .direct
260        .sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
261    impact.indirect.sort_by(|a, b| {
262        a.depth
263            .cmp(&b.depth)
264            .then(a.qualified_name.cmp(&b.qualified_name))
265    });
266
267    // Apply limit
268    impact.direct.truncate(max_results);
269    impact
270        .indirect
271        .truncate(max_results.saturating_sub(impact.direct.len()));
272
273    let mut files_vec: Vec<String> = impact.affected_files.into_iter().collect();
274    files_vec.sort();
275
276    let stats = ImpactStats {
277        direct_count: impact.direct.len(),
278        indirect_count: impact.indirect.len(),
279        total_affected: impact.direct.len() + impact.indirect.len(),
280        affected_files_count: files_vec.len(),
281        max_depth: bfs.max_depth_reached,
282    };
283
284    let output = ImpactOutput {
285        symbol: symbol.to_string(),
286        direct: impact.direct,
287        indirect: impact.indirect,
288        affected_files: if include_files { files_vec } else { Vec::new() },
289        stats,
290    };
291
292    // Output
293    if cli.json {
294        let json = serde_json::to_string_pretty(&output).context("Failed to serialize to JSON")?;
295        streams.write_result(&json)?;
296    } else {
297        let text = format_impact_text(&output);
298        streams.write_result(&text)?;
299    }
300
301    Ok(())
302}
303
304/// Format direct dependents section for text output.
305fn format_direct_dependents(lines: &mut Vec<String>, direct: &[ImpactSymbol]) {
306    if !direct.is_empty() {
307        lines.push("Direct dependents:".to_string());
308        for sym in direct {
309            lines.push(format!(
310                "  {} [{}] ({} this)",
311                sym.qualified_name, sym.kind, sym.relation
312            ));
313            lines.push(format!("    {}:{}", sym.file, sym.line));
314        }
315    }
316}
317
318/// Format indirect dependents section for text output.
319fn format_indirect_dependents(lines: &mut Vec<String>, indirect: &[ImpactSymbol]) {
320    if !indirect.is_empty() {
321        lines.push(String::new());
322        lines.push("Indirect dependents:".to_string());
323        for sym in indirect {
324            lines.push(format!(
325                "  {} [{}] depth={} ({} chain)",
326                sym.qualified_name, sym.kind, sym.depth, sym.relation
327            ));
328            lines.push(format!("    {}:{}", sym.file, sym.line));
329        }
330    }
331}
332
333fn format_impact_text(output: &ImpactOutput) -> String {
334    let mut lines = Vec::new();
335
336    lines.push(format!("Impact analysis for: {}", output.symbol));
337    lines.push(format!(
338        "Total affected: {} ({} direct, {} indirect)",
339        output.stats.total_affected, output.stats.direct_count, output.stats.indirect_count
340    ));
341    if output.stats.affected_files_count > 0 {
342        lines.push(format!(
343            "Affected files: {}",
344            output.stats.affected_files_count
345        ));
346    }
347    lines.push(String::new());
348
349    if output.direct.is_empty() && output.indirect.is_empty() {
350        lines.push("No dependents found. This symbol appears to be unused.".to_string());
351    } else {
352        format_direct_dependents(&mut lines, &output.direct);
353        format_indirect_dependents(&mut lines, &output.indirect);
354    }
355
356    if !output.affected_files.is_empty() {
357        lines.push(String::new());
358        lines.push("Affected files:".to_string());
359        for file in &output.affected_files {
360            lines.push(format!("  {file}"));
361        }
362    }
363
364    lines.join("\n")
365}