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};
10use serde::Serialize;
11use sqry_core::graph::unified::node::NodeId;
12use sqry_core::graph::unified::resolution::{AmbiguousSymbolError, SymbolResolveError};
13use sqry_core::graph::unified::traversal::EdgeClassification;
14use sqry_core::graph::unified::{
15 EdgeFilter, FileScope, TraversalConfig, TraversalDirection, TraversalLimits, traverse,
16};
17use std::collections::{HashMap, HashSet};
18
19pub const AMBIGUOUS_SYMBOL_EXIT_CODE: i32 = 4;
24
25pub const SYMBOL_NOT_FOUND_EXIT_CODE: i32 = 2;
28
29pub const AMBIGUOUS_SYMBOL_ERROR_CODE: &str = "sqry::ambiguous_symbol";
31
32pub const SYMBOL_NOT_FOUND_ERROR_CODE: &str = "sqry::symbol_not_found";
34
35#[derive(Debug, Serialize)]
41struct AmbiguousSymbolEnvelope<'a> {
42 code: &'static str,
43 message: String,
44 candidates: &'a [sqry_core::graph::unified::resolution::AmbiguousSymbolCandidate],
45 truncated: bool,
46}
47
48#[derive(Debug, Serialize)]
49struct AmbiguousSymbolWireWrapper<'a> {
50 error: AmbiguousSymbolEnvelope<'a>,
51}
52
53pub(crate) fn emit_ambiguous_symbol_error(
60 streams: &mut OutputStreams,
61 err: &AmbiguousSymbolError,
62 json_output: bool,
63) -> i32 {
64 let message = format!(
65 "Symbol '{}' is ambiguous; specify the qualified name",
66 err.name
67 );
68 if json_output {
69 let envelope = AmbiguousSymbolWireWrapper {
70 error: AmbiguousSymbolEnvelope {
71 code: AMBIGUOUS_SYMBOL_ERROR_CODE,
72 message,
73 candidates: &err.candidates,
74 truncated: err.truncated,
75 },
76 };
77 let json = serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| {
78 format!(
79 "{{\"error\":{{\"code\":\"{AMBIGUOUS_SYMBOL_ERROR_CODE}\",\"message\":\"{}\"}}}}",
80 err.name
81 )
82 });
83 let _ = streams.write_result(&json);
84 } else {
85 let mut lines = vec![format!("Error: {message}.")];
86 if err.truncated {
87 lines.push(format!(
88 "Showing first {} candidates (more matched):",
89 err.candidates.len()
90 ));
91 } else {
92 lines.push("Candidates:".to_string());
93 }
94 for candidate in &err.candidates {
95 lines.push(format!(
96 " - {} [{}] ({}:{}:{})",
97 candidate.qualified_name,
98 candidate.kind,
99 candidate.file_path,
100 candidate.start_line,
101 candidate.start_column
102 ));
103 }
104 let _ = streams.write_diagnostic(&lines.join("\n"));
105 }
106 AMBIGUOUS_SYMBOL_EXIT_CODE
107}
108
109pub(crate) fn emit_symbol_not_found(
112 streams: &mut OutputStreams,
113 name: &str,
114 json_output: bool,
115) -> i32 {
116 let message = format!("Symbol '{name}' not found in graph");
117 if json_output {
118 let envelope = serde_json::json!({
119 "error": {
120 "code": SYMBOL_NOT_FOUND_ERROR_CODE,
121 "message": message,
122 }
123 });
124 let json = serde_json::to_string_pretty(&envelope)
125 .unwrap_or_else(|_| format!("{{\"error\":{{\"code\":\"{SYMBOL_NOT_FOUND_ERROR_CODE}\",\"message\":\"{name}\"}}}}"));
126 let _ = streams.write_result(&json);
127 } else {
128 let _ = streams.write_diagnostic(&format!("Error: {message}."));
129 }
130 SYMBOL_NOT_FOUND_EXIT_CODE
131}
132
133#[derive(Debug, Serialize)]
135struct ImpactOutput {
136 symbol: String,
138 direct: Vec<ImpactSymbol>,
140 #[serde(skip_serializing_if = "Vec::is_empty")]
142 indirect: Vec<ImpactSymbol>,
143 #[serde(skip_serializing_if = "Vec::is_empty")]
145 affected_files: Vec<String>,
146 stats: ImpactStats,
148}
149
150#[derive(Debug, Serialize)]
151struct ImpactSymbol {
152 name: String,
153 qualified_name: String,
154 kind: String,
155 file: String,
156 line: u32,
157 relation: String,
159 depth: usize,
161}
162
163#[derive(Debug, Serialize)]
164struct ImpactStats {
165 direct_count: usize,
166 indirect_count: usize,
167 total_affected: usize,
168 affected_files_count: usize,
169 max_depth: usize,
170}
171
172struct BfsResult {
174 visited: HashSet<NodeId>,
175 node_depths: HashMap<NodeId, usize>,
176 node_relations: HashMap<NodeId, String>,
177 max_depth_reached: usize,
178}
179
180fn collect_dependents_bfs(
204 graph: &sqry_core::graph::unified::concurrent::CodeGraph,
205 target_node_id: NodeId,
206 effective_max_depth: usize,
207) -> BfsResult {
208 let snapshot = graph.snapshot();
209
210 let config = TraversalConfig {
211 direction: TraversalDirection::Incoming,
212 edge_filter: EdgeFilter::dependency_edges(),
213 limits: TraversalLimits {
214 max_depth: u32::try_from(effective_max_depth).unwrap_or(u32::MAX),
215 max_nodes: None,
216 max_edges: None,
217 max_paths: None,
218 },
219 };
220
221 let result = traverse(&snapshot, &[target_node_id], &config, None);
222
223 let mut visited: HashSet<NodeId> = HashSet::new();
224 let mut node_depths: HashMap<NodeId, usize> = HashMap::new();
225 let mut node_relations: HashMap<NodeId, String> = HashMap::new();
226 let mut actual_max_depth: usize = 0;
227
228 for (idx, mat_node) in result.nodes.iter().enumerate() {
229 if mat_node.node_id == target_node_id {
231 continue;
232 }
233
234 visited.insert(mat_node.node_id);
235
236 let depth = result
238 .edges
239 .iter()
240 .filter(|e| e.source_idx == idx || e.target_idx == idx)
241 .map(|e| e.depth as usize)
242 .min()
243 .unwrap_or(1);
244
245 node_depths.insert(mat_node.node_id, depth);
246 actual_max_depth = actual_max_depth.max(depth);
247
248 let relation = result
250 .edges
251 .iter()
252 .find(|e| e.source_idx == idx || e.target_idx == idx)
253 .map(|e| classify_relation(&e.classification))
254 .unwrap_or_default();
255
256 node_relations.insert(mat_node.node_id, relation);
257 }
258
259 BfsResult {
260 visited,
261 node_depths,
262 node_relations,
263 max_depth_reached: actual_max_depth,
264 }
265}
266
267#[allow(clippy::trivially_copy_pass_by_ref)] fn classify_relation(classification: &EdgeClassification) -> String {
270 match classification {
271 EdgeClassification::Call { .. } => "calls".to_string(),
272 EdgeClassification::Import { .. } => "imports".to_string(),
273 EdgeClassification::Reference => "references".to_string(),
274 EdgeClassification::Inherits => "inherits".to_string(),
275 EdgeClassification::Implements => "implements".to_string(),
276 EdgeClassification::Export { .. } => "exports".to_string(),
277 EdgeClassification::Contains => "contains".to_string(),
278 EdgeClassification::Defines => "defines".to_string(),
279 EdgeClassification::TypeOf => "type_of".to_string(),
280 EdgeClassification::DatabaseAccess => "database_access".to_string(),
281 EdgeClassification::ServiceInteraction => "service_interaction".to_string(),
282 }
283}
284
285struct CategorizedImpact {
287 direct: Vec<ImpactSymbol>,
288 indirect: Vec<ImpactSymbol>,
289 affected_files: HashSet<String>,
290}
291
292fn build_impact_symbols(
294 graph: &sqry_core::graph::unified::concurrent::CodeGraph,
295 bfs: &BfsResult,
296 include_indirect: bool,
297 include_files: bool,
298) -> CategorizedImpact {
299 let strings = graph.strings();
300 let files = graph.files();
301 let mut direct: Vec<ImpactSymbol> = Vec::new();
302 let mut indirect: Vec<ImpactSymbol> = Vec::new();
303 let mut affected_files: HashSet<String> = HashSet::new();
304
305 for &node_id in &bfs.visited {
306 if let Some(entry) = graph.nodes().get(node_id) {
307 let depth = *bfs.node_depths.get(&node_id).unwrap_or(&0);
308 let relation = bfs
309 .node_relations
310 .get(&node_id)
311 .cloned()
312 .unwrap_or_default();
313
314 let name = strings
315 .resolve(entry.name)
316 .map(|s| s.to_string())
317 .unwrap_or_default();
318 let qualified_name = entry
319 .qualified_name
320 .and_then(|id| strings.resolve(id))
321 .map_or_else(|| name.clone(), |s| s.to_string());
322
323 let file_path = files
324 .resolve(entry.file)
325 .map(|p| p.display().to_string())
326 .unwrap_or_default();
327
328 let impact_sym = ImpactSymbol {
329 name,
330 qualified_name,
331 kind: format!("{:?}", entry.kind),
332 file: file_path.clone(),
333 line: entry.start_line,
334 relation,
335 depth,
336 };
337
338 if include_files {
339 affected_files.insert(file_path);
340 }
341
342 if depth == 1 {
343 direct.push(impact_sym);
344 } else if include_indirect {
345 indirect.push(impact_sym);
346 }
347 }
348 }
349
350 CategorizedImpact {
351 direct,
352 indirect,
353 affected_files,
354 }
355}
356
357pub fn run_impact(
362 cli: &Cli,
363 symbol: &str,
364 path: Option<&str>,
365 max_depth: usize,
366 max_results: usize,
367 include_indirect: bool,
368 include_files: bool,
369) -> Result<()> {
370 let mut streams = OutputStreams::new();
371
372 let search_path = path.map_or_else(
374 || std::env::current_dir().unwrap_or_default(),
375 std::path::PathBuf::from,
376 );
377
378 let index_location = find_nearest_index(&search_path);
379 let Some(ref loc) = index_location else {
380 streams
381 .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
382 return Ok(());
383 };
384
385 let config = GraphLoadConfig::default();
387 let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli)
388 .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
389
390 let snapshot = graph.snapshot();
399 let target_node_id =
400 match snapshot.resolve_global_symbol_ambiguity_aware(symbol, FileScope::Any) {
401 Ok(node_id) => node_id,
402 Err(SymbolResolveError::Ambiguous(err)) => {
403 let exit_code = emit_ambiguous_symbol_error(&mut streams, &err, cli.json);
404 std::process::exit(exit_code);
405 }
406 Err(SymbolResolveError::NotFound { name }) => {
407 let exit_code = emit_symbol_not_found(&mut streams, &name, cli.json);
408 std::process::exit(exit_code);
409 }
410 };
411
412 let effective_max_depth = if include_indirect { max_depth } else { 1 };
414 let bfs = collect_dependents_bfs(&graph, target_node_id, effective_max_depth);
415
416 let mut impact = build_impact_symbols(&graph, &bfs, include_indirect, include_files);
418
419 impact
421 .direct
422 .sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
423 impact.indirect.sort_by(|a, b| {
424 a.depth
425 .cmp(&b.depth)
426 .then(a.qualified_name.cmp(&b.qualified_name))
427 });
428
429 impact.direct.truncate(max_results);
431 impact
432 .indirect
433 .truncate(max_results.saturating_sub(impact.direct.len()));
434
435 let mut files_vec: Vec<String> = impact.affected_files.into_iter().collect();
436 files_vec.sort();
437
438 let stats = ImpactStats {
439 direct_count: impact.direct.len(),
440 indirect_count: impact.indirect.len(),
441 total_affected: impact.direct.len() + impact.indirect.len(),
442 affected_files_count: files_vec.len(),
443 max_depth: bfs.max_depth_reached,
444 };
445
446 let output = ImpactOutput {
447 symbol: symbol.to_string(),
448 direct: impact.direct,
449 indirect: impact.indirect,
450 affected_files: if include_files { files_vec } else { Vec::new() },
451 stats,
452 };
453
454 if cli.json {
456 let json = serde_json::to_string_pretty(&output).context("Failed to serialize to JSON")?;
457 streams.write_result(&json)?;
458 } else {
459 let text = format_impact_text(&output);
460 streams.write_result(&text)?;
461 }
462
463 Ok(())
464}
465
466fn format_direct_dependents(lines: &mut Vec<String>, direct: &[ImpactSymbol]) {
468 if !direct.is_empty() {
469 lines.push("Direct dependents:".to_string());
470 for sym in direct {
471 lines.push(format!(
472 " {} [{}] ({} this)",
473 sym.qualified_name, sym.kind, sym.relation
474 ));
475 lines.push(format!(" {}:{}", sym.file, sym.line));
476 }
477 }
478}
479
480fn format_indirect_dependents(lines: &mut Vec<String>, indirect: &[ImpactSymbol]) {
482 if !indirect.is_empty() {
483 lines.push(String::new());
484 lines.push("Indirect dependents:".to_string());
485 for sym in indirect {
486 lines.push(format!(
487 " {} [{}] depth={} ({} chain)",
488 sym.qualified_name, sym.kind, sym.depth, sym.relation
489 ));
490 lines.push(format!(" {}:{}", sym.file, sym.line));
491 }
492 }
493}
494
495fn format_impact_text(output: &ImpactOutput) -> String {
496 let mut lines = Vec::new();
497
498 lines.push(format!("Impact analysis for: {}", output.symbol));
499 lines.push(format!(
500 "Total affected: {} ({} direct, {} indirect)",
501 output.stats.total_affected, output.stats.direct_count, output.stats.indirect_count
502 ));
503 if output.stats.affected_files_count > 0 {
504 lines.push(format!(
505 "Affected files: {}",
506 output.stats.affected_files_count
507 ));
508 }
509 lines.push(String::new());
510
511 if output.direct.is_empty() && output.indirect.is_empty() {
512 lines.push("No dependents found. This symbol appears to be unused.".to_string());
513 } else {
514 format_direct_dependents(&mut lines, &output.direct);
515 format_indirect_dependents(&mut lines, &output.indirect);
516 }
517
518 if !output.affected_files.is_empty() {
519 lines.push(String::new());
520 lines.push("Affected files:".to_string());
521 for file in &output.affected_files {
522 lines.push(format!(" {file}"));
523 }
524 }
525
526 lines.join("\n")
527}