Skip to main content

sqry_cli/commands/
planner_query.rs

1//! `sqry plan-query` — structural query execution through the sqry-db planner.
2//!
3//! Bridges the text-syntax parser ([`sqry_db::planner::parse_query`]) and the
4//! plan executor ([`sqry_db::planner::execute_plan`]) to a user-facing CLI
5//! command. DB13 scope note: the legacy `sqry query` engine remains alongside
6//! this subcommand; DB14+ migrates the traversal handlers and eventually
7//! replaces the legacy path.
8//!
9//! # Output
10//!
11//! Results are printed one per line as
12//! `<kind> <qualified_or_short_name> <file>:<line>`. When `cli.json` is set,
13//! each row is serialized as a JSON object instead, matching the shape other
14//! sqry CLI commands use for JSON output.
15
16use crate::args::Cli;
17use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli, no_op_reporter};
18use crate::index_discovery::find_nearest_index;
19use crate::output::OutputStreams;
20use anyhow::{Context, Result};
21use serde::Serialize;
22use std::path::PathBuf;
23use std::sync::Arc;
24
25use sqry_db::planner::{ParseError, execute_plan, parse_query};
26use sqry_db::queries::dispatch::make_query_db_cold;
27
28/// One row of CLI / JSON output describing a matched node.
29#[derive(Debug, Clone, Serialize)]
30pub struct PlanQueryHit {
31    /// Symbol's short name (as interned in the graph).
32    pub name: String,
33    /// Fully qualified name if the graph recorded one; else copies `name`.
34    pub qualified_name: String,
35    /// `NodeKind` in lowercase snake_case form.
36    pub kind: String,
37    /// Filesystem path of the file containing this symbol.
38    pub file: String,
39    /// 1-based line number of the symbol's starting location.
40    pub line: u32,
41    /// Optional visibility string captured on the node entry.
42    pub visibility: Option<String>,
43}
44
45/// Runs the `sqry plan-query` subcommand.
46///
47/// # Errors
48///
49/// Returns an error if:
50/// - No `.sqry-index` can be discovered from the working directory.
51/// - The indexed graph fails to load.
52/// - The text query fails to parse or the resulting plan fails validation.
53pub fn run_planner_query(cli: &Cli, query: &str, path: Option<&str>, limit: usize) -> Result<()> {
54    let mut streams = OutputStreams::new();
55
56    let search_path = path.map_or_else(
57        || std::env::current_dir().unwrap_or_default(),
58        PathBuf::from,
59    );
60
61    let Some(location) = find_nearest_index(&search_path) else {
62        streams.write_diagnostic(
63            "No .sqry-index found. Run 'sqry index' first to build the graph index.",
64        )?;
65        return Ok(());
66    };
67
68    let config = GraphLoadConfig::default();
69    let graph = load_unified_graph_for_cli(&location.index_root, &config, cli, no_op_reporter())
70        .context("failed to load graph; run 'sqry index' to rebuild")?;
71
72    let plan = parse_query(query).map_err(format_parse_error)?;
73    let snapshot = Arc::new(graph.snapshot());
74
75    // Cluster-B iter-2 BLOCKER 1: gate the planner CLI path on the
76    // pre-flight cost check. Without this, `sqry plan-query` accepts
77    // unbounded regex shapes the MCP `sqry_query` path already rejects
78    // (`sqry-mcp/src/execution/tools/planner_query.rs` already calls
79    // `check_plan` before `execute_plan`). Mirror that contract here
80    // so the CLI surface produces the same `PlannerCostGateError` →
81    // `query_too_broad` envelope.
82    sqry_db::planner::cost_gate::check_plan(
83        &plan,
84        snapshot.nodes().len(),
85        &sqry_db::planner::cost_gate::PlannerCostGateConfig::default(),
86    )
87    .map_err(|e| anyhow::anyhow!("{e}"))?;
88
89    let db = make_query_db_cold(Arc::clone(&snapshot), &location.index_root);
90
91    let node_ids = execute_plan(&plan, &db);
92    let mut hits: Vec<PlanQueryHit> = Vec::with_capacity(node_ids.len().min(limit));
93
94    for node_id in node_ids.into_iter().take(limit) {
95        let Some(entry) = snapshot.nodes().get(node_id) else {
96            continue;
97        };
98        let strings = snapshot.strings();
99        let files = snapshot.files();
100        let name = strings
101            .resolve(entry.name)
102            .map(|s| s.to_string())
103            .unwrap_or_default();
104        let qualified_name = entry
105            .qualified_name
106            .and_then(|sid| strings.resolve(sid))
107            .map_or_else(|| name.clone(), |s| s.to_string());
108        let file = files
109            .resolve(entry.file)
110            .map(|p| p.display().to_string())
111            .unwrap_or_default();
112        let visibility = entry
113            .visibility
114            .and_then(|sid| strings.resolve(sid))
115            .map(|s| s.to_string());
116
117        hits.push(PlanQueryHit {
118            name,
119            qualified_name,
120            kind: entry.kind.as_str().to_string(),
121            file,
122            line: entry.start_line,
123            visibility,
124        });
125    }
126
127    if cli.json {
128        let payload =
129            serde_json::to_string_pretty(&hits).context("serializing plan-query hits as JSON")?;
130        streams.write_result(&payload)?;
131    } else {
132        for hit in &hits {
133            streams.write_result(&format!(
134                "{} {} {}:{}",
135                hit.kind, hit.qualified_name, hit.file, hit.line
136            ))?;
137        }
138    }
139
140    Ok(())
141}
142
143/// Wraps a [`ParseError`] with a caret-pointer diagnostic so CLI users can
144/// see exactly where the parser choked.
145fn format_parse_error(err: ParseError) -> anyhow::Error {
146    anyhow::anyhow!("query parse error: {err}")
147}