use std::collections::{HashSet, VecDeque};
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
use anyhow::{Result, anyhow};
use sqry_core::graph::unified::resolution::{AmbiguousSymbolError, SymbolResolveError};
use sqry_core::graph::unified::{FileScope, ResolutionMode, SymbolCandidateOutcome, SymbolQuery};
pub const MCP_AMBIGUOUS_SYMBOL_ERROR_CODE: &str = "sqry::ambiguous_symbol";
#[must_use]
pub fn ambiguous_symbol_envelope_json(err: &AmbiguousSymbolError) -> String {
let envelope = serde_json::json!({
"error": {
"code": MCP_AMBIGUOUS_SYMBOL_ERROR_CODE,
"message": format!(
"Symbol '{}' is ambiguous; specify the qualified name",
err.name
),
"candidates": err.candidates,
"truncated": err.truncated,
}
});
serde_json::to_string(&envelope).unwrap_or_else(|_| {
format!(
"{{\"error\":{{\"code\":\"{}\",\"message\":\"Symbol '{}' is ambiguous\"}}}}",
MCP_AMBIGUOUS_SYMBOL_ERROR_CODE, err.name
)
})
}
use crate::engine::{Engine, canonicalize_in_workspace, engine_for_workspace};
use crate::tools::{CrossLanguageEdgesArgs, DependencyImpactArgs, SemanticDiffArgs};
use crate::execution::git_worktree;
use crate::execution::graph_builders::build_graph_metadata;
use crate::execution::location::node_location_for_reporting;
use crate::execution::types::{
CrossLanguageEdgesData, DependencyImpactData, FindUnusedData, ImpactedSymbol, NodeChange,
NodeRefData, PositionData, RangeData, RelationEdgeData, SemanticDiffData, ToolExecution,
UnusedSymbolData,
};
use crate::execution::utils::{duration_to_ms, paginate};
fn resolve_workspace_path(path: &str) -> Option<std::path::PathBuf> {
if path == "." {
None
} else {
Some(std::path::PathBuf::from(path))
}
}
fn resolve_global_symbol_strict(
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
symbol: &str,
file_path: Option<&Path>,
) -> Result<sqry_core::graph::unified::node::NodeId> {
let file_scope = match file_path {
Some(path) => FileScope::Path(path),
None => FileScope::Any,
};
match snapshot.resolve_global_symbol_ambiguity_aware(symbol, file_scope) {
Ok(node_id) => Ok(node_id),
Err(SymbolResolveError::NotFound { .. }) => {
if let Some(path) = file_path {
Err(anyhow!(
"No definition of '{}' found in file '{}'.",
symbol,
path.display()
))
} else {
Err(anyhow!("Symbol '{symbol}' not found in graph."))
}
}
Err(SymbolResolveError::Ambiguous(err)) => {
Err(anyhow!("{}", ambiguous_symbol_envelope_json(&err)))
}
}
}
fn candidate_bucket_for_symbol(
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
symbol: &str,
) -> Vec<sqry_core::graph::unified::node::NodeId> {
match snapshot.find_symbol_candidates(&SymbolQuery {
symbol,
file_scope: FileScope::Any,
mode: ResolutionMode::AllowSuffixCandidates,
}) {
SymbolCandidateOutcome::Candidates(candidates) => candidates,
SymbolCandidateOutcome::NotFound | SymbolCandidateOutcome::FileNotIndexed => Vec::new(),
}
}
fn cycle_type_label(cycle_type: CycleType) -> &'static str {
match cycle_type {
CycleType::Calls => "calls",
CycleType::Imports => "imports",
CycleType::Modules => "modules",
}
}
fn mcp_cycle_type_to_core(cycle_type: CycleType) -> CircularType {
match cycle_type {
CycleType::Calls => CircularType::Calls,
CycleType::Imports => CircularType::Imports,
CycleType::Modules => CircularType::Modules,
}
}
fn cycle_bounds_for(
min_depth: usize,
max_depth: Option<usize>,
max_results: usize,
include_self_loops: bool,
) -> sqry_db::queries::CycleBounds {
sqry_db::queries::CycleBounds {
min_depth,
max_depth,
max_results,
should_include_self_loops: include_self_loops,
}
}
fn materialize_cycle_node_ids(
cycles: &[Vec<sqry_core::graph::unified::node::NodeId>],
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
) -> Vec<Vec<String>> {
let strings = snapshot.strings();
cycles
.iter()
.map(|cycle| {
cycle
.iter()
.filter_map(|&node_id| {
snapshot.get_node(node_id).and_then(|entry| {
entry
.qualified_name
.and_then(|sid| strings.resolve(sid))
.or_else(|| strings.resolve(entry.name))
.map(|s| s.to_string())
})
})
.collect()
})
.filter(|cycle: &Vec<String>| !cycle.is_empty())
.collect()
}
fn should_include_in_unused_results(
entry: &sqry_core::graph::unified::storage::arena::NodeEntry,
args: &FindUnusedArgs,
strings: &sqry_core::graph::unified::storage::StringInterner,
files: &sqry_core::graph::unified::storage::registry::FileRegistry,
) -> bool {
let visibility_str = entry
.visibility
.and_then(|vid| strings.resolve(vid))
.map(|s| s.to_string());
if !matches_scope_filter(entry.kind, visibility_str.as_deref(), args.scope) {
return false;
}
if !args.languages.is_empty() {
if let Some(lang) = files.language_for_file(entry.file) {
let lang_str = lang.to_string();
if !args
.languages
.iter()
.any(|l| l.eq_ignore_ascii_case(&lang_str))
{
return false;
}
} else {
return false;
}
}
if !args.kinds.is_empty() {
let kind_str = format!("{:?}", entry.kind).to_lowercase();
if !args.kinds.iter().any(|k| k.eq_ignore_ascii_case(&kind_str)) {
return false;
}
}
true
}
fn build_unused_symbol_data(
entry: &sqry_core::graph::unified::storage::arena::NodeEntry,
node_id: sqry_core::graph::unified::node::NodeId,
graph: &sqry_core::graph::unified::concurrent::CodeGraph,
strings: &sqry_core::graph::unified::storage::StringInterner,
files: &sqry_core::graph::unified::storage::registry::FileRegistry,
workspace_root: &std::path::Path,
) -> UnusedSymbolData {
let name = strings
.resolve(entry.name)
.map_or_else(String::new, |s| s.to_string());
let language = files.language_for_file(entry.file);
let qualified_name =
crate::execution::symbol_utils::display_entry_qualified_name(entry, strings, files, &name);
let file_path = files.resolve(entry.file);
let full_path = file_path
.as_ref()
.map(|p| workspace_root.join(p.as_ref()))
.unwrap_or_default();
let file_uri = url::Url::from_file_path(&full_path).ok().map_or_else(
|| crate::execution::symbol_utils::path_to_forward_slash(&full_path),
Into::into,
);
let language = language.map_or("unknown".to_string(), |l| l.to_string());
let kind = format!("{:?}", entry.kind).to_lowercase();
let visibility = format!("{:?}", entry.visibility).to_lowercase();
let loc = node_location_for_reporting(graph, node_id, workspace_root);
UnusedSymbolData {
name,
qualified_name,
kind,
file_uri,
line: loc.as_ref().map_or(entry.start_line, |l| l.line),
language,
visibility,
}
}
fn matches_scope_filter(
kind: sqry_core::graph::unified::node::NodeKind,
visibility_str: Option<&str>,
scope: UnusedScope,
) -> bool {
use sqry_core::graph::unified::node::NodeKind;
match scope {
UnusedScope::Public => visibility_str.is_some_and(|v| v.eq_ignore_ascii_case("public")),
UnusedScope::Private => visibility_str.is_some_and(|v| v.eq_ignore_ascii_case("private")),
UnusedScope::Function => matches!(kind, NodeKind::Function | NodeKind::Method),
UnusedScope::Struct => matches!(
kind,
NodeKind::Struct | NodeKind::Class | NodeKind::Interface | NodeKind::Trait
),
UnusedScope::All => true,
}
}
pub fn execute_cross_language_edges(
args: &CrossLanguageEdgesArgs,
) -> Result<ToolExecution<CrossLanguageEdgesData>> {
let start = Instant::now();
let workspace_path = resolve_workspace_path(&args.path);
let engine = engine_for_workspace(workspace_path.as_ref())?;
let workspace_root = engine.workspace_root().to_path_buf();
let _base = canonicalize_in_workspace(&args.path, &workspace_root)?;
let graph = engine.ensure_graph()?;
let snapshot = graph.snapshot();
let files = snapshot.files();
let strings = snapshot.strings();
let mut edges: Vec<RelationEdgeData> = Vec::new();
for (source_id, target_id, edge_kind) in snapshot.iter_edges() {
let Some(source_node) = snapshot.get_node(source_id) else {
continue;
};
let Some(target_node) = snapshot.get_node(target_id) else {
continue;
};
let source_lang = files.language_for_file(source_node.file);
let target_lang = files.language_for_file(target_node.file);
let (Some(from_lang), Some(to_lang)) = (source_lang, target_lang) else {
continue;
};
if from_lang == to_lang {
continue;
}
if let Some(ref fl) = args.from_lang
&& !from_lang.to_string().eq_ignore_ascii_case(fl)
{
continue;
}
if let Some(ref tl) = args.to_lang
&& !to_lang.to_string().eq_ignore_ascii_case(tl)
{
continue;
}
let from_ref = build_node_ref_from_node(
source_node,
source_id,
&graph,
from_lang,
files,
strings,
&workspace_root,
);
let to_ref = build_node_ref_from_node(
target_node,
target_id,
&graph,
to_lang,
files,
strings,
&workspace_root,
);
edges.push(RelationEdgeData {
from: Some(from_ref),
to: Some(to_ref),
relation_type: format!("{edge_kind:?}").to_lowercase(),
depth: 1,
metadata: None,
});
if edges.len() >= args.max_results {
break;
}
}
let total = edges.len();
let (page_slice, next_page_token) = paginate(&edges, &args.pagination);
let page_edges = page_slice.to_vec();
let graph_metadata = build_graph_metadata(Some(&workspace_root), Some(&snapshot), None);
Ok(ToolExecution {
data: CrossLanguageEdgesData {
edges: page_edges,
total: total as u64,
},
used_index: false,
used_graph: true,
graph_metadata: Some(graph_metadata),
execution_ms: duration_to_ms(start.elapsed()),
next_page_token,
total: Some(total as u64),
truncated: Some(total > args.max_results),
candidates_scanned: None,
workspace_path: crate::execution::symbol_utils::path_to_forward_slash(workspace_root),
})
}
fn build_node_ref_from_node(
node: &sqry_core::graph::unified::storage::arena::NodeEntry,
node_id: sqry_core::graph::unified::node::NodeId,
graph: &sqry_core::graph::unified::concurrent::CodeGraph,
language: sqry_core::graph::Language,
files: &sqry_core::graph::unified::storage::FileRegistry,
strings: &sqry_core::graph::unified::storage::StringInterner,
workspace_root: &std::path::Path,
) -> NodeRefData {
use sqry_core::graph::unified::node::NodeKind;
let name = strings
.resolve(node.name)
.map(|s| s.to_string())
.unwrap_or_default();
let qualified_name =
crate::execution::symbol_utils::display_entry_qualified_name(node, strings, files, &name);
let kind = match node.kind {
NodeKind::Class => "class",
NodeKind::Module => "module",
NodeKind::Variable => "variable",
NodeKind::Constant => "constant",
NodeKind::Interface => "interface",
NodeKind::Trait => "trait",
NodeKind::Method => "method",
NodeKind::Struct => "struct",
NodeKind::Enum => "enum",
NodeKind::Type => "type",
_ => "function",
};
let file_path = files
.resolve(node.file)
.map(|arc_path| workspace_root.join(arc_path.as_ref()))
.unwrap_or_default();
let file_uri = url::Url::from_file_path(&file_path).ok().map_or_else(
|| crate::execution::symbol_utils::path_to_forward_slash(&file_path),
Into::into,
);
let loc = node_location_for_reporting(graph, node_id, workspace_root);
let resolution_source = loc.as_ref().map(|l| format!("{:?}", l.resolution_source));
NodeRefData {
name,
qualified_name,
kind: kind.to_string(),
language: language.to_string(),
file_uri,
range: RangeData {
start: PositionData {
line: loc.as_ref().map_or(node.start_line, |l| l.line),
character: loc.as_ref().map_or(node.start_column, |l| l.column),
},
end: PositionData {
line: loc.as_ref().map_or(node.end_line, |l| l.end_line),
character: loc.as_ref().map_or(node.end_column, |l| l.end_column),
},
},
metadata: None,
resolution_source,
}
}
fn build_impact_node_ref(
entry: &sqry_core::graph::unified::storage::arena::NodeEntry,
node_id: sqry_core::graph::unified::node::NodeId,
graph: &sqry_core::graph::unified::concurrent::CodeGraph,
strings: &sqry_core::graph::unified::storage::StringInterner,
files: &sqry_core::graph::unified::storage::registry::FileRegistry,
workspace_root: &std::path::Path,
) -> NodeRefData {
let name = strings
.resolve(entry.name)
.map_or_else(String::new, |s| s.to_string());
let qualified_name =
crate::execution::symbol_utils::display_entry_qualified_name(entry, strings, files, &name);
let file_path = files.resolve(entry.file);
let full_path = file_path
.as_ref()
.map(|p| workspace_root.join(p.as_ref()))
.unwrap_or_default();
let file_uri = url::Url::from_file_path(&full_path).ok().map_or_else(
|| crate::execution::symbol_utils::path_to_forward_slash(&full_path),
Into::into,
);
let language = files
.language_for_file(entry.file)
.map_or("unknown".to_string(), |l| l.to_string());
let kind = format!("{:?}", entry.kind).to_lowercase();
let loc = node_location_for_reporting(graph, node_id, workspace_root);
let resolution_source = loc.as_ref().map(|l| format!("{:?}", l.resolution_source));
NodeRefData {
name,
qualified_name,
kind,
language,
file_uri,
range: RangeData {
start: PositionData {
line: loc.as_ref().map_or(entry.start_line, |l| l.line),
character: loc.as_ref().map_or(entry.start_column, |l| l.column),
},
end: PositionData {
line: loc.as_ref().map_or(entry.end_line, |l| l.end_line),
character: loc.as_ref().map_or(entry.end_column, |l| l.end_column),
},
},
metadata: None,
resolution_source,
}
}
fn process_caller_node(
entry: &sqry_core::graph::unified::NodeEntry,
node_id: sqry_core::graph::unified::node::NodeId,
graph: &sqry_core::graph::unified::concurrent::CodeGraph,
strings: &sqry_core::graph::unified::storage::StringInterner,
files: &sqry_core::graph::unified::storage::registry::FileRegistry,
workspace_root: &std::path::Path,
depth: usize,
_args: &DependencyImpactArgs,
) -> (ImpactedSymbol, String) {
let node_ref = build_impact_node_ref(entry, node_id, graph, strings, files, workspace_root);
let file_uri = node_ref.file_uri.clone();
let symbol = ImpactedSymbol {
symbol: node_ref,
depth: u32::try_from(depth + 1).unwrap_or(u32::MAX),
impact_type: "caller".to_string(),
};
(symbol, file_uri)
}
fn collect_impacted_callers_bfs(
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
graph: &sqry_core::graph::unified::concurrent::CodeGraph,
target_node_id: sqry_core::graph::unified::node::NodeId,
args: &DependencyImpactArgs,
workspace_root: &std::path::Path,
) -> (Vec<ImpactedSymbol>, HashSet<String>) {
let strings = snapshot.strings();
let files = snapshot.files();
let mut impacted: Vec<ImpactedSymbol> = Vec::new();
let mut affected_files: HashSet<String> = HashSet::new();
let mut queue: VecDeque<(sqry_core::graph::unified::node::NodeId, usize)> = VecDeque::new();
let mut visited: HashSet<sqry_core::graph::unified::node::NodeId> = HashSet::new();
queue.push_back((target_node_id, 0));
visited.insert(target_node_id);
while let Some((current_id, depth)) = queue.pop_front() {
if depth >= args.max_depth {
continue;
}
let callers = snapshot.get_callers(current_id);
for caller_id in callers {
if visited.contains(&caller_id) {
continue;
}
let Some(entry) = snapshot.get_node(caller_id) else {
continue;
};
let (symbol, file_uri) = process_caller_node(
entry,
caller_id,
graph,
strings,
files,
workspace_root,
depth,
args,
);
if args.include_files {
affected_files.insert(file_uri);
}
impacted.push(symbol);
if args.include_indirect {
visited.insert(caller_id);
queue.push_back((caller_id, depth + 1));
}
}
}
(impacted, affected_files)
}
pub fn execute_dependency_impact(
args: &DependencyImpactArgs,
) -> Result<ToolExecution<DependencyImpactData>> {
let start = Instant::now();
let workspace_path = resolve_workspace_path(&args.path);
let engine = engine_for_workspace(workspace_path.as_ref())?;
let workspace_root = engine.workspace_root().to_path_buf();
let _search_root = canonicalize_in_workspace(&args.path, &workspace_root)?;
tracing::debug!(
symbol = %args.symbol,
max_depth = args.max_depth,
include_files = args.include_files,
include_indirect = args.include_indirect,
"Executing dependency_impact tool"
);
let graph = engine.ensure_graph()?;
let ctx = crate::daemon_adapter::WorkspaceContext {
workspace_root,
graph,
executor: engine.executor_arc(),
};
inner::execute_dependency_impact(&ctx, args, start)
}
pub fn execute_semantic_diff(args: &SemanticDiffArgs) -> Result<ToolExecution<SemanticDiffData>> {
let start = Instant::now();
let workspace_path = resolve_workspace_path(&args.path);
let engine = engine_for_workspace(workspace_path.as_ref())?;
let workspace_root = engine.workspace_root().to_path_buf();
let graph = engine.ensure_graph()?;
let ctx = crate::daemon_adapter::WorkspaceContext {
workspace_root,
graph,
executor: engine.executor_arc(),
};
inner::execute_semantic_diff(&ctx, args, start)
}
fn convert_db_change_to_wire(
db_change: sqry_db::NodeChange,
workspace_root: &std::path::Path,
base_worktree: &std::path::Path,
target_worktree: &std::path::Path,
) -> Result<NodeChange> {
let sqry_db::NodeChange {
symbol_name,
qualified_name,
kind,
change_type,
base_location,
target_location,
signature_before,
signature_after,
} = db_change;
let base_wire = base_location
.map(|loc| {
db_location_to_ref(
&loc,
workspace_root,
base_worktree,
&symbol_name,
&qualified_name,
&kind,
)
})
.transpose()?;
let target_wire = target_location
.map(|loc| {
db_location_to_ref(
&loc,
workspace_root,
target_worktree,
&symbol_name,
&qualified_name,
&kind,
)
})
.transpose()?;
Ok(NodeChange {
symbol_name,
qualified_name,
kind,
change_type: change_type.as_str().to_string(),
base_location: base_wire,
target_location: target_wire,
signature_before,
signature_after,
})
}
fn db_location_to_ref(
loc: &sqry_db::NodeLocation,
workspace_root: &std::path::Path,
worktree_root: &std::path::Path,
symbol_name: &str,
qualified_name: &str,
kind: &str,
) -> Result<NodeRefData> {
let real_path = if let Ok(relative) = loc.file_path.strip_prefix(worktree_root) {
workspace_root.join(relative)
} else {
tracing::trace!(
path = %loc.file_path.display(),
"Worktree path did not match expected root; using as-is"
);
loc.file_path.clone()
};
let file_uri = url::Url::from_file_path(&real_path)
.map_err(|()| anyhow!("Invalid file path: {}", real_path.display()))?
.to_string();
Ok(NodeRefData {
name: symbol_name.to_string(),
qualified_name: qualified_name.to_string(),
kind: kind.to_string(),
language: loc.language.clone(),
file_uri,
range: RangeData {
start: PositionData {
line: loc.start_line.saturating_sub(1),
character: 0,
},
end: PositionData {
line: loc.end_line.saturating_sub(1),
character: 0,
},
},
metadata: None,
resolution_source: None,
})
}
fn summarise_wire_changes(changes: &[NodeChange]) -> crate::execution::types::DiffSummary {
let mut summary = crate::execution::types::DiffSummary {
added: 0,
removed: 0,
modified: 0,
renamed: 0,
signature_changed: 0,
unchanged: 0,
};
for change in changes {
match change.change_type.as_str() {
"added" => summary.added += 1,
"removed" => summary.removed += 1,
"modified" => summary.modified += 1,
"renamed" => summary.renamed += 1,
"signature_changed" => summary.signature_changed += 1,
_ => summary.unchanged += 1,
}
}
summary
}
use crate::execution::types::{
CycleData, CycleNodeData, DuplicateGroupData, DuplicateSymbolData, FindCyclesData,
FindDuplicatesData,
};
use crate::tools::{
CycleType, DuplicateType, FindCyclesArgs, FindDuplicatesArgs, FindUnusedArgs, UnusedScope,
};
use sqry_core::query::DuplicateType as CoreDuplicateType;
use sqry_core::query::{CircularType, DuplicateConfig, build_duplicate_groups_graph};
fn convert_duplicate_groups(
groups: Vec<sqry_core::query::DuplicateGroup>,
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
graph: &sqry_core::graph::unified::concurrent::CodeGraph,
workspace_root: &std::path::Path,
) -> Vec<DuplicateGroupData> {
let strings = snapshot.strings();
let files = snapshot.files();
groups
.into_iter()
.filter(|g| g.total_members > 1)
.map(|group| {
let symbols: Vec<DuplicateSymbolData> = group
.node_ids
.iter()
.filter_map(|&node_id| {
let entry = snapshot.get_node(node_id)?;
let name = strings
.resolve(entry.name)
.map(|s| s.to_string())
.unwrap_or_default();
let language = files.language_for_file(entry.file);
let qualified_name =
crate::execution::symbol_utils::display_entry_qualified_name(
entry, strings, files, &name,
);
let file_path = files.resolve(entry.file)?;
let full_path = workspace_root.join(file_path.as_ref());
let file_uri = url::Url::from_file_path(&full_path).ok().map_or_else(
|| crate::execution::symbol_utils::path_to_forward_slash(&full_path),
Into::into,
);
let language = language.map_or("unknown".to_string(), |l| l.to_string());
let loc = node_location_for_reporting(graph, node_id, workspace_root);
Some(DuplicateSymbolData {
name,
qualified_name,
kind: format!("{:?}", entry.kind).to_lowercase(),
file_uri,
line: loc.as_ref().map_or(entry.start_line, |l| l.line),
language,
})
})
.collect();
let group_id = if let Some(body_hash) = group.body_hash_128 {
format!("{body_hash}") } else {
format!("{:016x}", group.hash)
};
DuplicateGroupData {
group_id,
count: symbols.len(),
total_members: group.total_members,
members_truncated: group.members_truncated,
symbols,
}
})
.filter(|g| g.total_members > 1)
.collect()
}
pub fn execute_find_duplicates(
args: &FindDuplicatesArgs,
) -> Result<ToolExecution<FindDuplicatesData>> {
let start = Instant::now();
let workspace_path = resolve_workspace_path(&args.path);
let engine = engine_for_workspace(workspace_path.as_ref())?;
let workspace_root = engine.workspace_root().to_path_buf();
let _search_root = canonicalize_in_workspace(&args.path, &workspace_root)?;
let graph = engine.ensure_graph()?;
let (core_dup_type, dup_type_str) = match args.duplicate_type {
DuplicateType::Body => (CoreDuplicateType::Body, "body"),
DuplicateType::Signature => (CoreDuplicateType::Signature, "signature"),
DuplicateType::Struct => (CoreDuplicateType::Struct, "struct"),
};
let config = DuplicateConfig {
threshold: if args.exact {
1.0
} else {
f64::from(args.threshold) / 100.0
},
max_results: args.max_results,
is_exact_only: args.exact || args.threshold >= 100,
max_members_per_group: args.max_members_per_group,
};
let groups = build_duplicate_groups_graph(core_dup_type, &graph, &config);
let snapshot = graph.snapshot();
let mut output_groups = convert_duplicate_groups(groups, &snapshot, &graph, &workspace_root);
output_groups.sort_by(|a, b| {
b.total_members
.cmp(&a.total_members)
.then_with(|| a.group_id.cmp(&b.group_id))
});
let total = output_groups.len();
let truncated = total > args.max_results;
output_groups.truncate(args.max_results);
let (page_slice, next_page_token) = paginate(&output_groups, &args.pagination);
let page_groups = page_slice.to_vec();
Ok(ToolExecution {
data: FindDuplicatesData {
duplicate_type: dup_type_str.to_string(),
threshold: args.threshold,
groups: page_groups,
total: total as u64,
},
used_index: false,
used_graph: true,
graph_metadata: None,
execution_ms: duration_to_ms(start.elapsed()),
next_page_token,
total: Some(total as u64),
truncated: Some(truncated),
candidates_scanned: None,
workspace_path: crate::execution::symbol_utils::path_to_forward_slash(workspace_root),
})
}
fn resolve_cycle_node(
name: &str,
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
graph: &sqry_core::graph::unified::concurrent::CodeGraph,
workspace_root: &std::path::Path,
) -> CycleNodeData {
let strings = snapshot.strings();
let files = snapshot.files();
let node_ids = candidate_bucket_for_symbol(snapshot, name);
if let Some(&node_id) = node_ids.first()
&& let Some(entry) = snapshot.get_node(node_id)
{
let node_name = strings
.resolve(entry.name)
.map_or_else(|| name.to_string(), |s| s.to_string());
let qualified_name = crate::execution::symbol_utils::display_entry_qualified_name(
entry, strings, files, &node_name,
);
let file_path = files
.resolve(entry.file)
.map(|p| workspace_root.join(p.as_ref()))
.unwrap_or_default();
let file_uri = url::Url::from_file_path(&file_path).ok().map_or_else(
|| crate::execution::symbol_utils::path_to_forward_slash(&file_path),
Into::into,
);
let loc = node_location_for_reporting(graph, node_id, workspace_root);
return CycleNodeData {
name: node_name,
qualified_name,
file_uri,
line: loc.as_ref().map_or(entry.start_line, |l| l.line),
};
}
CycleNodeData {
name: name.to_string(),
qualified_name: name.to_string(),
file_uri: String::new(),
line: 0,
}
}
fn convert_cycles_to_output(
cycles: Vec<Vec<String>>,
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
graph: &sqry_core::graph::unified::concurrent::CodeGraph,
workspace_root: &std::path::Path,
) -> Vec<CycleData> {
cycles
.into_iter()
.map(|cycle| {
let nodes: Vec<CycleNodeData> = cycle
.iter()
.map(|name| resolve_cycle_node(name, snapshot, graph, workspace_root))
.collect();
let chain = if cycle.is_empty() {
String::new()
} else {
let mut parts = cycle.clone();
parts.push(cycle[0].clone()); parts.join(" \u{2192} ")
};
CycleData {
depth: nodes.len(),
nodes,
chain,
}
})
.collect()
}
pub fn execute_find_cycles(args: &FindCyclesArgs) -> Result<ToolExecution<FindCyclesData>> {
let start = Instant::now();
let workspace_path = resolve_workspace_path(&args.path);
let engine = engine_for_workspace(workspace_path.as_ref())?;
let workspace_root = engine.workspace_root().to_path_buf();
let _search_root = canonicalize_in_workspace(&args.path, &workspace_root)?;
let graph = engine.ensure_graph()?;
let ctx = crate::daemon_adapter::WorkspaceContext {
workspace_root,
graph,
executor: engine.executor_arc(),
};
inner::execute_find_cycles(&ctx, args, start)
}
fn mcp_scope_to_core_superset(scope: UnusedScope) -> sqry_core::query::UnusedScope {
match scope {
UnusedScope::All => sqry_core::query::UnusedScope::All,
UnusedScope::Public => sqry_core::query::UnusedScope::Public,
UnusedScope::Private => sqry_core::query::UnusedScope::Private,
UnusedScope::Function => sqry_core::query::UnusedScope::Function,
UnusedScope::Struct => sqry_core::query::UnusedScope::All,
}
}
pub fn execute_find_unused(args: &FindUnusedArgs) -> Result<ToolExecution<FindUnusedData>> {
let start = Instant::now();
let workspace_path = resolve_workspace_path(&args.path);
let engine = engine_for_workspace(workspace_path.as_ref())?;
let workspace_root = engine.workspace_root().to_path_buf();
let _search_root = canonicalize_in_workspace(&args.path, &workspace_root)?;
let graph = engine.ensure_graph()?;
let ctx = crate::daemon_adapter::WorkspaceContext {
workspace_root,
graph,
executor: engine.executor_arc(),
};
inner::execute_find_unused(&ctx, args, start)
}
use crate::execution::types::{
CallerCalleeData, DirectCalleesData, DirectCallersData, NodeInCycleData, PatternMatchData,
PatternSearchData,
};
use crate::tools::{DirectCalleesArgs, DirectCallersArgs, IsNodeInCycleArgs, PatternSearchArgs};
pub fn execute_is_node_in_cycle(
args: &IsNodeInCycleArgs,
) -> Result<ToolExecution<NodeInCycleData>> {
let start = Instant::now();
let workspace_path = resolve_workspace_path(&args.path);
let engine = engine_for_workspace(workspace_path.as_ref())?;
let workspace_root = engine.workspace_root().to_path_buf();
let _search_root = canonicalize_in_workspace(&args.path, &workspace_root)?;
let graph = engine.ensure_graph()?;
let ctx = crate::daemon_adapter::WorkspaceContext {
workspace_root,
graph,
executor: engine.executor_arc(),
};
inner::execute_is_node_in_cycle(&ctx, args, start)
}
pub fn execute_pattern_search(
args: &PatternSearchArgs,
) -> Result<ToolExecution<PatternSearchData>> {
let start = Instant::now();
let workspace_path = resolve_workspace_path(&args.path);
let engine = engine_for_workspace(workspace_path.as_ref())?;
let workspace_root = engine.workspace_root().to_path_buf();
let _search_root = canonicalize_in_workspace(&args.path, &workspace_root)?;
let graph = engine.ensure_graph()?;
let snapshot = graph.snapshot();
let strings = snapshot.strings();
let files = snapshot.files();
let matching_ids = snapshot.find_by_pattern(&args.pattern);
let filtered_ids: Vec<_> = if args.include_classpath {
matching_ids
} else {
matching_ids
.into_iter()
.filter(|&node_id| {
!crate::execution::symbol_utils::is_node_external(&snapshot, node_id)
})
.collect()
};
let mut matches: Vec<PatternMatchData> = filtered_ids
.into_iter()
.filter_map(|node_id| {
let entry = snapshot.get_node(node_id)?;
let name = strings.resolve(entry.name)?.to_string();
let language = files.language_for_file(entry.file);
let qualified_name = crate::execution::symbol_utils::display_entry_qualified_name(
entry, strings, files, &name,
);
let file_path = files.resolve(entry.file)?;
let full_path = workspace_root.join(file_path.as_ref());
let file_uri = url::Url::from_file_path(&full_path).ok().map_or_else(
|| crate::execution::symbol_utils::path_to_forward_slash(&full_path),
Into::into,
);
let language = language.map_or("unknown".to_string(), |l| l.to_string());
let kind = format!("{:?}", entry.kind).to_lowercase();
let provenance = crate::execution::symbol_utils::get_classpath_provenance_for_node(
&snapshot, node_id,
);
let loc = node_location_for_reporting(&graph, node_id, &workspace_root);
Some(PatternMatchData {
name,
qualified_name,
kind,
file_uri,
line: loc.as_ref().map_or(entry.start_line, |l| l.line),
language,
provenance,
})
})
.collect();
let total = matches.len();
matches.truncate(args.max_results);
let (page_slice, next_page_token) = paginate(&matches, &args.pagination);
let page_matches = page_slice.to_vec();
Ok(ToolExecution {
data: PatternSearchData {
pattern: args.pattern.clone(),
matches: page_matches,
total: total as u64,
},
used_index: false,
used_graph: true,
graph_metadata: None,
execution_ms: duration_to_ms(start.elapsed()),
next_page_token,
total: Some(total as u64),
truncated: Some(total > args.max_results),
candidates_scanned: None,
workspace_path: crate::execution::symbol_utils::path_to_forward_slash(workspace_root),
})
}
fn find_similar_symbols(
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
query: &str,
max_suggestions: usize,
) -> Vec<String> {
let query_lower = query.to_lowercase();
let strings = snapshot.strings();
let mut suggestions = Vec::new();
let simple_query = query_lower
.rsplit("::")
.next()
.unwrap_or(&query_lower)
.split('.')
.next_back()
.unwrap_or(&query_lower);
for (_node_id, entry) in snapshot.nodes().iter() {
if entry.is_unified_loser() {
continue;
}
if suggestions.len() >= max_suggestions * 2 {
break;
}
let name = match entry
.qualified_name
.and_then(|id| strings.resolve(id))
.or_else(|| strings.resolve(entry.name))
{
Some(n) => n.to_string(),
None => continue,
};
let name_lower = name.to_lowercase();
let is_substring_match = name_lower.contains(simple_query)
|| (simple_query.contains(&name_lower) && name_lower.len() >= 3);
if is_substring_match && name != query {
suggestions.push(name);
}
}
suggestions.sort();
suggestions.dedup();
suggestions.truncate(max_suggestions);
suggestions
}
fn decorate_single_symbol_lookup_error(
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
symbol: &str,
error: anyhow::Error,
) -> anyhow::Error {
if !error.to_string().contains("not found in graph") {
return error;
}
let suggestions = find_similar_symbols(snapshot, symbol, 3);
let suggestions_str = if suggestions.is_empty() {
String::new()
} else {
format!(
"\n\nDid you mean one of these?\n • {}",
suggestions.join("\n • ")
)
};
anyhow!(
"Symbol '{symbol}' not found in graph.\n\n\
Hints:\n\
• Use the full qualified name (e.g., 'module::function_name')\n\
• Check for typos in the symbol name\n\
• Run 'semantic_search' to find the correct symbol name{suggestions_str}"
)
}
pub fn execute_direct_callers(
args: &DirectCallersArgs,
) -> Result<ToolExecution<DirectCallersData>> {
let start = Instant::now();
let workspace_path = resolve_workspace_path(&args.path);
let engine = engine_for_workspace(workspace_path.as_ref())?;
let workspace_root = engine.workspace_root().to_path_buf();
let _search_root = canonicalize_in_workspace(&args.path, &workspace_root)?;
let graph = engine.ensure_graph()?;
let ctx = crate::daemon_adapter::WorkspaceContext {
workspace_root,
graph,
executor: engine.executor_arc(),
};
inner::execute_direct_callers(&ctx, args, start)
}
pub fn execute_direct_callees(
args: &DirectCalleesArgs,
) -> Result<ToolExecution<DirectCalleesData>> {
let start = Instant::now();
let workspace_path = resolve_workspace_path(&args.path);
let engine = engine_for_workspace(workspace_path.as_ref())?;
let workspace_root = engine.workspace_root().to_path_buf();
let _search_root = canonicalize_in_workspace(&args.path, &workspace_root)?;
let graph = engine.ensure_graph()?;
let ctx = crate::daemon_adapter::WorkspaceContext {
workspace_root,
graph,
executor: engine.executor_arc(),
};
inner::execute_direct_callees(&ctx, args, start)
}
fn build_caller_callee_data(
graph: &sqry_core::graph::unified::concurrent::CodeGraph,
snapshot: &sqry_core::graph::unified::concurrent::GraphSnapshot,
node_ids: &[sqry_core::graph::unified::node::NodeId],
workspace_root: &std::path::Path,
) -> Vec<CallerCalleeData> {
let strings = snapshot.strings();
let files = snapshot.files();
node_ids
.iter()
.filter_map(|&node_id| {
let entry = snapshot.get_node(node_id)?;
let name = strings.resolve(entry.name)?.to_string();
let language = files.language_for_file(entry.file);
let qualified_name = crate::execution::symbol_utils::display_entry_qualified_name(
entry, strings, files, &name,
);
let loc = node_location_for_reporting(graph, node_id, workspace_root);
let file_path = loc
.as_ref()
.filter(|l| !l.file_path.is_empty())
.map(|l| workspace_root.join(&l.file_path))
.or_else(|| {
files
.resolve(entry.file)
.map(|p| workspace_root.join(p.as_ref()))
})
.unwrap_or_default();
let file_uri = url::Url::from_file_path(&file_path).ok().map_or_else(
|| crate::execution::symbol_utils::path_to_forward_slash(&file_path),
Into::into,
);
let language = loc
.as_ref()
.and_then(|l| l.language.clone())
.or_else(|| language.map(|l| l.to_string()))
.unwrap_or_else(|| "unknown".to_string());
let kind = format!("{:?}", entry.kind).to_lowercase();
Some(CallerCalleeData {
name,
qualified_name,
kind,
file_uri,
line: loc.as_ref().map_or(entry.start_line, |l| l.line),
language,
})
})
.collect()
}
pub(crate) mod inner {
use super::*;
pub(crate) fn execute_dependency_impact(
ctx: &crate::daemon_adapter::WorkspaceContext,
args: &DependencyImpactArgs,
start: Instant,
) -> Result<ToolExecution<DependencyImpactData>> {
let workspace_root: &std::path::Path = &ctx.workspace_root;
let graph = &ctx.graph;
let snapshot = graph.snapshot();
let target_node_id =
resolve_global_symbol_strict(&snapshot, &args.symbol, args.file_path.as_deref())?;
let (mut impacted, affected_files) =
collect_impacted_callers_bfs(&snapshot, graph, target_node_id, args, workspace_root);
let total = impacted.len();
let truncated = total > args.max_results;
impacted.truncate(args.max_results);
let (page_slice, next_page_token) = paginate(&impacted, &args.pagination);
let page_impacted: Vec<ImpactedSymbol> = page_slice.to_vec();
let affected_files_vec = if args.include_files {
let mut files_list: Vec<String> = affected_files.into_iter().collect();
files_list.sort();
Some(files_list)
} else {
None
};
let truncated_flag = truncated || next_page_token.is_some();
Ok(ToolExecution {
data: DependencyImpactData {
target_symbol: args.symbol.clone(),
impacted_symbols: page_impacted,
affected_files: affected_files_vec,
total: total as u64,
},
used_index: false,
used_graph: true,
graph_metadata: None,
execution_ms: duration_to_ms(start.elapsed()),
next_page_token,
total: Some(total as u64),
truncated: Some(truncated_flag),
candidates_scanned: None,
workspace_path: crate::execution::symbol_utils::path_to_forward_slash(workspace_root),
})
}
pub(crate) fn execute_semantic_diff(
ctx: &crate::daemon_adapter::WorkspaceContext,
args: &SemanticDiffArgs,
start: Instant,
) -> Result<ToolExecution<SemanticDiffData>> {
use sqry_core::graph::unified::build::{BuildConfig, build_unified_graph};
use sqry_db::{ComparativeQueryDb, DiffOptions};
let workspace_root: &std::path::Path = &ctx.workspace_root;
tracing::debug!(
base_ref = %args.base.git_ref,
target_ref = %args.target.git_ref,
include_unchanged = args.include_unchanged,
max_results = args.max_results,
"Executing semantic_diff tool"
);
let worktree_mgr = git_worktree::WorktreeManager::create(
workspace_root,
&args.base.git_ref,
&args.target.git_ref,
)?;
let plugins = Engine::plugin_manager();
let config = BuildConfig::default();
let base_graph = build_unified_graph(worktree_mgr.base_path(), &plugins, &config)
.map_err(|e| anyhow!("Failed to build base graph: {e}"))?;
let target_graph = build_unified_graph(worktree_mgr.target_path(), &plugins, &config)
.map_err(|e| anyhow!("Failed to build target graph: {e}"))?;
let base_snapshot = Arc::new(base_graph.snapshot());
let target_snapshot = Arc::new(target_graph.snapshot());
let cmp_db =
ComparativeQueryDb::new(Arc::clone(&base_snapshot), Arc::clone(&target_snapshot));
let diff_opts = DiffOptions {
old_worktree_path: worktree_mgr.base_path().to_path_buf(),
new_worktree_path: worktree_mgr.target_path().to_path_buf(),
};
let diff_output = cmp_db.diff(&diff_opts);
let worktree_base = worktree_mgr.base_path().to_path_buf();
let worktree_target = worktree_mgr.target_path().to_path_buf();
let mut changes: Vec<NodeChange> = diff_output
.changes
.into_iter()
.map(|c| convert_db_change_to_wire(c, workspace_root, &worktree_base, &worktree_target))
.collect::<Result<Vec<_>>>()?;
if !args.filters.change_types.is_empty() {
changes.retain(|change| {
args.filters
.change_types
.iter()
.any(|ct| ct.as_str() == change.change_type)
});
}
if !args.filters.symbol_kinds.is_empty() {
changes.retain(|change| {
args.filters
.symbol_kinds
.iter()
.any(|kind| kind.eq_ignore_ascii_case(&change.kind))
});
}
if !args.include_unchanged {
changes.retain(|change| change.change_type != "unchanged");
}
let summary = summarise_wire_changes(&changes);
let total = changes.len();
let truncated = total > args.max_results;
changes.truncate(args.max_results);
let (page_slice, next_page_token) = paginate(&changes, &args.pagination);
let page_changes = page_slice.to_vec();
let execution_ms = duration_to_ms(start.elapsed());
tracing::debug!(
total_changes = total,
page_size = page_changes.len(),
execution_ms = execution_ms,
"semantic_diff tool completed"
);
Ok(ToolExecution {
data: SemanticDiffData {
base_ref: args.base.git_ref.clone(),
target_ref: args.target.git_ref.clone(),
changes: page_changes,
summary,
total: total as u64,
},
used_index: false,
used_graph: true,
graph_metadata: None,
execution_ms,
next_page_token,
total: Some(total as u64),
truncated: Some(truncated),
candidates_scanned: None,
workspace_path: crate::execution::symbol_utils::path_to_forward_slash(workspace_root),
})
}
pub(crate) fn execute_find_cycles(
ctx: &crate::daemon_adapter::WorkspaceContext,
args: &FindCyclesArgs,
start: Instant,
) -> Result<ToolExecution<FindCyclesData>> {
let workspace_root: &std::path::Path = &ctx.workspace_root;
let graph = &ctx.graph;
let snapshot = Arc::new(graph.snapshot());
let db =
sqry_db::queries::dispatch::make_query_db_cold(Arc::clone(&snapshot), workspace_root);
let key = sqry_db::queries::CyclesKey {
circular_type: mcp_cycle_type_to_core(args.cycle_type),
bounds: cycle_bounds_for(
args.min_depth,
args.max_depth,
args.max_results,
args.include_self_loops,
),
};
let cycle_node_ids = db.get::<sqry_db::queries::CyclesQuery>(&key);
let cycles = materialize_cycle_node_ids(&cycle_node_ids, snapshot.as_ref());
let output_cycles =
convert_cycles_to_output(cycles, snapshot.as_ref(), graph, workspace_root);
let total = output_cycles.len();
let (page_slice, next_page_token) = paginate(&output_cycles, &args.pagination);
let page_cycles = page_slice.to_vec();
Ok(ToolExecution {
data: FindCyclesData {
cycle_type: cycle_type_label(args.cycle_type).to_string(),
cycles: page_cycles,
total: total as u64,
},
used_index: false,
used_graph: true,
graph_metadata: None,
execution_ms: duration_to_ms(start.elapsed()),
next_page_token,
total: Some(total as u64),
truncated: Some(total > args.max_results),
candidates_scanned: None,
workspace_path: crate::execution::symbol_utils::path_to_forward_slash(workspace_root),
})
}
#[allow(
clippy::unnecessary_wraps,
reason = "Shared tool-handler contract returns Result for uniform daemon and MCP dispatch."
)]
pub(crate) fn execute_find_unused(
ctx: &crate::daemon_adapter::WorkspaceContext,
args: &FindUnusedArgs,
start: Instant,
) -> Result<ToolExecution<FindUnusedData>> {
let workspace_root: &std::path::Path = &ctx.workspace_root;
let graph = &ctx.graph;
let snapshot = Arc::new(graph.snapshot());
let scope_str = match args.scope {
UnusedScope::Public => "public",
UnusedScope::Private => "private",
UnusedScope::Function => "function",
UnusedScope::Struct => "struct",
UnusedScope::All => "all",
};
let db =
sqry_db::queries::dispatch::make_query_db_cold(Arc::clone(&snapshot), workspace_root);
let node_count = snapshot.nodes().len();
let candidate_cap = node_count.max(args.max_results);
let key = sqry_db::queries::UnusedKey {
scope: mcp_scope_to_core_superset(args.scope),
max_results: candidate_cap,
};
let raw_unused_ids = db.get::<sqry_db::queries::UnusedQuery>(&key);
let unused_ids = sqry_db::queries::unused_post_filter::apply_binding_plane_post_filter(
&raw_unused_ids,
&snapshot,
&db,
);
let strings = snapshot.strings();
let files = snapshot.files();
let mut unused_symbols: Vec<UnusedSymbolData> = Vec::new();
for &node_id in &unused_ids {
if unused_symbols.len() >= args.max_results {
break;
}
let Some(entry) = snapshot.get_node(node_id) else {
continue;
};
if !should_include_in_unused_results(entry, args, strings, files) {
continue;
}
unused_symbols.push(build_unused_symbol_data(
entry,
node_id,
graph,
strings,
files,
workspace_root,
));
}
let total = unused_symbols.len();
let (page_slice, next_page_token) = paginate(&unused_symbols, &args.pagination);
Ok(ToolExecution {
data: FindUnusedData {
scope: scope_str.to_string(),
symbols: page_slice.to_vec(),
total: total as u64,
},
used_index: false,
used_graph: true,
graph_metadata: None,
execution_ms: duration_to_ms(start.elapsed()),
next_page_token,
total: Some(total as u64),
truncated: Some(total > args.max_results),
candidates_scanned: None,
workspace_path: crate::execution::symbol_utils::path_to_forward_slash(workspace_root),
})
}
pub(crate) fn execute_is_node_in_cycle(
ctx: &crate::daemon_adapter::WorkspaceContext,
args: &IsNodeInCycleArgs,
start: Instant,
) -> Result<ToolExecution<NodeInCycleData>> {
let workspace_root: &std::path::Path = &ctx.workspace_root;
let graph = &ctx.graph;
let snapshot = Arc::new(graph.snapshot());
let node_id = resolve_global_symbol_strict(
snapshot.as_ref(),
&args.symbol,
args.file_path.as_deref(),
)?;
let circular_type = mcp_cycle_type_to_core(args.cycle_type);
let predicate_bounds =
cycle_bounds_for(args.min_depth, args.max_depth, 100, args.include_self_loops);
let db =
sqry_db::queries::dispatch::make_query_db_cold(Arc::clone(&snapshot), workspace_root);
let in_cycle =
db.get::<sqry_db::queries::IsInCycleQuery>(&sqry_db::queries::IsInCycleKey {
node_id,
circular_type,
bounds: predicate_bounds,
});
let cycle = if in_cycle {
let cycle_lookup_bounds = cycle_bounds_for(
args.min_depth,
args.max_depth,
usize::MAX,
args.include_self_loops,
);
let cycles_key = sqry_db::queries::CyclesKey {
circular_type,
bounds: cycle_lookup_bounds,
};
let all_cycles = db.get::<sqry_db::queries::CyclesQuery>(&cycles_key);
all_cycles
.iter()
.find(|component| component.contains(&node_id))
.map(|component| {
materialize_cycle_node_ids(std::slice::from_ref(component), snapshot.as_ref())
.into_iter()
.next()
.unwrap_or_default()
})
} else {
None
};
let cycle_type_str = match args.cycle_type {
CycleType::Calls => "calls",
CycleType::Imports => "imports",
CycleType::Modules => "modules",
};
Ok(ToolExecution {
data: NodeInCycleData {
symbol: args.symbol.clone(),
in_cycle,
cycle_type: cycle_type_str.to_string(),
cycle,
},
used_index: false,
used_graph: true,
graph_metadata: None,
execution_ms: duration_to_ms(start.elapsed()),
next_page_token: None,
total: None,
truncated: None,
candidates_scanned: None,
workspace_path: crate::execution::symbol_utils::path_to_forward_slash(workspace_root),
})
}
pub(crate) fn execute_direct_callers(
ctx: &crate::daemon_adapter::WorkspaceContext,
args: &DirectCallersArgs,
start: Instant,
) -> Result<ToolExecution<DirectCallersData>> {
let workspace_root: &std::path::Path = &ctx.workspace_root;
let graph = &ctx.graph;
let snapshot = std::sync::Arc::new(graph.snapshot());
if sqry_core::graph::unified::materialize::find_nodes_by_name(&snapshot, &args.symbol)
.is_empty()
{
return Err(decorate_single_symbol_lookup_error(
&snapshot,
&args.symbol,
anyhow!("Symbol '{}' not found in graph.", args.symbol),
));
}
let db = sqry_db::queries::dispatch::make_query_db_cold(
std::sync::Arc::clone(&snapshot),
workspace_root,
);
let key = sqry_db::queries::RelationKey::exact(&args.symbol);
let caller_ids = crate::execution::relation_dispatch::mcp_callers_query(&db, &key);
let mut callers = build_caller_callee_data(graph, &snapshot, &caller_ids, workspace_root);
let total = callers.len();
callers.truncate(args.max_results);
let (page_slice, next_page_token) = paginate(&callers, &args.pagination);
let page_callers = page_slice.to_vec();
Ok(ToolExecution {
data: DirectCallersData {
target: args.symbol.clone(),
callers: page_callers,
total: total as u64,
},
used_index: false,
used_graph: true,
graph_metadata: None,
execution_ms: duration_to_ms(start.elapsed()),
next_page_token,
total: Some(total as u64),
truncated: Some(total > args.max_results),
candidates_scanned: None,
workspace_path: crate::execution::symbol_utils::path_to_forward_slash(workspace_root),
})
}
pub(crate) fn execute_direct_callees(
ctx: &crate::daemon_adapter::WorkspaceContext,
args: &DirectCalleesArgs,
start: Instant,
) -> Result<ToolExecution<DirectCalleesData>> {
let workspace_root: &std::path::Path = &ctx.workspace_root;
let graph = &ctx.graph;
let snapshot = std::sync::Arc::new(graph.snapshot());
if sqry_core::graph::unified::materialize::find_nodes_by_name(&snapshot, &args.symbol)
.is_empty()
{
return Err(decorate_single_symbol_lookup_error(
&snapshot,
&args.symbol,
anyhow!("Symbol '{}' not found in graph.", args.symbol),
));
}
let db = sqry_db::queries::dispatch::make_query_db_cold(
std::sync::Arc::clone(&snapshot),
workspace_root,
);
let key = sqry_db::queries::RelationKey::exact(&args.symbol);
let callee_ids = crate::execution::relation_dispatch::mcp_callees_query(&db, &key);
let mut callees = build_caller_callee_data(graph, &snapshot, &callee_ids, workspace_root);
let total = callees.len();
callees.truncate(args.max_results);
let (page_slice, next_page_token) = paginate(&callees, &args.pagination);
let page_callees = page_slice.to_vec();
Ok(ToolExecution {
data: DirectCalleesData {
source: args.symbol.clone(),
callees: page_callees,
total: total as u64,
},
used_index: false,
used_graph: true,
graph_metadata: None,
execution_ms: duration_to_ms(start.elapsed()),
next_page_token,
total: Some(total as u64),
truncated: Some(total > args.max_results),
candidates_scanned: None,
workspace_path: crate::execution::symbol_utils::path_to_forward_slash(workspace_root),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqry_core::graph::unified::concurrent::CodeGraph;
use sqry_core::graph::unified::edge::EdgeKind;
use sqry_core::graph::unified::node::NodeKind;
use sqry_core::graph::unified::storage::NodeEntry;
use std::path::Path;
fn create_test_graph_for_unused() -> CodeGraph {
use sqry_core::graph::unified::ExportKind;
let mut graph = CodeGraph::new();
let main_rs = graph
.files_mut()
.register(Path::new("src/main.rs"))
.unwrap();
let lib_rs = graph.files_mut().register(Path::new("src/lib.rs")).unwrap();
let helper_rs = graph
.files_mut()
.register(Path::new("src/helper.rs"))
.unwrap();
let main_fn = graph.strings_mut().intern("main").unwrap();
let public_fn = graph.strings_mut().intern("public_function").unwrap();
let used_fn = graph.strings_mut().intern("used_helper").unwrap();
let unused_fn = graph.strings_mut().intern("unused_helper").unwrap();
let vis_public = graph.strings_mut().intern("public").unwrap();
let vis_private = graph.strings_mut().intern("private").unwrap();
let mut main_entry = NodeEntry::new(NodeKind::Function, main_fn, main_rs);
main_entry.visibility = Some(vis_public);
let node_main = graph.nodes_mut().alloc(main_entry).unwrap();
graph
.indices_mut()
.add(node_main, NodeKind::Function, main_fn, None, main_rs);
let mut public_entry = NodeEntry::new(NodeKind::Function, public_fn, lib_rs);
public_entry.visibility = Some(vis_public);
let node_public = graph.nodes_mut().alloc(public_entry).unwrap();
graph
.indices_mut()
.add(node_public, NodeKind::Function, public_fn, None, lib_rs);
let mut used_entry = NodeEntry::new(NodeKind::Function, used_fn, helper_rs);
used_entry.visibility = Some(vis_private);
let node_used = graph.nodes_mut().alloc(used_entry).unwrap();
graph
.indices_mut()
.add(node_used, NodeKind::Function, used_fn, None, helper_rs);
let mut unused_entry = NodeEntry::new(NodeKind::Function, unused_fn, helper_rs);
unused_entry.visibility = Some(vis_private);
let node_unused = graph.nodes_mut().alloc(unused_entry).unwrap();
graph
.indices_mut()
.add(node_unused, NodeKind::Function, unused_fn, None, helper_rs);
let call_kind = EdgeKind::Calls {
argument_count: 0,
is_async: false,
};
graph
.edges_mut()
.add_edge(node_main, node_public, call_kind.clone(), main_rs);
graph
.edges_mut()
.add_edge(node_public, node_used, call_kind, lib_rs);
let export_kind = EdgeKind::Exports {
kind: ExportKind::Direct,
alias: None,
};
graph
.edges_mut()
.add_edge(node_public, node_public, export_kind, lib_rs);
graph
}
#[test]
fn test_matches_scope_filter_public() {
let graph = create_test_graph_for_unused();
let snapshot = graph.snapshot();
let strings = snapshot.strings();
let public_node = candidate_bucket_for_symbol(&snapshot, "public_function")
.into_iter()
.next()
.unwrap();
let public_entry = snapshot.get_node(public_node).unwrap();
let visibility_string = public_entry
.visibility
.and_then(|vid| strings.resolve(vid))
.map(|s| s.to_string());
let visibility_str = visibility_string.as_deref();
assert!(matches_scope_filter(
public_entry.kind,
visibility_str,
UnusedScope::Public
));
assert!(matches_scope_filter(
public_entry.kind,
visibility_str,
UnusedScope::All
));
assert!(!matches_scope_filter(
public_entry.kind,
visibility_str,
UnusedScope::Private
));
}
#[test]
fn test_matches_scope_filter_private() {
let graph = create_test_graph_for_unused();
let snapshot = graph.snapshot();
let strings = snapshot.strings();
let unused_node = candidate_bucket_for_symbol(&snapshot, "unused_helper")
.into_iter()
.next()
.unwrap();
let unused_entry = snapshot.get_node(unused_node).unwrap();
let visibility_string = unused_entry
.visibility
.and_then(|vid| strings.resolve(vid))
.map(|s| s.to_string());
let visibility_str = visibility_string.as_deref();
assert!(matches_scope_filter(
unused_entry.kind,
visibility_str,
UnusedScope::Private
));
assert!(matches_scope_filter(
unused_entry.kind,
visibility_str,
UnusedScope::All
));
assert!(!matches_scope_filter(
unused_entry.kind,
visibility_str,
UnusedScope::Public
));
}
#[test]
fn test_matches_scope_filter_function() {
let graph = create_test_graph_for_unused();
let snapshot = graph.snapshot();
let main_node = candidate_bucket_for_symbol(&snapshot, "main")
.into_iter()
.next()
.unwrap();
let main_entry = snapshot.get_node(main_node).unwrap();
assert!(matches_scope_filter(
main_entry.kind,
None,
UnusedScope::Function
));
assert!(!matches_scope_filter(
main_entry.kind,
None,
UnusedScope::Struct
));
}
#[test]
fn test_cycle_type_label_calls() {
assert_eq!(cycle_type_label(CycleType::Calls), "calls");
}
#[test]
fn test_cycle_type_label_imports() {
assert_eq!(cycle_type_label(CycleType::Imports), "imports");
}
#[test]
fn test_cycle_type_label_modules() {
assert_eq!(cycle_type_label(CycleType::Modules), "modules");
}
#[test]
fn test_mcp_cycle_type_to_core_parity() {
assert_eq!(
mcp_cycle_type_to_core(CycleType::Calls),
CircularType::Calls
);
assert_eq!(
mcp_cycle_type_to_core(CycleType::Imports),
CircularType::Imports
);
assert_eq!(
mcp_cycle_type_to_core(CycleType::Modules),
CircularType::Modules
);
}
#[test]
fn test_resolve_workspace_path_dot_returns_none() {
assert!(resolve_workspace_path(".").is_none());
}
#[test]
fn test_resolve_workspace_path_explicit_path_returns_some() {
let result = resolve_workspace_path("/some/path");
assert!(result.is_some());
assert_eq!(result.unwrap(), std::path::PathBuf::from("/some/path"));
}
#[test]
fn test_matches_scope_filter_struct_for_class() {
assert!(matches_scope_filter(
sqry_core::graph::unified::node::NodeKind::Class,
None,
UnusedScope::Struct
));
}
#[test]
fn test_matches_scope_filter_struct_for_interface() {
assert!(matches_scope_filter(
sqry_core::graph::unified::node::NodeKind::Interface,
None,
UnusedScope::Struct
));
}
#[test]
fn test_matches_scope_filter_struct_for_trait() {
assert!(matches_scope_filter(
sqry_core::graph::unified::node::NodeKind::Trait,
None,
UnusedScope::Struct
));
}
#[test]
fn test_matches_scope_filter_function_for_method() {
assert!(matches_scope_filter(
sqry_core::graph::unified::node::NodeKind::Method,
None,
UnusedScope::Function
));
}
#[test]
fn test_convert_cycles_to_output_empty_cycles() {
let graph = CodeGraph::new();
let snapshot = graph.snapshot();
let ws = Path::new("/workspace");
let result = convert_cycles_to_output(vec![], &snapshot, &graph, ws);
assert!(result.is_empty());
}
#[test]
fn test_convert_cycles_to_output_nonempty_cycle() {
let graph = CodeGraph::new();
let snapshot = graph.snapshot();
let ws = Path::new("/workspace");
let cycles = vec![vec!["alpha".to_string(), "beta".to_string()]];
let result = convert_cycles_to_output(cycles, &snapshot, &graph, ws);
assert_eq!(result.len(), 1);
let cycle = &result[0];
assert_eq!(cycle.depth, 2);
assert!(cycle.chain.contains("alpha"));
assert!(cycle.chain.contains("beta"));
assert!(cycle.chain.ends_with("alpha"));
}
#[test]
fn test_convert_cycles_to_output_single_node_cycle() {
let graph = CodeGraph::new();
let snapshot = graph.snapshot();
let ws = Path::new("/workspace");
let cycles = vec![vec!["selfloop".to_string()]];
let result = convert_cycles_to_output(cycles, &snapshot, &graph, ws);
assert_eq!(result.len(), 1);
assert_eq!(result[0].depth, 1);
assert!(result[0].chain.contains("selfloop"));
}
#[test]
fn test_candidate_bucket_finds_registered_symbol() {
let mut graph = CodeGraph::new();
let file_id = graph.files_mut().register(Path::new("src/x.rs")).unwrap();
let nm = graph.strings_mut().intern("my_func").unwrap();
let entry = NodeEntry::new(NodeKind::Function, nm, file_id);
let node_id = graph.nodes_mut().alloc(entry).unwrap();
graph
.indices_mut()
.add(node_id, NodeKind::Function, nm, None, file_id);
let snapshot = graph.snapshot();
let candidates = candidate_bucket_for_symbol(&snapshot, "my_func");
assert!(!candidates.is_empty());
assert!(candidates.contains(&node_id));
}
#[test]
fn test_candidate_bucket_returns_empty_for_unknown() {
let graph = CodeGraph::new();
let snapshot = graph.snapshot();
let candidates = candidate_bucket_for_symbol(&snapshot, "nonexistent_xyz_123");
assert!(candidates.is_empty());
}
#[test]
fn test_find_similar_symbols_finds_substring_match() {
let mut graph = CodeGraph::new();
let file_id = graph.files_mut().register(Path::new("src/lib.rs")).unwrap();
for name in &["process_request", "process_response", "other_func"] {
let nm = graph.strings_mut().intern(name).unwrap();
let entry = NodeEntry::new(NodeKind::Function, nm, file_id);
let nid = graph.nodes_mut().alloc(entry).unwrap();
graph
.indices_mut()
.add(nid, NodeKind::Function, nm, None, file_id);
}
let snapshot = graph.snapshot();
let suggestions = find_similar_symbols(&snapshot, "process", 5);
assert!(!suggestions.is_empty());
assert!(suggestions.iter().any(|s| s.contains("process")));
}
#[test]
fn test_find_similar_symbols_empty_graph_returns_empty() {
let graph = CodeGraph::new();
let snapshot = graph.snapshot();
let suggestions = find_similar_symbols(&snapshot, "anything", 5);
assert!(suggestions.is_empty());
}
#[test]
fn test_find_similar_symbols_truncated_to_max() {
let mut graph = CodeGraph::new();
let file_id = graph.files_mut().register(Path::new("src/lib.rs")).unwrap();
for i in 0..20 {
let name = format!("foo_func_{i}");
let nm = graph.strings_mut().intern(&name).unwrap();
let entry = NodeEntry::new(NodeKind::Function, nm, file_id);
let nid = graph.nodes_mut().alloc(entry).unwrap();
graph
.indices_mut()
.add(nid, NodeKind::Function, nm, None, file_id);
}
let snapshot = graph.snapshot();
let suggestions = find_similar_symbols(&snapshot, "foo_func", 3);
assert!(suggestions.len() <= 3);
}
#[test]
fn test_decorate_error_for_not_found_adds_hints() {
let graph = CodeGraph::new();
let snapshot = graph.snapshot();
let err = anyhow::anyhow!("Symbol 'xyz' not found in graph.");
let decorated = decorate_single_symbol_lookup_error(&snapshot, "xyz", err);
let msg = decorated.to_string();
assert!(msg.contains("not found in graph"));
assert!(msg.contains("Hints:") || msg.contains("Use the full qualified name"));
}
#[test]
fn test_decorate_error_for_other_error_unchanged() {
let graph = CodeGraph::new();
let snapshot = graph.snapshot();
let err = anyhow::anyhow!("Some other error.");
let decorated = decorate_single_symbol_lookup_error(&snapshot, "xyz", err);
assert_eq!(decorated.to_string(), "Some other error.");
}
#[test]
fn test_build_unused_symbol_data_basic() {
let mut graph = CodeGraph::new();
let ws = Path::new("/workspace");
let file_id = graph.files_mut().register(Path::new("src/lib.rs")).unwrap();
let nm = graph.strings_mut().intern("orphan_fn").unwrap();
let vis_pub = graph.strings_mut().intern("public").unwrap();
let mut entry = NodeEntry::new(NodeKind::Function, nm, file_id);
entry.visibility = Some(vis_pub);
let node_id = graph.nodes_mut().alloc(entry.clone()).unwrap();
let snapshot = graph.snapshot();
let strings = snapshot.strings();
let files = snapshot.files();
let data = build_unused_symbol_data(&entry, node_id, &graph, strings, files, ws);
assert_eq!(data.name, "orphan_fn");
assert_eq!(data.kind, "function");
assert!(!data.file_uri.is_empty());
}
#[test]
fn test_resolve_global_symbol_strict_not_found() {
let graph = CodeGraph::new();
let snapshot = graph.snapshot();
let result = resolve_global_symbol_strict(&snapshot, "nonexistent_symbol_xyz", None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_resolve_global_symbol_strict_found() {
let mut graph = CodeGraph::new();
let file_id = graph.files_mut().register(Path::new("src/lib.rs")).unwrap();
let nm = graph.strings_mut().intern("unique_function_xyz").unwrap();
let entry = NodeEntry::new(NodeKind::Function, nm, file_id);
let node_id = graph.nodes_mut().alloc(entry).unwrap();
graph
.indices_mut()
.add(node_id, NodeKind::Function, nm, None, file_id);
let snapshot = graph.snapshot();
let result = resolve_global_symbol_strict(&snapshot, "unique_function_xyz", None);
assert!(result.is_ok());
assert_eq!(result.unwrap(), node_id);
}
fn make_ambiguous_two_file_graph() -> (
CodeGraph,
sqry_core::graph::unified::file::id::FileId,
sqry_core::graph::unified::file::id::FileId,
) {
let mut graph = CodeGraph::new();
let file_a = graph
.files_mut()
.register(Path::new("src/module_a.rs"))
.unwrap();
let file_b = graph
.files_mut()
.register(Path::new("src/module_b.rs"))
.unwrap();
let nm = graph.strings_mut().intern("shared_fn").unwrap();
let mut entry_a = NodeEntry::new(NodeKind::Function, nm, file_a);
entry_a.start_line = 10;
let node_a = graph.nodes_mut().alloc(entry_a).unwrap();
graph
.indices_mut()
.add(node_a, NodeKind::Function, nm, None, file_a);
let mut entry_b = NodeEntry::new(NodeKind::Function, nm, file_b);
entry_b.start_line = 20;
let node_b = graph.nodes_mut().alloc(entry_b).unwrap();
graph
.indices_mut()
.add(node_b, NodeKind::Function, nm, None, file_b);
(graph, file_a, file_b)
}
fn make_ambiguous_four_file_graph() -> CodeGraph {
let mut graph = CodeGraph::new();
let paths = [
"src/module_a.rs",
"src/module_b.rs",
"src/module_c.rs",
"src/module_d.rs",
];
let nm = graph.strings_mut().intern("shared_fn").unwrap();
for (i, path) in paths.iter().enumerate() {
let file_id = graph.files_mut().register(Path::new(path)).unwrap();
let mut entry = NodeEntry::new(NodeKind::Function, nm, file_id);
entry.start_line = (i as u32 + 1) * 10;
let node_id = graph.nodes_mut().alloc(entry).unwrap();
graph
.indices_mut()
.add(node_id, NodeKind::Function, nm, None, file_id);
}
graph
}
fn make_same_file_two_candidate_graph()
-> (CodeGraph, sqry_core::graph::unified::file::id::FileId) {
let mut graph = CodeGraph::new();
let file_id = graph
.files_mut()
.register(Path::new("src/module_x.rs"))
.unwrap();
let nm = graph.strings_mut().intern("shared_fn").unwrap();
let qn_a = graph
.strings_mut()
.intern("module_x::ImplA::shared_fn")
.unwrap();
let qn_b = graph
.strings_mut()
.intern("module_x::ImplB::shared_fn")
.unwrap();
let mut entry_a = NodeEntry::new(NodeKind::Function, nm, file_id);
entry_a.start_line = 5;
entry_a.qualified_name = Some(qn_a);
let node_a = graph.nodes_mut().alloc(entry_a).unwrap();
graph
.indices_mut()
.add(node_a, NodeKind::Function, nm, Some(qn_a), file_id);
let mut entry_b = NodeEntry::new(NodeKind::Function, nm, file_id);
entry_b.start_line = 25;
entry_b.qualified_name = Some(qn_b);
let node_b = graph.nodes_mut().alloc(entry_b).unwrap();
graph
.indices_mut()
.add(node_b, NodeKind::Function, nm, Some(qn_b), file_id);
(graph, file_id)
}
#[test]
fn test_resolve_global_symbol_strict_file_path_narrows_to_one() {
let (graph, _file_a, _file_b) = make_ambiguous_two_file_graph();
let snapshot = graph.snapshot();
let result_ambiguous = resolve_global_symbol_strict(&snapshot, "shared_fn", None);
assert!(result_ambiguous.is_err());
let err_msg = result_ambiguous.unwrap_err().to_string();
assert!(
err_msg.contains("ambiguous"),
"Expected ambiguous error, got: {err_msg}"
);
let path_a = Path::new("src/module_a.rs");
let result = resolve_global_symbol_strict(&snapshot, "shared_fn", Some(path_a));
assert!(
result.is_ok(),
"Expected Ok with file_path, got: {:?}",
result.err()
);
}
#[test]
fn test_resolve_global_symbol_strict_file_path_zero_candidates() {
let (graph, _file_a, _file_b) = make_ambiguous_two_file_graph();
let snapshot = graph.snapshot();
let nonexistent_path = Path::new("src/nonexistent.rs");
let result = resolve_global_symbol_strict(&snapshot, "shared_fn", Some(nonexistent_path));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("No definition of") || err_msg.contains("not found"),
"Expected no-definition error, got: {err_msg}"
);
}
#[test]
fn test_resolve_global_symbol_strict_ambiguity_error_includes_samples() {
let (graph, _file_a, _file_b) = make_ambiguous_two_file_graph();
let snapshot = graph.snapshot();
let result = resolve_global_symbol_strict(&snapshot, "shared_fn", None);
let err = result.expect_err("ambiguous symbol must surface as Err");
let err_msg = err.to_string();
let envelope: serde_json::Value =
serde_json::from_str(&err_msg).expect("envelope must be valid JSON");
let error_obj = envelope.get("error").expect("envelope wraps `error`");
assert_eq!(error_obj["code"], "sqry::ambiguous_symbol");
let msg = error_obj["message"].as_str().expect("message is string");
assert!(msg.contains("shared_fn"));
assert!(msg.contains("ambiguous"));
assert_eq!(error_obj["truncated"], serde_json::Value::Bool(false));
let candidates = error_obj["candidates"]
.as_array()
.expect("candidates[] required");
assert_eq!(candidates.len(), 2);
let combined: Vec<(String, u64)> = candidates
.iter()
.map(|c| {
(
c["file_path"].as_str().unwrap().to_string(),
c["start_line"].as_u64().unwrap(),
)
})
.collect();
assert!(
combined
.iter()
.any(|(p, l)| p.contains("module_a.rs") && *l == 10),
"expected module_a.rs:10 candidate, got {combined:?}"
);
assert!(
combined
.iter()
.any(|(p, l)| p.contains("module_b.rs") && *l == 20),
"expected module_b.rs:20 candidate, got {combined:?}"
);
}
#[test]
fn test_resolve_global_symbol_strict_ambiguity_error_caps_samples_at_three() {
let graph = make_ambiguous_four_file_graph();
let snapshot = graph.snapshot();
let result = resolve_global_symbol_strict(&snapshot, "shared_fn", None);
let err = result.expect_err("ambiguous symbol must surface as Err");
let err_msg = err.to_string();
let envelope: serde_json::Value =
serde_json::from_str(&err_msg).expect("envelope must be valid JSON");
let error_obj = envelope.get("error").expect("envelope wraps `error`");
let candidates = error_obj["candidates"]
.as_array()
.expect("candidates[] required");
assert_eq!(candidates.len(), 4, "all 4 candidates must surface");
assert_eq!(
error_obj["truncated"],
serde_json::Value::Bool(false),
"4 < cap of 20, truncated stays false"
);
}
#[test]
fn test_resolve_global_symbol_strict_file_path_not_found_error_mentions_file() {
let (graph, _file_a, _file_b) = make_ambiguous_two_file_graph();
let snapshot = graph.snapshot();
let missing_path = Path::new("kernel/rcu/tree.c");
let result = resolve_global_symbol_strict(&snapshot, "shared_fn", Some(missing_path));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("tree.c") || err_msg.contains("kernel"),
"Expected file name in error, got: {err_msg}"
);
}
#[test]
fn test_resolve_global_symbol_strict_same_file_ambiguity_lists_candidates() {
let (graph, _file_id) = make_same_file_two_candidate_graph();
let snapshot = graph.snapshot();
let file_path = Path::new("src/module_x.rs");
let result = resolve_global_symbol_strict(&snapshot, "shared_fn", Some(file_path));
let err = result.expect_err("ambiguous symbol must surface as Err");
let err_msg = err.to_string();
let envelope: serde_json::Value =
serde_json::from_str(&err_msg).expect("envelope must be valid JSON");
let error_obj = envelope.get("error").expect("envelope wraps `error`");
assert_eq!(error_obj["code"], "sqry::ambiguous_symbol");
let candidates = error_obj["candidates"]
.as_array()
.expect("candidates[] required");
let qnames: Vec<&str> = candidates
.iter()
.map(|c| c["qualified_name"].as_str().unwrap())
.collect();
assert!(
qnames.iter().any(|q| q.ends_with("ImplA::shared_fn")),
"expected qualified name ending in ImplA::shared_fn in {qnames:?}"
);
assert!(
qnames.iter().any(|q| q.ends_with("ImplB::shared_fn")),
"expected qualified name ending in ImplB::shared_fn in {qnames:?}"
);
for cand in candidates {
assert!(
cand["file_path"].as_str().unwrap().contains("module_x.rs"),
"every candidate must include the matched file path"
);
}
}
#[test]
fn test_collect_impacted_callers_bfs_direct_caller() {
use crate::tools::{DependencyImpactArgs, PaginationArgs};
let mut graph = CodeGraph::new();
let file_id = graph.files_mut().register(Path::new("src/lib.rs")).unwrap();
let nm_a = graph.strings_mut().intern("caller_of_target").unwrap();
let nm_b = graph.strings_mut().intern("target_fn").unwrap();
let node_a = graph
.nodes_mut()
.alloc(NodeEntry::new(NodeKind::Function, nm_a, file_id))
.unwrap();
let node_b = graph
.nodes_mut()
.alloc(NodeEntry::new(NodeKind::Function, nm_b, file_id))
.unwrap();
graph
.indices_mut()
.add(node_a, NodeKind::Function, nm_a, None, file_id);
graph
.indices_mut()
.add(node_b, NodeKind::Function, nm_b, None, file_id);
let call_kind = EdgeKind::Calls {
argument_count: 0,
is_async: false,
};
graph
.edges_mut()
.add_edge(node_a, node_b, call_kind, file_id);
let snapshot = graph.snapshot();
let ws = Path::new("/workspace");
let args = DependencyImpactArgs {
symbol: "target_fn".to_string(),
path: ".".to_string(),
max_depth: 2,
max_results: 100,
include_indirect: false,
include_files: false,
pagination: PaginationArgs {
offset: 0,
size: 100,
},
file_path: None,
};
let (impacted, _) = collect_impacted_callers_bfs(&snapshot, &graph, node_b, &args, ws);
assert_eq!(impacted.len(), 1);
assert_eq!(impacted[0].symbol.name, "caller_of_target");
assert_eq!(impacted[0].impact_type, "caller");
}
#[test]
fn test_collect_impacted_callers_bfs_include_files() {
use crate::tools::{DependencyImpactArgs, PaginationArgs};
let mut graph = CodeGraph::new();
let file_id = graph.files_mut().register(Path::new("src/lib.rs")).unwrap();
let nm_a = graph.strings_mut().intern("caller_with_files").unwrap();
let nm_b = graph.strings_mut().intern("target_with_files").unwrap();
let node_a = graph
.nodes_mut()
.alloc(NodeEntry::new(NodeKind::Function, nm_a, file_id))
.unwrap();
let node_b = graph
.nodes_mut()
.alloc(NodeEntry::new(NodeKind::Function, nm_b, file_id))
.unwrap();
graph
.indices_mut()
.add(node_a, NodeKind::Function, nm_a, None, file_id);
graph
.indices_mut()
.add(node_b, NodeKind::Function, nm_b, None, file_id);
let call_kind = EdgeKind::Calls {
argument_count: 0,
is_async: false,
};
graph
.edges_mut()
.add_edge(node_a, node_b, call_kind, file_id);
let snapshot = graph.snapshot();
let ws = Path::new("/workspace");
let args = DependencyImpactArgs {
symbol: "target_with_files".to_string(),
path: ".".to_string(),
max_depth: 1,
max_results: 100,
include_indirect: false,
include_files: true,
pagination: PaginationArgs {
offset: 0,
size: 100,
},
file_path: None,
};
let (impacted, affected_files) =
collect_impacted_callers_bfs(&snapshot, &graph, node_b, &args, ws);
assert_eq!(impacted.len(), 1);
assert!(!affected_files.is_empty());
}
#[test]
fn test_should_include_in_unused_results_passes_all_scope() {
use crate::tools::{FindUnusedArgs, PaginationArgs};
let mut graph = CodeGraph::new();
let file_id = graph.files_mut().register(Path::new("src/lib.rs")).unwrap();
let nm = graph.strings_mut().intern("some_fn").unwrap();
let entry = NodeEntry::new(NodeKind::Function, nm, file_id);
let _ = graph.nodes_mut().alloc(entry.clone()).unwrap();
let snapshot = graph.snapshot();
let strings = snapshot.strings();
let files = snapshot.files();
let args = FindUnusedArgs {
path: ".".to_string(),
scope: UnusedScope::All,
languages: vec![],
kinds: vec![],
max_results: 100,
pagination: PaginationArgs {
offset: 0,
size: 100,
},
};
assert!(should_include_in_unused_results(
&entry, &args, strings, files
));
}
#[test]
fn test_should_include_in_unused_results_fails_language_filter() {
use crate::tools::{FindUnusedArgs, PaginationArgs};
let mut graph = CodeGraph::new();
let file_id = graph.files_mut().register(Path::new("src/lib.rs")).unwrap();
let nm = graph.strings_mut().intern("some_fn").unwrap();
let entry = NodeEntry::new(NodeKind::Function, nm, file_id);
let _ = graph.nodes_mut().alloc(entry.clone()).unwrap();
let snapshot = graph.snapshot();
let strings = snapshot.strings();
let files = snapshot.files();
let args = FindUnusedArgs {
path: ".".to_string(),
scope: UnusedScope::All,
languages: vec!["python".to_string()],
kinds: vec![],
max_results: 100,
pagination: PaginationArgs {
offset: 0,
size: 100,
},
};
assert!(!should_include_in_unused_results(
&entry, &args, strings, files
));
}
#[test]
fn test_should_include_in_unused_results_fails_kind_filter() {
use crate::tools::{FindUnusedArgs, PaginationArgs};
let mut graph = CodeGraph::new();
let file_id = graph.files_mut().register(Path::new("src/lib.rs")).unwrap();
let nm = graph.strings_mut().intern("my_struct").unwrap();
let entry = NodeEntry::new(NodeKind::Struct, nm, file_id);
let _ = graph.nodes_mut().alloc(entry.clone()).unwrap();
let snapshot = graph.snapshot();
let strings = snapshot.strings();
let files = snapshot.files();
let args = FindUnusedArgs {
path: ".".to_string(),
scope: UnusedScope::All,
languages: vec![],
kinds: vec!["function".to_string()], max_results: 100,
pagination: PaginationArgs {
offset: 0,
size: 100,
},
};
assert!(!should_include_in_unused_results(
&entry, &args, strings, files
));
}
#[test]
fn test_process_caller_node_builds_impacted_symbol() {
use crate::tools::{DependencyImpactArgs, PaginationArgs};
let mut graph = CodeGraph::new();
let file_id = graph.files_mut().register(Path::new("src/lib.rs")).unwrap();
let nm = graph.strings_mut().intern("caller_fn").unwrap();
let entry = NodeEntry::new(NodeKind::Function, nm, file_id);
let node_id = graph.nodes_mut().alloc(entry.clone()).unwrap();
let snapshot = graph.snapshot();
let strings = snapshot.strings();
let files = snapshot.files();
let ws = Path::new("/workspace");
let args = DependencyImpactArgs {
symbol: "caller_fn".to_string(),
path: ".".to_string(),
max_depth: 2,
max_results: 100,
include_indirect: false,
include_files: false,
pagination: PaginationArgs {
offset: 0,
size: 100,
},
file_path: None,
};
let (symbol, _file_uri) =
process_caller_node(&entry, node_id, &graph, strings, files, ws, 0, &args);
assert_eq!(symbol.symbol.name, "caller_fn");
assert_eq!(symbol.impact_type, "caller");
assert_eq!(symbol.depth, 1); }
#[test]
fn hierarchical_search_implicit_and_kind_plus_bare_word_returns_results() {
use sqry_core::graph::Language;
use sqry_core::query::QueryExecutor;
use std::sync::Arc;
let mut graph = CodeGraph::new();
let file_id = graph
.files_mut()
.register_with_language(Path::new("/workspace/fs/smb2.c"), Some(Language::C))
.expect("register smb2.c");
let nm = graph
.strings_mut()
.intern("smb2_open")
.expect("intern smb2_open");
let entry = NodeEntry::new(NodeKind::Function, nm, file_id);
let node_id = graph
.nodes_mut()
.alloc(entry)
.expect("alloc smb2_open node");
graph
.indices_mut()
.add(node_id, NodeKind::Function, nm, None, file_id);
let graph = Arc::new(graph);
let executor = QueryExecutor::new();
let workspace_root = Path::new("/workspace");
let results = executor
.execute_on_preloaded_graph(
Arc::clone(&graph),
"kind:function smb2_open",
workspace_root,
None,
)
.expect("execute_on_preloaded_graph must succeed for implicit AND query");
assert_eq!(
results.len(),
1,
"kind:function smb2_open via shared executor parser must return the smb2_open node"
);
let matched_name = results
.iter()
.next()
.and_then(|m| m.name())
.map(|n| n.to_string())
.unwrap_or_default();
assert_eq!(
matched_name, "smb2_open",
"matched node name must be smb2_open"
);
}
}