1use 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#[derive(Debug, Serialize)]
20struct ImpactOutput {
21 symbol: String,
23 direct: Vec<ImpactSymbol>,
25 #[serde(skip_serializing_if = "Vec::is_empty")]
27 indirect: Vec<ImpactSymbol>,
28 #[serde(skip_serializing_if = "Vec::is_empty")]
30 affected_files: Vec<String>,
31 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 relation: String,
44 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
57struct BfsResult {
59 visited: HashSet<NodeId>,
60 node_depths: HashMap<NodeId, usize>,
61 node_relations: HashMap<NodeId, String>,
62 max_depth_reached: usize,
63}
64
65fn 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 if mat_node.node_id == target_node_id {
116 continue;
117 }
118
119 visited.insert(mat_node.node_id);
120
121 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 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#[allow(clippy::trivially_copy_pass_by_ref)] fn 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
170struct CategorizedImpact {
172 direct: Vec<ImpactSymbol>,
173 indirect: Vec<ImpactSymbol>,
174 affected_files: HashSet<String>,
175}
176
177fn 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
242pub 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 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 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 let target_node_id = graph
279 .nodes()
280 .iter()
281 .find(|(_, entry)| {
282 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 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 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 let mut impact = build_impact_symbols(&graph, &bfs, include_indirect, include_files);
306
307 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 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 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
354fn 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
368fn 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}