vapor_cli/
repl.rs

1//! # Interactive REPL Mode
2//!
3//! This module implements the interactive Read-Eval-Print Loop (REPL) for `vapor-cli`.
4//! It provides a command-line interface for users to execute SQL queries and special
5//! commands directly against a SQLite database.
6//!
7//! ## Features:
8//! - **SQL Execution**: Run any valid SQL query.
9//! - **Special Commands**: Dot-prefixed commands for database inspection, output formatting, etc. (e.g., `.tables`, `.schema`).
10//! - **Multi-line Input**: Supports SQL queries that span multiple lines, ending with a semicolon.
11//! - **Command History**: Persists command history between sessions.
12//! - **Transaction Management**: Supports `BEGIN`, `COMMIT`, and `ROLLBACK` with status indicators.
13//! - **Query Bookmarking**: Save, list, and run frequently used queries.
14//! - **Non-Interactive Mode**: Can execute SQL from piped input (e.g., `cat query.sql | vapor-cli repl ...`).
15//! - **Robust Error Handling**: Provides informative error messages and offers to reconnect on critical failures.
16
17use anyhow::{Context, Result};
18use atty::Stream;
19use rusqlite::Connection;
20use rustyline::DefaultEditor;
21use std::io::{Read, Write};
22use std::path::Path;
23use std::sync::{Arc, Mutex};
24
25use crate::bookmarks::BookmarkManager;
26use crate::config;
27use crate::db::list_tables;
28use crate::display::{
29    execute_sql, show_all_schemas, show_database_info, show_table_schema, OutputFormat,
30    QueryOptions,
31};
32use crate::export::{export_to_csv, import_csv_to_table};
33use crate::transactions::TransactionManager;
34
35/// Starts the interactive SQL REPL session.
36///
37/// This is the main entry point for the REPL mode. It sets up the connection to the
38/// specified database, initializes the `rustyline` editor for user input, and enters
39/// a loop to read and process commands. It handles both interactive and non-interactive
40/// (piped) input.
41///
42/// # Arguments
43///
44/// * `db_path` - The file path to the SQLite database.
45///
46/// # Returns
47///
48/// A `Result` which is `Ok(())` when the REPL exits gracefully, or an `Err` with
49/// context if a critical error occurs that cannot be handled.
50pub fn repl_mode(db_path: &str) -> Result<()> {
51    // Convert to absolute path
52    let db_path = std::fs::canonicalize(db_path)
53        .with_context(|| format!("Failed to resolve absolute path for database '{}'", db_path))?
54        .to_str()
55        .ok_or_else(|| anyhow::anyhow!("Database path contains invalid UTF-8 characters"))?
56        .to_string();
57
58    // Validate database exists and is accessible
59    if !Path::new(&db_path).exists() {
60        anyhow::bail!(
61            "Database '{}' does not exist. Use 'vapor-cli init --name {}' to create it.",
62            db_path,
63            db_path.trim_end_matches(".db")
64        );
65    }
66
67    // Verify database integrity before starting REPL
68    verify_database_file(&db_path)?;
69
70    // Connect to the database with retry logic
71    let mut conn = create_robust_connection(&db_path)?;
72
73    // Handle non-interactive mode (piped input)
74    if !atty::is(Stream::Stdin) {
75        return handle_non_interactive_mode(&conn);
76    }
77
78    println!("Connected to database: {}", db_path);
79    println!("REPL with timing, bookmarks, and transaction support");
80    print_help_summary();
81
82    // Initialize REPL components with error handling
83    let mut rl = match DefaultEditor::new() {
84        Ok(editor) => editor,
85        Err(e) => {
86            eprintln!("Warning: Could not initialize readline editor: {}", e);
87            eprintln!("   Falling back to basic input mode.");
88            return handle_basic_repl_mode(&conn);
89        }
90    };
91
92    // Load command history if available
93    let history_path = config::get_repl_history_path()?;
94    if rl.load_history(&history_path).is_err() {
95        // No history file yet is fine
96    }
97
98    let mut multi_line_input = String::new();
99    let last_select_query = Arc::new(Mutex::new(String::new()));
100    let bookmarks = Arc::new(Mutex::new(
101        BookmarkManager::new().with_context(|| "Failed to initialize bookmarks")?,
102    ));
103    let transaction_manager = TransactionManager::new();
104    let mut query_options = QueryOptions::default();
105
106    loop {
107        let prompt = get_prompt(&multi_line_input, &transaction_manager);
108
109        let readline = rl.readline(prompt);
110        match readline {
111            Ok(line) => {
112                let line = line.trim();
113                if line.is_empty() && multi_line_input.is_empty() {
114                    continue;
115                }
116
117                // Handle multi-line input
118                let command_to_execute = handle_multi_line_input(&mut multi_line_input, line);
119
120                if let Some(command) = command_to_execute {
121                    let command_trimmed = command.trim();
122                    let result = if command_trimmed.starts_with('.') {
123                        handle_special_commands(
124                            command_trimmed,
125                            &mut conn,
126                            &db_path,
127                            &bookmarks,
128                            &last_select_query,
129                            &transaction_manager,
130                            &mut query_options,
131                        )
132                    } else {
133                        match transaction_manager.handle_sql_command(&conn, command_trimmed) {
134                            Ok(true) => Ok(()), // Command was handled, do nothing more.
135                            Ok(false) => {
136                                // Not a transaction command, execute normally.
137                                handle_single_line_command(
138                                    command_trimmed,
139                                    &mut conn,
140                                    &transaction_manager,
141                                    &mut query_options,
142                                )
143                            }
144                            Err(e) => Err(e), // Propagate error.
145                        }
146                    };
147
148                    if let Err(e) = result {
149                        print_command_error(&command, &e);
150                        if is_critical_error(&e) {
151                            if !offer_reconnection(&db_path) {
152                                break; // Exit REPL
153                            }
154                        }
155                    }
156                }
157            }
158            Err(rustyline::error::ReadlineError::Interrupted) => {
159                println!("^C");
160                continue;
161            }
162            Err(rustyline::error::ReadlineError::Eof) => {
163                println!("EOF");
164                break;
165            }
166            Err(err) => {
167                eprintln!("Input error: {}", err);
168                eprintln!("Try typing your command again or type 'help' for assistance.");
169                continue;
170            }
171        }
172    }
173
174    // Cleanup on exit
175    cleanup_repl_session(&conn, &transaction_manager, &mut rl, &history_path)?;
176    println!("Goodbye!");
177    Ok(())
178}
179
180fn verify_database_file(db_path: &str) -> Result<()> {
181    let metadata = std::fs::metadata(db_path)
182        .with_context(|| format!("Cannot read database file '{}'", db_path))?;
183
184    if metadata.is_dir() {
185        anyhow::bail!("'{}' is a directory, not a database file", db_path);
186    }
187
188    if metadata.len() == 0 {
189        eprintln!("Warning: Database file '{}' is empty", db_path);
190    }
191
192    Ok(())
193}
194
195fn create_robust_connection(db_path: &str) -> Result<Connection> {
196    let mut last_error = None;
197    let max_retries = 3;
198
199    for attempt in 1..=max_retries {
200        match Connection::open(db_path) {
201            Ok(conn) => {
202                if attempt > 1 {
203                    println!("Connection succeeded on attempt {}", attempt);
204                }
205                return Ok(conn);
206            }
207            Err(e) => {
208                last_error = Some(e);
209                if attempt < max_retries {
210                    println!("Connection attempt {} failed, retrying...", attempt);
211                    std::thread::sleep(std::time::Duration::from_millis(100 * attempt as u64));
212                }
213            }
214        }
215    }
216
217    Err(last_error.unwrap())
218        .with_context(|| format!(
219            "Failed to connect to database '{}' after {} attempts. Database may be locked or corrupted.",
220            db_path, max_retries
221        ))
222}
223
224fn handle_non_interactive_mode(conn: &Connection) -> Result<()> {
225    let mut input = String::new();
226    std::io::stdin().read_to_string(&mut input)?;
227
228    let options = QueryOptions::default();
229    execute_sql(conn, &input, &options)
230}
231
232fn handle_basic_repl_mode(conn: &Connection) -> Result<()> {
233    let mut buffer = String::with_capacity(1024); // Pre-allocate buffer with reasonable capacity
234    let options = QueryOptions::default();
235    let stdout = std::io::stdout();
236    let mut stdout_handle = stdout.lock(); // Lock stdout once instead of multiple times
237
238    loop {
239        stdout_handle.write_all(b"vapor> ")?;
240        stdout_handle.flush()?;
241
242        buffer.clear(); // Clear buffer without deallocating
243        if std::io::stdin().read_line(&mut buffer)? == 0 {
244            break;
245        }
246
247        let line = buffer.trim();
248        if line.is_empty() {
249            continue;
250        }
251
252        if let Err(e) = execute_sql(conn, line, &options) {
253            writeln!(stdout_handle, "Error: {}", e)?;
254        }
255    }
256
257    Ok(())
258}
259
260fn get_prompt(multi_line_input: &str, transaction_manager: &TransactionManager) -> &'static str {
261    if multi_line_input.is_empty() {
262        if transaction_manager.is_active() {
263            "*> "
264        } else {
265            "> "
266        }
267    } else {
268        "... "
269    }
270}
271
272fn handle_multi_line_input(multi_line_input: &mut String, line: &str) -> Option<String> {
273    if !multi_line_input.is_empty() {
274        multi_line_input.push_str(" ");
275        multi_line_input.push_str(line);
276        if line.ends_with(';') {
277            let command = multi_line_input.trim().to_string();
278            multi_line_input.clear();
279            Some(command)
280        } else {
281            None
282        }
283    } else if line.ends_with(';') || is_complete_command(line) {
284        Some(line.to_string())
285    } else {
286        multi_line_input.push_str(line);
287        None
288    }
289}
290
291fn is_complete_command(line: &str) -> bool {
292    let line_lower = line.to_lowercase();
293    // These commands don't need semicolons
294    matches!(
295        line_lower.as_str(),
296        "exit" | "quit" | "help" | "tables" | "clear" | "info"
297    ) || line_lower.starts_with("schema")
298        || line_lower.starts_with(".")
299        || line_lower.starts_with("begin")
300        || line_lower.starts_with("commit")
301        || line_lower.starts_with("rollback")
302        || line_lower.starts_with("drop")
303}
304
305fn print_help_summary() {
306    println!("Vapor CLI - SQLite Database Management");
307    println!("\nSpecial Commands:");
308    println!("  .help              Show this help message");
309    println!("  .tables            List all tables");
310    println!("  .schema [table]    Show schema for all tables or specific table");
311    println!("  .info             Show database information");
312    println!("  .format [type]    Set output format (table, json, csv)");
313    println!("  .limit [n]        Set row limit (0 for no limit)");
314    println!("  .timing           Enable query timing");
315    println!("  .notiming         Disable query timing");
316    println!("  .clear            Clear screen");
317    println!("  .exit/.quit       Exit REPL");
318    println!("\nSQL Commands:");
319    println!("  Enter any valid SQL command ending with semicolon");
320    println!("  Example: SELECT * FROM users;");
321}
322
323fn print_command_error(command: &str, error: &anyhow::Error) {
324    eprintln!("Error executing command '{}':", command);
325    eprintln!("{}", error);
326}
327
328fn is_critical_error(error: &anyhow::Error) -> bool {
329    let error_msg = error.to_string().to_lowercase();
330    error_msg.contains("database is locked")
331        || error_msg.contains("connection")
332        || error_msg.contains("i/o error")
333        || error_msg.contains("disk")
334}
335
336fn offer_reconnection(db_path: &str) -> bool {
337    print!(
338        "Would you like to try reconnecting to '{}'? (y/N): ",
339        db_path
340    );
341    std::io::stdout().flush().unwrap_or(());
342
343    let mut input = String::new();
344    if std::io::stdin().read_line(&mut input).is_ok() {
345        input.trim().to_lowercase().starts_with('y')
346    } else {
347        false
348    }
349}
350
351fn cleanup_repl_session(
352    conn: &Connection,
353    transaction_manager: &TransactionManager,
354    rl: &mut DefaultEditor,
355    history_path: &Path,
356) -> Result<()> {
357    // Rollback any active transaction
358    if transaction_manager.is_active() {
359        println!("Rolling back active transaction...");
360        transaction_manager.rollback_transaction(conn)?;
361    }
362
363    // Save command history
364    if let Err(e) = rl.save_history(history_path) {
365        eprintln!("Warning: Could not save command history: {}", e);
366    }
367
368    Ok(())
369}
370
371fn handle_special_commands(
372    command: &str,
373    conn: &mut Connection,
374    db_path: &str,
375    bookmarks: &Arc<Mutex<BookmarkManager>>,
376    last_select_query: &Arc<Mutex<String>>,
377    transaction_manager: &TransactionManager,
378    query_options: &mut QueryOptions,
379) -> Result<()> {
380    let command = command.trim();
381    let parts: Vec<&str> = command.split_whitespace().collect();
382    let base_command = parts.get(0).cloned().unwrap_or("");
383
384    match base_command {
385        ".help" => show_help(),
386        ".shell" => {
387            println!("Switching to shell mode...");
388            crate::shell::shell_mode(db_path)?;
389            println!("\nReturning to REPL mode.");
390            print_help_summary();
391        }
392        ".exit" | ".quit" => std::process::exit(0),
393        ".tables" => {
394            let tables = list_tables(db_path)?;
395            for table in tables {
396                println!("{}", table);
397            }
398        }
399        ".clear" => {
400            print!("\x1B[2J\x1B[1;1H");
401            std::io::stdout()
402                .flush()
403                .context("Failed to flush stdout")?;
404        }
405        ".info" => show_database_info(conn, db_path)?,
406        ".format" => {
407            if parts.len() > 1 {
408                match parts[1] {
409                    "table" => query_options.format = OutputFormat::Table,
410                    "json" => query_options.format = OutputFormat::Json,
411                    "csv" => query_options.format = OutputFormat::Csv,
412                    _ => println!("Invalid format. Available: table, json, csv"),
413                }
414            } else {
415                println!("Current format: {:?}", query_options.format);
416                println!("Usage: .format [table|json|csv]");
417            }
418        }
419        ".limit" => {
420            if parts.len() > 1 {
421                if let Ok(n) = parts[1].parse::<usize>() {
422                    if n == 0 {
423                        query_options.max_rows = None;
424                        println!("Row limit removed");
425                    } else {
426                        query_options.max_rows = Some(n);
427                        println!("Row limit set to {}", n);
428                    }
429                } else {
430                    println!("Invalid limit value. Use a positive number or 0 for no limit.");
431                }
432            } else {
433                match query_options.max_rows {
434                    None => println!("No row limit set"),
435                    Some(n) => println!("Current row limit: {}", n),
436                }
437            }
438        }
439        ".timing" => {
440            query_options.show_timing = true;
441            println!("Query timing enabled");
442        }
443        ".notiming" => {
444            query_options.show_timing = false;
445            println!("Query timing disabled");
446        }
447        ".export" => {
448            if parts.len() > 1 {
449                let filename = parts[1];
450                let query = last_select_query.lock().unwrap().clone();
451                if query.is_empty() {
452                    println!("No SELECT query has been executed yet.");
453                } else {
454                    export_to_csv(conn, &query, filename)?;
455                }
456            } else {
457                println!("Usage: .export FILENAME");
458            }
459        }
460        ".import" => {
461            if parts.len() >= 3 {
462                import_csv_to_table(conn, parts[1], parts[2])?;
463            } else {
464                println!("Usage: .import CSV_FILENAME TABLE_NAME");
465            }
466        }
467        ".bookmark" => {
468            return handle_bookmark_command(
469                command,
470                bookmarks,
471                last_select_query,
472                conn,
473                query_options,
474            );
475        }
476        ".schema" => {
477            if parts.len() > 1 {
478                show_table_schema(conn, parts[1])?;
479            } else {
480                show_all_schemas(conn)?;
481            }
482        }
483        ".status" => {
484            transaction_manager.show_status();
485        }
486        _ => {
487            println!(
488                "Unknown command: '{}'. Type '.help' for a list of commands.",
489                command
490            );
491        }
492    }
493    Ok(())
494}
495
496fn handle_single_line_command(
497    line: &str,
498    conn: &mut Connection,
499    transaction_manager: &TransactionManager,
500    query_options: &mut QueryOptions,
501) -> Result<()> {
502    let line = line.trim();
503    match line.to_lowercase().as_str() {
504        "begin" | "begin transaction" => transaction_manager.begin_transaction(conn),
505        "commit" | "commit transaction" => transaction_manager.commit_transaction(conn),
506        "rollback" | "rollback transaction" => transaction_manager.rollback_transaction(conn),
507        _ => {
508            // Regular SQL query
509            execute_sql(conn, line, query_options)
510        }
511    }
512}
513
514fn handle_bookmark_command(
515    line: &str,
516    bookmarks: &Arc<Mutex<BookmarkManager>>,
517    last_select_query: &Arc<Mutex<String>>,
518    conn: &mut Connection,
519    query_options: &QueryOptions,
520) -> Result<()> {
521    let parts: Vec<&str> = line.split_whitespace().collect();
522    if parts.len() < 2 {
523        println!("Usage: .bookmark [save|list|run|show|delete] [args...]");
524        return Ok(());
525    }
526
527    let mut bookmarks = bookmarks.lock().unwrap();
528
529    match parts[1] {
530        "save" => {
531            if parts.len() < 3 {
532                println!("Usage: .bookmark save NAME [DESCRIPTION]");
533                return Ok(());
534            }
535            let name = parts[2].to_string();
536            let description = if parts.len() > 3 {
537                Some(parts[3..].join(" "))
538            } else {
539                None
540            };
541            let query = last_select_query.lock().unwrap().clone();
542            if query.is_empty() {
543                println!("No query to save. Execute a query first.");
544            } else {
545                bookmarks.save_bookmark(name.clone(), query, description)?;
546                println!("Bookmark '{}' saved.", name);
547            }
548        }
549        "list" => {
550            bookmarks.list_bookmarks();
551        }
552        "run" => {
553            if parts.len() < 3 {
554                println!("Usage: .bookmark run NAME");
555                return Ok(());
556            }
557            let name = parts[2];
558            if let Some(bookmark) = bookmarks.get_bookmark(name) {
559                println!("Executing bookmark '{}': {}", name, bookmark.query);
560                execute_sql(conn, &bookmark.query, query_options)?;
561            } else {
562                println!("Bookmark '{}' not found.", name);
563            }
564        }
565        "show" => {
566            if parts.len() < 3 {
567                println!("Usage: .bookmark show NAME");
568                return Ok(());
569            }
570            let name = parts[2];
571            if bookmarks.show_bookmark(name).is_none() {
572                println!("Bookmark '{}' not found.", name);
573            }
574        }
575        "delete" => {
576            if parts.len() < 3 {
577                println!("Usage: .bookmark delete NAME");
578                return Ok(());
579            }
580            let name = parts[2];
581            if bookmarks.delete_bookmark(name)? {
582                println!("Bookmark '{}' deleted.", name);
583            } else {
584                println!("Bookmark '{}' not found.", name);
585            }
586        }
587        _ => {
588            println!("Unknown bookmark command. Use: save, list, run, show, or delete");
589        }
590    }
591    Ok(())
592}
593
594/// Displays detailed help information for all REPL commands.
595///
596/// This function prints a comprehensive list of available special commands (`.commands`),
597/// SQL operations, and other features of the REPL to the console, helping users
598/// understand how to interact with the tool.
599pub fn show_help() {
600    println!("Enhanced REPL Commands:");
601    println!();
602    println!("SQL Operations:");
603    println!("  SQL statements - Any valid SQL statement ending with semicolon");
604    println!("  begin/commit/rollback - Transaction control");
605    println!();
606    println!("Database Information:");
607    println!("  tables - List all tables in the database");
608    println!("  schema [table_name] - Show schema for a table or all tables");
609    println!("  info - Show database information and statistics");
610    println!();
611    println!("Output Control:");
612    println!("  .format [table|json|csv] - Set output format (default: table)");
613    println!("  .limit [N] - Set row limit, 0 for no limit (default: 1000)");
614    println!("  .timing [on|off] - Toggle query timing (default: on)");
615    println!("  .export FILENAME - Export last SELECT query to CSV file");
616    println!("  .import CSV_FILENAME TABLE_NAME - Import CSV file into table");
617    println!();
618    println!("Bookmarks:");
619    println!("  .bookmark save NAME [DESC] - Save current query as bookmark");
620    println!("  .bookmark list - List all saved bookmarks");
621    println!("  .bookmark run NAME - Execute a saved bookmark");
622    println!("  .bookmark show NAME - Show bookmark details");
623    println!("  .bookmark delete NAME - Delete a bookmark");
624    println!();
625    println!("Session Management:");
626    println!("  .status - Show transaction status");
627    println!("  clear - Clear the screen");
628    println!("  help - Show this help message");
629    println!("  exit/quit - Exit the REPL");
630    println!();
631    println!("Features:");
632    println!("  • Multi-line input support (continue until semicolon)");
633    println!("  • Command history with arrow keys");
634    println!("  • Query timing and result pagination");
635    println!("  • Transaction status in prompt (* indicates active transaction)");
636    println!("  • Multiple output formats (table, JSON, CSV)");
637    println!("  • Query bookmarking system");
638}