sqry_cli/commands/
shell.rs1use crate::args::Cli;
7use crate::output::{DisplaySymbol, FormatterMetadata, OutputStreams, create_formatter};
8use anyhow::{Context, Result, anyhow};
9use rustyline::history::History;
10use rustyline::{DefaultEditor, error::ReadlineError};
11use sqry_core::json_response::Filters;
12use sqry_core::query::results::QueryResults;
13use sqry_core::session::{SessionConfig, SessionManager};
14use std::path::{Path, PathBuf};
15use std::time::Instant;
16
17const PROMPT: &str = "sqry> ";
18
19pub fn run_shell(cli: &Cli, path: &str) -> Result<()> {
25 let workspace = PathBuf::from(path);
26 ensure_index_exists(&workspace)?;
27
28 let config = SessionConfig::default();
30 let session =
31 SessionManager::with_config(config).context("failed to initialise session manager")?;
32
33 let start = Instant::now();
35 session
36 .preload(&workspace)
37 .with_context(|| format!("failed to load index from {}", workspace.display()))?;
38 let preload_elapsed = start.elapsed();
39
40 println!(
41 "Loaded index from {} in {}ms",
42 workspace.display(),
43 preload_elapsed.as_millis()
44 );
45 println!("sqry shell - type 'help' for commands, 'exit' to quit");
46
47 let mut rl = DefaultEditor::new()?;
48 loop {
49 match rl.readline(PROMPT) {
50 Ok(line) => {
51 let trimmed = line.trim();
52 if trimmed.is_empty() {
53 continue;
54 }
55
56 if handle_command(cli, trimmed, &session, &workspace, &mut rl) {
57 break;
58 }
59 }
60 Err(ReadlineError::Interrupted) => {
61 println!("(Press Ctrl+D or type 'exit' to quit)");
63 }
64 Err(ReadlineError::Eof) => {
65 println!("Goodbye!");
66 break;
67 }
68 Err(err) => {
69 return Err(anyhow!("failed to read input: {err}"));
70 }
71 }
72 }
73
74 Ok(())
75}
76
77fn ensure_index_exists(path: &Path) -> Result<()> {
78 use sqry_core::graph::unified::persistence::GraphStorage;
79
80 let storage = GraphStorage::new(path);
82 if storage.exists() {
83 return Ok(());
84 }
85
86 let legacy_index_path = path.join(".sqry-index");
88 if legacy_index_path.exists() {
89 return Ok(());
90 }
91
92 Err(anyhow!(
93 "no index found at {}. Run `sqry index {}` first.",
94 path.display(),
95 path.display()
96 ))
97}
98
99fn handle_command(
101 cli: &Cli,
102 input: &str,
103 session: &SessionManager,
104 workspace: &Path,
105 rl: &mut DefaultEditor,
106) -> bool {
107 match parse_meta_command(input) {
108 ShellControl::Help => {
109 print_help();
110 false
111 }
112 ShellControl::Stats => {
113 print_stats(session);
114 false
115 }
116 ShellControl::Refresh => {
117 if let Err(err) = refresh_session(session, workspace) {
118 eprintln!("Error: {err}");
119 }
120 false
121 }
122 ShellControl::Clear => {
123 print!("\x1B[2J\x1B[1;1H");
124 false
125 }
126 ShellControl::History => {
127 print_history(rl);
128 false
129 }
130 ShellControl::Exit => true,
131 ShellControl::Query(query) => {
132 match execute_query(cli, session, workspace, query) {
133 Ok(()) => {
134 let _ = rl.add_history_entry(query);
135 }
136 Err(err) => {
137 eprintln!("Error: {err}");
138 }
139 }
140 false
141 }
142 }
143}
144
145fn print_help() {
146 println!("Available commands:");
147 println!(" help - Show this help message");
148 println!(" stats - Show session statistics");
149 println!(" refresh - Reload the index from disk");
150 println!(" clear - Clear the screen");
151 println!(" history - Show previous queries");
152 println!(" exit - Exit the shell");
153 println!();
154 println!("Enter a query expression (e.g., kind:function AND name:test) to search.");
155}
156
157fn print_stats(session: &SessionManager) {
158 let stats = session.stats();
159 let total_cache_events = stats.cache_hits + stats.cache_misses;
160 let hit_rate = if total_cache_events > 0 {
161 (u64_to_f64_lossy(stats.cache_hits) / u64_to_f64_lossy(total_cache_events)) * 100.0
162 } else {
163 0.0
164 };
165
166 println!("Session statistics:");
167 println!(" Cached graphs : {}", stats.cached_graphs);
168 println!(" Total queries : {}", stats.total_queries);
169 println!(
170 " Cache hits : {} ({hit_rate:.1}% hit rate)",
171 stats.cache_hits
172 );
173 println!(" Cache misses : {}", stats.cache_misses);
174 println!(" Estimated memory: ~{} MB", stats.total_memory_mb);
175}
176
177fn refresh_session(session: &SessionManager, workspace: &Path) -> Result<()> {
178 let start = Instant::now();
179 session
180 .invalidate(workspace)
181 .with_context(|| format!("failed to invalidate session for {}", workspace.display()))?;
182 session
183 .preload(workspace)
184 .with_context(|| format!("failed to reload index for {}", workspace.display()))?;
185 let elapsed = start.elapsed();
186
187 println!(
188 "Index reloaded in {}ms for {}",
189 elapsed.as_millis(),
190 workspace.display()
191 );
192
193 Ok(())
194}
195
196fn print_history(rl: &DefaultEditor) {
197 let history = rl.history();
198 if history.len() == 0 {
199 println!("History is empty.");
200 return;
201 }
202
203 for (idx, entry) in history.iter().enumerate() {
204 println!("{:>3}: {}", idx + 1, entry);
205 }
206}
207
208fn u64_to_f64_lossy(value: u64) -> f64 {
209 let narrowed = u32::try_from(value).unwrap_or(u32::MAX);
210 f64::from(narrowed)
211}
212
213fn query_results_to_display_symbols(results: &QueryResults) -> Vec<DisplaySymbol> {
215 results
216 .iter()
217 .map(|m| DisplaySymbol::from_query_match(&m))
218 .collect()
219}
220
221fn execute_query(cli: &Cli, session: &SessionManager, workspace: &Path, query: &str) -> Result<()> {
222 let limit = cli.limit.unwrap_or(100);
223 let count_only = cli.count;
224
225 let before_stats = session.stats();
226 let start = Instant::now();
227 let query_results = session
228 .query(workspace, query)
229 .with_context(|| format!("failed to execute query \"{query}\""))?;
230 let elapsed = start.elapsed();
231 let after_stats = session.stats();
232
233 let total_matches = query_results.len();
234
235 if count_only {
236 println!("{total_matches}");
237 return Ok(());
238 }
239
240 let mut results = query_results_to_display_symbols(&query_results);
242
243 if limit > 0 && results.len() > limit {
244 results.truncate(limit);
245 }
246
247 let mut streams = OutputStreams::with_pager(cli.pager_config());
248 let formatter = create_formatter(cli);
249 let metadata = FormatterMetadata {
250 pattern: Some(query.to_string()),
251 total_matches,
252 execution_time: elapsed,
253 filters: Filters {
254 kind: None,
255 lang: None,
256 ignore_case: cli.ignore_case,
257 exact: cli.exact,
258 fuzzy: None,
259 },
260 index_age_seconds: None,
261 used_ancestor_index: None,
262 filtered_to: None,
263 };
264
265 formatter.format(&results, Some(&metadata), &mut streams)?;
266
267 if !cli.json && total_matches > limit && limit > 0 {
268 streams.write_diagnostic(&format!(
269 "\nShowing {limit} of {total_matches} matches (use --limit to adjust)"
270 ))?;
271 }
272
273 if !cli.json {
274 let served_from_cache = after_stats.cache_hits > before_stats.cache_hits;
275 let cache_status = if served_from_cache {
276 "cache hit"
277 } else {
278 "cache miss"
279 };
280
281 streams.write_diagnostic(&format!(
282 "{} results in {}ms — {}",
283 total_matches,
284 elapsed.as_millis(),
285 cache_status
286 ))?;
287 }
288
289 streams.finish_checked()
290}
291
292fn parse_meta_command(input: &str) -> ShellControl<'_> {
293 match input {
294 "help" | ".help" => ShellControl::Help,
295 "stats" | ".stats" => ShellControl::Stats,
296 "refresh" | ".refresh" => ShellControl::Refresh,
297 "clear" | ".clear" => ShellControl::Clear,
298 "history" | ".history" => ShellControl::History,
299 "exit" | ".exit" | "quit" | ".quit" => ShellControl::Exit,
300 other => ShellControl::Query(other),
301 }
302}
303
304enum ShellControl<'a> {
305 Help,
306 Stats,
307 Refresh,
308 Clear,
309 History,
310 Exit,
311 Query(&'a str),
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use std::fs;
318 use tempfile::tempdir;
319
320 #[test]
321 fn parse_meta_command_recognises_aliases() {
322 assert!(matches!(parse_meta_command("help"), ShellControl::Help));
323 assert!(matches!(parse_meta_command(".stats"), ShellControl::Stats));
324 assert!(matches!(parse_meta_command("exit"), ShellControl::Exit));
325
326 if let ShellControl::Query(query) = parse_meta_command("kind:function") {
327 assert_eq!(query, "kind:function");
328 } else {
329 panic!("expected query variant");
330 }
331 }
332
333 #[test]
334 fn ensure_index_exists_validates_presence() {
335 let temp = tempdir().unwrap();
336 assert!(ensure_index_exists(temp.path()).is_err());
337
338 let index_path = temp.path().join(".sqry-index");
339 fs::File::create(&index_path).unwrap();
340
341 ensure_index_exists(temp.path()).unwrap();
342 }
343}