1use 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#[derive(Debug, Serialize)]
17struct ImpactOutput {
18 symbol: String,
20 direct: Vec<ImpactSymbol>,
22 #[serde(skip_serializing_if = "Vec::is_empty")]
24 indirect: Vec<ImpactSymbol>,
25 #[serde(skip_serializing_if = "Vec::is_empty")]
27 affected_files: Vec<String>,
28 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 relation: String,
41 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
54struct BfsResult {
56 visited: HashSet<NodeId>,
57 node_depths: HashMap<NodeId, usize>,
58 node_relations: HashMap<NodeId, String>,
59 max_depth_reached: usize,
60}
61
62fn 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 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, };
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 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
120struct CategorizedImpact {
122 direct: Vec<ImpactSymbol>,
123 indirect: Vec<ImpactSymbol>,
124 affected_files: HashSet<String>,
125}
126
127fn 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
192pub 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 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 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 let target_node_id = graph
229 .nodes()
230 .iter()
231 .find(|(_, entry)| {
232 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 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 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 let mut impact = build_impact_symbols(&graph, &bfs, include_indirect, include_files);
256
257 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 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 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
304fn 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
318fn 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}