Skip to main content

sqry_cli/commands/
shell.rs

1//! Interactive session shell for repeated semantic queries.
2//!
3//! Provides a REPL that keeps the `.sqry-index` cache warm via
4//! [`SessionManager`] so subsequent queries execute quickly.
5
6use 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
19/// Execute the interactive shell command.
20///
21/// # Errors
22/// Returns an error if the index cannot be loaded, the session fails to start,
23/// or terminal input cannot be read.
24pub fn run_shell(cli: &Cli, path: &str) -> Result<()> {
25    let workspace = PathBuf::from(path);
26    ensure_index_exists(&workspace)?;
27
28    // Create session manager for unified graph caching
29    let config = SessionConfig::default();
30    let session =
31        SessionManager::with_config(config).context("failed to initialise session manager")?;
32
33    // Warm the cache so the first query is instant.
34    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                // Ctrl+C - show helpful message and continue.
62                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    // Check for new unified graph format first
81    let storage = GraphStorage::new(path);
82    if storage.exists() {
83        return Ok(());
84    }
85
86    // Fall back to legacy format check
87    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
99/// Returns `true` if the caller requested to exit the shell.
100fn 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
213/// Convert `QueryResults` to `Vec<DisplaySymbol>` for display purposes.
214fn 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    // Convert to Vec<DisplaySymbol> for display
241    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}