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(
61 streams: &mut OutputStreams,
62 err: &AmbiguousSymbolError,
63 json_output: bool,
64) -> i32 {
65 let suggested_file = err.candidates.first().map(|c| c.file_path.as_str());
66 let message = build_ambiguity_message(&err.name, err.candidates.len(), suggested_file);
67 if json_output {
68 let envelope = AmbiguousSymbolWireWrapper {
69 error: AmbiguousSymbolEnvelope {
70 code: AMBIGUOUS_SYMBOL_ERROR_CODE,
71 message,
72 candidates: &err.candidates,
73 truncated: err.truncated,
74 },
75 };
76 let json = serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| {
77 format!(
78 "{{\"error\":{{\"code\":\"{AMBIGUOUS_SYMBOL_ERROR_CODE}\",\"message\":\"{}\"}}}}",
79 err.name
80 )
81 });
82 let _ = streams.write_result(&json);
83 } else {
84 let mut lines = vec![format!("Error: {message}.")];
85 if err.truncated {
86 lines.push(format!(
87 "Showing first {} candidates (more matched):",
88 err.candidates.len()
89 ));
90 } else {
91 lines.push("Candidates:".to_string());
92 }
93 for candidate in &err.candidates {
94 lines.push(format!(
95 " - {} [{}] ({}:{}:{})",
96 candidate.qualified_name,
97 candidate.kind,
98 candidate.file_path,
99 candidate.start_line,
100 candidate.start_column
101 ));
102 }
103 let _ = streams.write_diagnostic(&lines.join("\n"));
104 }
105 AMBIGUOUS_SYMBOL_EXIT_CODE
106}
107
108fn build_ambiguity_message(
118 name: &str,
119 candidate_count: usize,
120 sample_file: Option<&str>,
121) -> String {
122 let mut msg = format!(
123 "Symbol '{name}' is ambiguous ({candidate_count} candidates); pass `--in <file>` \
124 to disambiguate by the file the intended symbol is defined in"
125 );
126 if let Some(file) = sample_file {
127 msg.push_str(&format!(" (e.g., `--in {file}`)"));
128 }
129 msg
130}
131
132pub(crate) fn emit_symbol_not_found(
135 streams: &mut OutputStreams,
136 name: &str,
137 json_output: bool,
138) -> i32 {
139 let message = format!("Symbol '{name}' not found in graph");
140 if json_output {
141 let envelope = serde_json::json!({
142 "error": {
143 "code": SYMBOL_NOT_FOUND_ERROR_CODE,
144 "message": message,
145 }
146 });
147 let json = serde_json::to_string_pretty(&envelope)
148 .unwrap_or_else(|_| format!("{{\"error\":{{\"code\":\"{SYMBOL_NOT_FOUND_ERROR_CODE}\",\"message\":\"{name}\"}}}}"));
149 let _ = streams.write_result(&json);
150 } else {
151 let _ = streams.write_diagnostic(&format!("Error: {message}."));
152 }
153 SYMBOL_NOT_FOUND_EXIT_CODE
154}
155
156#[derive(Debug, Serialize)]
158struct ImpactOutput {
159 symbol: String,
161 direct: Vec<ImpactSymbol>,
163 #[serde(skip_serializing_if = "Vec::is_empty")]
165 indirect: Vec<ImpactSymbol>,
166 #[serde(skip_serializing_if = "Vec::is_empty")]
168 affected_files: Vec<String>,
169 stats: ImpactStats,
171}
172
173#[derive(Debug, Serialize)]
174struct ImpactSymbol {
175 name: String,
176 qualified_name: String,
177 kind: String,
178 file: String,
179 line: u32,
180 relation: String,
182 depth: usize,
184}
185
186#[derive(Debug, Serialize)]
187struct ImpactStats {
188 direct_count: usize,
189 indirect_count: usize,
190 total_affected: usize,
191 affected_files_count: usize,
192 max_depth: usize,
193}
194
195struct BfsResult {
197 visited: HashSet<NodeId>,
198 node_depths: HashMap<NodeId, usize>,
199 node_relations: HashMap<NodeId, String>,
200 max_depth_reached: usize,
201}
202
203fn collect_dependents_bfs(
227 graph: &sqry_core::graph::unified::concurrent::CodeGraph,
228 target_node_id: NodeId,
229 effective_max_depth: usize,
230) -> BfsResult {
231 let snapshot = graph.snapshot();
232
233 let config = TraversalConfig {
234 direction: TraversalDirection::Incoming,
235 edge_filter: EdgeFilter::dependency_edges(),
236 limits: TraversalLimits {
237 max_depth: u32::try_from(effective_max_depth).unwrap_or(u32::MAX),
238 max_nodes: None,
239 max_edges: None,
240 max_paths: None,
241 },
242 };
243
244 let result = traverse(&snapshot, &[target_node_id], &config, None);
245
246 let mut visited: HashSet<NodeId> = HashSet::new();
247 let mut node_depths: HashMap<NodeId, usize> = HashMap::new();
248 let mut node_relations: HashMap<NodeId, String> = HashMap::new();
249 let mut actual_max_depth: usize = 0;
250
251 for (idx, mat_node) in result.nodes.iter().enumerate() {
252 if mat_node.node_id == target_node_id {
254 continue;
255 }
256
257 visited.insert(mat_node.node_id);
258
259 let depth = result
261 .edges
262 .iter()
263 .filter(|e| e.source_idx == idx || e.target_idx == idx)
264 .map(|e| e.depth as usize)
265 .min()
266 .unwrap_or(1);
267
268 node_depths.insert(mat_node.node_id, depth);
269 actual_max_depth = actual_max_depth.max(depth);
270
271 let relation = result
273 .edges
274 .iter()
275 .find(|e| e.source_idx == idx || e.target_idx == idx)
276 .map(|e| classify_relation(&e.classification))
277 .unwrap_or_default();
278
279 node_relations.insert(mat_node.node_id, relation);
280 }
281
282 BfsResult {
283 visited,
284 node_depths,
285 node_relations,
286 max_depth_reached: actual_max_depth,
287 }
288}
289
290#[allow(clippy::trivially_copy_pass_by_ref)] fn classify_relation(classification: &EdgeClassification) -> String {
293 match classification {
294 EdgeClassification::Call { .. } => "calls".to_string(),
295 EdgeClassification::Import { .. } => "imports".to_string(),
296 EdgeClassification::Reference => "references".to_string(),
297 EdgeClassification::Inherits => "inherits".to_string(),
298 EdgeClassification::Implements => "implements".to_string(),
299 EdgeClassification::Export { .. } => "exports".to_string(),
300 EdgeClassification::Contains => "contains".to_string(),
301 EdgeClassification::Defines => "defines".to_string(),
302 EdgeClassification::TypeOf => "type_of".to_string(),
303 EdgeClassification::DatabaseAccess => "database_access".to_string(),
304 EdgeClassification::ServiceInteraction => "service_interaction".to_string(),
305 }
306}
307
308struct CategorizedImpact {
310 direct: Vec<ImpactSymbol>,
311 indirect: Vec<ImpactSymbol>,
312 affected_files: HashSet<String>,
313}
314
315fn build_impact_symbols(
317 graph: &sqry_core::graph::unified::concurrent::CodeGraph,
318 bfs: &BfsResult,
319 include_indirect: bool,
320 include_files: bool,
321) -> CategorizedImpact {
322 let strings = graph.strings();
323 let files = graph.files();
324 let mut direct: Vec<ImpactSymbol> = Vec::new();
325 let mut indirect: Vec<ImpactSymbol> = Vec::new();
326 let mut affected_files: HashSet<String> = HashSet::new();
327
328 for &node_id in &bfs.visited {
329 if let Some(entry) = graph.nodes().get(node_id) {
330 let depth = *bfs.node_depths.get(&node_id).unwrap_or(&0);
331 let relation = bfs
332 .node_relations
333 .get(&node_id)
334 .cloned()
335 .unwrap_or_default();
336
337 let name = strings
338 .resolve(entry.name)
339 .map(|s| s.to_string())
340 .unwrap_or_default();
341 let qualified_name = entry
342 .qualified_name
343 .and_then(|id| strings.resolve(id))
344 .map_or_else(|| name.clone(), |s| s.to_string());
345
346 let file_path = files
347 .resolve(entry.file)
348 .map(|p| p.display().to_string())
349 .unwrap_or_default();
350
351 let impact_sym = ImpactSymbol {
352 name,
353 qualified_name,
354 kind: format!("{:?}", entry.kind),
355 file: file_path.clone(),
356 line: entry.start_line,
357 relation,
358 depth,
359 };
360
361 if include_files {
362 affected_files.insert(file_path);
363 }
364
365 if depth == 1 {
366 direct.push(impact_sym);
367 } else if include_indirect {
368 indirect.push(impact_sym);
369 }
370 }
371 }
372
373 CategorizedImpact {
374 direct,
375 indirect,
376 affected_files,
377 }
378}
379
380pub fn run_impact(
390 cli: &Cli,
391 symbol: &str,
392 path: Option<&str>,
393 in_file: Option<&str>,
394 max_depth: usize,
395 max_results: usize,
396 include_indirect: bool,
397 include_files: bool,
398) -> Result<()> {
399 let mut streams = OutputStreams::new();
400
401 let search_path = path.map_or_else(
403 || std::env::current_dir().unwrap_or_default(),
404 std::path::PathBuf::from,
405 );
406
407 let index_location = find_nearest_index(&search_path);
408 let Some(ref loc) = index_location else {
409 streams
410 .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
411 return Ok(());
412 };
413
414 let config = GraphLoadConfig::default();
416 let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli)
417 .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
418
419 let snapshot = graph.snapshot();
433 let in_file_path = in_file.map(std::path::PathBuf::from);
434 let file_scope = in_file_path
435 .as_deref()
436 .map_or(FileScope::Any, FileScope::Path);
437 let target_node_id = match snapshot.resolve_global_symbol_ambiguity_aware(symbol, file_scope) {
438 Ok(node_id) => node_id,
439 Err(SymbolResolveError::Ambiguous(err)) => {
440 let exit_code = emit_ambiguous_symbol_error(&mut streams, &err, cli.json);
441 std::process::exit(exit_code);
442 }
443 Err(SymbolResolveError::NotFound { name }) => {
444 if let Some(path) = in_file {
445 let _ = streams.write_diagnostic(&format!(
446 "Error: No definition of '{name}' found in file '{path}'."
447 ));
448 std::process::exit(SYMBOL_NOT_FOUND_EXIT_CODE);
449 }
450 let exit_code = emit_symbol_not_found(&mut streams, &name, cli.json);
451 std::process::exit(exit_code);
452 }
453 };
454
455 let effective_max_depth = if include_indirect { max_depth } else { 1 };
457 let bfs = collect_dependents_bfs(&graph, target_node_id, effective_max_depth);
458
459 let mut impact = build_impact_symbols(&graph, &bfs, include_indirect, include_files);
461
462 impact
464 .direct
465 .sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
466 impact.indirect.sort_by(|a, b| {
467 a.depth
468 .cmp(&b.depth)
469 .then(a.qualified_name.cmp(&b.qualified_name))
470 });
471
472 impact.direct.truncate(max_results);
474 impact
475 .indirect
476 .truncate(max_results.saturating_sub(impact.direct.len()));
477
478 let mut files_vec: Vec<String> = impact.affected_files.into_iter().collect();
479 files_vec.sort();
480
481 let stats = ImpactStats {
482 direct_count: impact.direct.len(),
483 indirect_count: impact.indirect.len(),
484 total_affected: impact.direct.len() + impact.indirect.len(),
485 affected_files_count: files_vec.len(),
486 max_depth: bfs.max_depth_reached,
487 };
488
489 let output = ImpactOutput {
490 symbol: symbol.to_string(),
491 direct: impact.direct,
492 indirect: impact.indirect,
493 affected_files: if include_files { files_vec } else { Vec::new() },
494 stats,
495 };
496
497 if cli.json {
499 let json = serde_json::to_string_pretty(&output).context("Failed to serialize to JSON")?;
500 streams.write_result(&json)?;
501 } else {
502 let text = format_impact_text(&output);
503 streams.write_result(&text)?;
504 }
505
506 Ok(())
507}
508
509fn format_direct_dependents(lines: &mut Vec<String>, direct: &[ImpactSymbol]) {
511 if !direct.is_empty() {
512 lines.push("Direct dependents:".to_string());
513 for sym in direct {
514 lines.push(format!(
515 " {} [{}] ({} this)",
516 sym.qualified_name, sym.kind, sym.relation
517 ));
518 lines.push(format!(" {}:{}", sym.file, sym.line));
519 }
520 }
521}
522
523fn format_indirect_dependents(lines: &mut Vec<String>, indirect: &[ImpactSymbol]) {
525 if !indirect.is_empty() {
526 lines.push(String::new());
527 lines.push("Indirect dependents:".to_string());
528 for sym in indirect {
529 lines.push(format!(
530 " {} [{}] depth={} ({} chain)",
531 sym.qualified_name, sym.kind, sym.depth, sym.relation
532 ));
533 lines.push(format!(" {}:{}", sym.file, sym.line));
534 }
535 }
536}
537
538fn format_impact_text(output: &ImpactOutput) -> String {
539 let mut lines = Vec::new();
540
541 lines.push(format!("Impact analysis for: {}", output.symbol));
542 lines.push(format!(
543 "Total affected: {} ({} direct, {} indirect)",
544 output.stats.total_affected, output.stats.direct_count, output.stats.indirect_count
545 ));
546 if output.stats.affected_files_count > 0 {
547 lines.push(format!(
548 "Affected files: {}",
549 output.stats.affected_files_count
550 ));
551 }
552 lines.push(String::new());
553
554 if output.direct.is_empty() && output.indirect.is_empty() {
555 lines.push("No dependents found. This symbol appears to be unused.".to_string());
556 } else {
557 format_direct_dependents(&mut lines, &output.direct);
558 format_indirect_dependents(&mut lines, &output.indirect);
559 }
560
561 if !output.affected_files.is_empty() {
562 lines.push(String::new());
563 lines.push("Affected files:".to_string());
564 for file in &output.affected_files {
565 lines.push(format!(" {file}"));
566 }
567 }
568
569 lines.join("\n")
570}