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(
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 if mat_node.node_id == target_node_id {
98 continue;
99 }
100
101 visited.insert(mat_node.node_id);
102
103 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 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#[allow(clippy::trivially_copy_pass_by_ref)] fn 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
152struct CategorizedImpact {
154 direct: Vec<ImpactSymbol>,
155 indirect: Vec<ImpactSymbol>,
156 affected_files: HashSet<String>,
157}
158
159fn 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
224pub 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 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 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 let target_node_id = graph
261 .nodes()
262 .iter()
263 .find(|(_, entry)| {
264 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 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 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 let mut impact = build_impact_symbols(&graph, &bfs, include_indirect, include_files);
288
289 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 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 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
336fn 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
350fn 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}