use crate::args::Cli;
use crate::output::{DisplaySymbol, FormatterMetadata, OutputStreams, create_formatter};
use anyhow::{Context, Result, anyhow};
use rustyline::history::History;
use rustyline::{DefaultEditor, error::ReadlineError};
use sqry_core::json_response::Filters;
use sqry_core::query::results::QueryResults;
use sqry_core::session::{SessionConfig, SessionManager};
use std::path::{Path, PathBuf};
use std::time::Instant;
const PROMPT: &str = "sqry> ";
pub fn run_shell(cli: &Cli, path: &str) -> Result<()> {
let workspace = PathBuf::from(path);
ensure_index_exists(&workspace)?;
let config = SessionConfig::default();
let session =
SessionManager::with_config(config).context("failed to initialise session manager")?;
let start = Instant::now();
session
.preload(&workspace)
.with_context(|| format!("failed to load index from {}", workspace.display()))?;
let preload_elapsed = start.elapsed();
println!(
"Loaded index from {} in {}ms",
workspace.display(),
preload_elapsed.as_millis()
);
println!("sqry shell - type 'help' for commands, 'exit' to quit");
let mut rl = DefaultEditor::new()?;
loop {
match rl.readline(PROMPT) {
Ok(line) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if handle_command(cli, trimmed, &session, &workspace, &mut rl) {
break;
}
}
Err(ReadlineError::Interrupted) => {
println!("(Press Ctrl+D or type 'exit' to quit)");
}
Err(ReadlineError::Eof) => {
println!("Goodbye!");
break;
}
Err(err) => {
return Err(anyhow!("failed to read input: {err}"));
}
}
}
Ok(())
}
fn ensure_index_exists(path: &Path) -> Result<()> {
use sqry_core::graph::unified::persistence::GraphStorage;
let storage = GraphStorage::new(path);
if storage.exists() {
return Ok(());
}
let legacy_index_path = path.join(".sqry-index");
if legacy_index_path.exists() {
return Ok(());
}
Err(anyhow!(
"no index found at {}. Run `sqry index {}` first.",
path.display(),
path.display()
))
}
fn handle_command(
cli: &Cli,
input: &str,
session: &SessionManager,
workspace: &Path,
rl: &mut DefaultEditor,
) -> bool {
match parse_meta_command(input) {
ShellControl::Help => {
print_help();
false
}
ShellControl::Stats => {
print_stats(session);
false
}
ShellControl::Refresh => {
if let Err(err) = refresh_session(session, workspace) {
eprintln!("Error: {err}");
}
false
}
ShellControl::Clear => {
print!("\x1B[2J\x1B[1;1H");
false
}
ShellControl::History => {
print_history(rl);
false
}
ShellControl::Exit => true,
ShellControl::Query(query) => {
match execute_query(cli, session, workspace, query) {
Ok(()) => {
let _ = rl.add_history_entry(query);
}
Err(err) => {
eprintln!("Error: {err}");
}
}
false
}
}
}
fn print_help() {
println!("Available commands:");
println!(" help - Show this help message");
println!(" stats - Show session statistics");
println!(" refresh - Reload the index from disk");
println!(" clear - Clear the screen");
println!(" history - Show previous queries");
println!(" exit - Exit the shell");
println!();
println!("Enter a query expression (e.g., kind:function AND name:test) to search.");
}
fn print_stats(session: &SessionManager) {
let stats = session.stats();
let total_cache_events = stats.cache_hits + stats.cache_misses;
let hit_rate = if total_cache_events > 0 {
(u64_to_f64_lossy(stats.cache_hits) / u64_to_f64_lossy(total_cache_events)) * 100.0
} else {
0.0
};
println!("Session statistics:");
println!(" Cached graphs : {}", stats.cached_graphs);
println!(" Total queries : {}", stats.total_queries);
println!(
" Cache hits : {} ({hit_rate:.1}% hit rate)",
stats.cache_hits
);
println!(" Cache misses : {}", stats.cache_misses);
println!(" Estimated memory: ~{} MB", stats.total_memory_mb);
}
fn refresh_session(session: &SessionManager, workspace: &Path) -> Result<()> {
let start = Instant::now();
session
.invalidate(workspace)
.with_context(|| format!("failed to invalidate session for {}", workspace.display()))?;
session
.preload(workspace)
.with_context(|| format!("failed to reload index for {}", workspace.display()))?;
let elapsed = start.elapsed();
println!(
"Index reloaded in {}ms for {}",
elapsed.as_millis(),
workspace.display()
);
Ok(())
}
fn print_history(rl: &DefaultEditor) {
let history = rl.history();
if history.len() == 0 {
println!("History is empty.");
return;
}
for (idx, entry) in history.iter().enumerate() {
println!("{:>3}: {}", idx + 1, entry);
}
}
fn u64_to_f64_lossy(value: u64) -> f64 {
let narrowed = u32::try_from(value).unwrap_or(u32::MAX);
f64::from(narrowed)
}
fn query_results_to_display_symbols(results: &QueryResults) -> Vec<DisplaySymbol> {
results
.iter()
.map(|m| DisplaySymbol::from_query_match(&m))
.collect()
}
fn execute_query(cli: &Cli, session: &SessionManager, workspace: &Path, query: &str) -> Result<()> {
let limit = cli.limit.unwrap_or(100);
let count_only = cli.count;
let before_stats = session.stats();
let start = Instant::now();
let query_results = session
.query(workspace, query)
.with_context(|| format!("failed to execute query \"{query}\""))?;
let elapsed = start.elapsed();
let after_stats = session.stats();
let total_matches = query_results.len();
if count_only {
println!("{total_matches}");
return Ok(());
}
let mut results = query_results_to_display_symbols(&query_results);
if limit > 0 && results.len() > limit {
results.truncate(limit);
}
let mut streams = OutputStreams::with_pager(cli.pager_config());
let formatter = create_formatter(cli);
let metadata = FormatterMetadata {
pattern: Some(query.to_string()),
total_matches,
execution_time: elapsed,
filters: Filters {
kind: None,
lang: None,
ignore_case: cli.ignore_case,
exact: cli.exact,
fuzzy: None,
},
index_age_seconds: None,
used_ancestor_index: None,
filtered_to: None,
};
formatter.format(&results, Some(&metadata), &mut streams)?;
if !cli.json && total_matches > limit && limit > 0 {
streams.write_diagnostic(&format!(
"\nShowing {limit} of {total_matches} matches (use --limit to adjust)"
))?;
}
if !cli.json {
let served_from_cache = after_stats.cache_hits > before_stats.cache_hits;
let cache_status = if served_from_cache {
"cache hit"
} else {
"cache miss"
};
streams.write_diagnostic(&format!(
"{} results in {}ms — {}",
total_matches,
elapsed.as_millis(),
cache_status
))?;
}
streams.finish_checked()
}
fn parse_meta_command(input: &str) -> ShellControl<'_> {
match input {
"help" | ".help" => ShellControl::Help,
"stats" | ".stats" => ShellControl::Stats,
"refresh" | ".refresh" => ShellControl::Refresh,
"clear" | ".clear" => ShellControl::Clear,
"history" | ".history" => ShellControl::History,
"exit" | ".exit" | "quit" | ".quit" => ShellControl::Exit,
other => ShellControl::Query(other),
}
}
enum ShellControl<'a> {
Help,
Stats,
Refresh,
Clear,
History,
Exit,
Query(&'a str),
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn parse_meta_command_recognises_aliases() {
assert!(matches!(parse_meta_command("help"), ShellControl::Help));
assert!(matches!(parse_meta_command(".stats"), ShellControl::Stats));
assert!(matches!(parse_meta_command("exit"), ShellControl::Exit));
if let ShellControl::Query(query) = parse_meta_command("kind:function") {
assert_eq!(query, "kind:function");
} else {
panic!("expected query variant");
}
}
#[test]
fn ensure_index_exists_validates_presence() {
let temp = tempdir().unwrap();
assert!(ensure_index_exists(temp.path()).is_err());
let index_path = temp.path().join(".sqry-index");
fs::File::create(&index_path).unwrap();
ensure_index_exists(temp.path()).unwrap();
}
}