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                // Add to history before extensive trimming or further processing
113                // to save exactly what the user typed, if not empty.
114                // We use line.as_ref() as add_history_entry expects a &str.
115                if !line.trim().is_empty() { // Check if line is not just whitespace before adding
116                    if let Err(err) = rl.add_history_entry(line.as_str()) {
117                        eprintln!("Warning: Could not add to history: {}", err);
118                    }
119                }
120
121                let line = line.trim(); // Now trim for command processing
122                if line.is_empty() && multi_line_input.is_empty() {
123                    continue;
124                }
125
126                // Handle multi-line input
127                let command_to_execute = handle_multi_line_input(&mut multi_line_input, line);
128
129                if let Some(command) = command_to_execute {
130                    let command_trimmed = command.trim();
131                    let result = if command_trimmed.starts_with('.') {
132                        match handle_special_commands(
133                            command_trimmed,
134                            &mut conn,
135                            &db_path,
136                            &bookmarks,
137                            &last_select_query,
138                            &transaction_manager,
139                            &mut query_options,
140                        ) {
141                            Ok(should_continue) => {
142                                if !should_continue {
143                                    break; // Exit the REPL loop
144                                }
145                                Ok(())
146                            }
147                            Err(e) => Err(e), // Propagate other errors
148                        }
149                    } else {
150                        match transaction_manager.handle_sql_command(&conn, command_trimmed) {
151                            Ok(true) => Ok(()), // Command was handled, do nothing more.
152                            Ok(false) => {
153                                // Not a transaction command, execute normally.
154                                handle_single_line_command(
155                                    command_trimmed,
156                                    &mut conn,
157                                    &transaction_manager,
158                                    &mut query_options,
159                                    &last_select_query,
160                                )
161                            }
162                            Err(e) => Err(e), // Propagate error.
163                        }
164                    };
165
166                    if let Err(e) = result {
167                        print_command_error(&command, &e);
168                        if is_critical_error(&e) {
169                            if !offer_reconnection(&db_path) {
170                                break; // Exit REPL
171                            }
172                        }
173                    }
174                }
175            }
176            Err(rustyline::error::ReadlineError::Interrupted) => {
177                println!("^C");
178                continue;
179            }
180            Err(rustyline::error::ReadlineError::Eof) => {
181                println!("EOF");
182                break;
183            }
184            Err(err) => {
185                eprintln!("Input error: {}", err);
186                eprintln!("Try typing your command again or type 'help' for assistance.");
187                continue;
188            }
189        }
190    }
191
192    // Cleanup on exit
193    cleanup_repl_session(&conn, &transaction_manager, &mut rl, &history_path)?;
194    println!("Goodbye!");
195    Ok(())
196}
197
198fn verify_database_file(db_path: &str) -> Result<()> {
199    let metadata = std::fs::metadata(db_path)
200        .with_context(|| format!("Cannot read database file '{}'", db_path))?;
201
202    if metadata.is_dir() {
203        anyhow::bail!("'{}' is a directory, not a database file", db_path);
204    }
205
206    if metadata.len() == 0 {
207        eprintln!("Warning: Database file '{}' is empty", db_path);
208    }
209
210    Ok(())
211}
212
213fn create_robust_connection(db_path: &str) -> Result<Connection> {
214    let mut last_error = None;
215    let max_retries = 3;
216
217    for attempt in 1..=max_retries {
218        match Connection::open(db_path) {
219            Ok(conn) => {
220                if attempt > 1 {
221                    println!("Connection succeeded on attempt {}", attempt);
222                }
223                return Ok(conn);
224            }
225            Err(e) => {
226                last_error = Some(e);
227                if attempt < max_retries {
228                    println!("Connection attempt {} failed, retrying...", attempt);
229                    std::thread::sleep(std::time::Duration::from_millis(100 * attempt as u64));
230                }
231            }
232        }
233    }
234
235    Err(last_error.unwrap())
236        .with_context(|| format!(
237            "Failed to connect to database '{}' after {} attempts. Database may be locked or corrupted.",
238            db_path, max_retries
239        ))
240}
241
242fn handle_non_interactive_mode(conn: &Connection) -> Result<()> {
243    let mut input = String::new();
244    std::io::stdin().read_to_string(&mut input)?;
245    let options = QueryOptions::default(); // Use default options for non-interactive mode
246    let dummy_last_query = Arc::new(Mutex::new(String::new()));
247    execute_sql(conn, &input, &options, &dummy_last_query)
248}
249
250fn handle_basic_repl_mode(conn: &Connection) -> Result<()> {
251    println!("Basic input mode (no history or advanced features).");
252    let mut stdout = std::io::stdout();
253    let options = QueryOptions::default(); // Use default options for basic mode
254    let dummy_last_query = Arc::new(Mutex::new(String::new()));
255
256    loop {
257        print!("> ");
258        stdout.flush()?;
259        let mut line = String::new();
260        if std::io::stdin().read_line(&mut line)? == 0 {
261            println!("EOF");
262            break; // EOF
263        }
264        let line = line.trim();
265        if line == ".exit" || line == "exit" {
266            break;
267        }
268        if !line.is_empty() {
269            if let Err(e) = execute_sql(conn, line, &options, &dummy_last_query) {
270                eprintln!("Error: {}", e);
271            }
272        }
273    }
274    Ok(())
275}
276
277fn get_prompt(multi_line_input: &str, transaction_manager: &TransactionManager) -> &'static str {
278    if multi_line_input.is_empty() {
279        if transaction_manager.is_active() {
280            "*> "
281        } else {
282            "> "
283        }
284    } else {
285        "... "
286    }
287}
288
289fn handle_multi_line_input(multi_line_input: &mut String, line: &str) -> Option<String> {
290    if !multi_line_input.is_empty() {
291        multi_line_input.push_str(" ");
292        multi_line_input.push_str(line);
293        if line.ends_with(';') {
294            let command = multi_line_input.trim().to_string();
295            multi_line_input.clear();
296            Some(command)
297        } else {
298            None
299        }
300    } else if line.ends_with(';') || is_complete_command(line) {
301        Some(line.to_string())
302    } else {
303        multi_line_input.push_str(line);
304        None
305    }
306}
307
308fn is_complete_command(line: &str) -> bool {
309    let line_lower = line.to_lowercase();
310    // These commands don't need semicolons
311    matches!(
312        line_lower.as_str(),
313        "exit" | "quit" | "help" | "tables" | "clear" | "info"
314    ) || line_lower.starts_with("schema")
315        || line_lower.starts_with(".")
316        || line_lower.starts_with("begin")
317        || line_lower.starts_with("commit")
318        || line_lower.starts_with("rollback")
319        || line_lower.starts_with("drop")
320}
321
322fn print_help_summary() {
323    println!("Vapor CLI - SQLite Database Management");
324    println!("\nSpecial Commands:");
325    println!("  .help              Show this help message");
326    println!("  .tables            List all tables");
327    println!("  .schema [table]    Show schema for all tables or specific table");
328    println!("  .info             Show database information");
329    println!("  .format [type]    Set output format (table, json, csv)");
330    println!("  .limit [n]        Set row limit (0 for no limit)");
331    println!("  .timing           Enable query timing");
332    println!("  .notiming         Disable query timing");
333    println!("  .clear            Clear screen");
334    println!("  .exit/.quit       Exit REPL");
335    println!("\nSQL Commands:");
336    println!("  Enter any valid SQL command ending with semicolon");
337    println!("  Example: SELECT * FROM users;");
338}
339
340fn print_command_error(command: &str, error: &anyhow::Error) {
341    eprintln!("Error executing command '{}':", command);
342    eprintln!("{}", error);
343}
344
345fn is_critical_error(error: &anyhow::Error) -> bool {
346    let error_msg = error.to_string().to_lowercase();
347    error_msg.contains("database is locked")
348        || error_msg.contains("connection")
349        || error_msg.contains("i/o error")
350        || error_msg.contains("disk")
351}
352
353fn offer_reconnection(db_path: &str) -> bool {
354    print!(
355        "Would you like to try reconnecting to '{}'? (y/N): ",
356        db_path
357    );
358    std::io::stdout().flush().unwrap_or(());
359
360    let mut input = String::new();
361    if std::io::stdin().read_line(&mut input).is_ok() {
362        input.trim().to_lowercase().starts_with('y')
363    } else {
364        false
365    }
366}
367
368fn cleanup_repl_session(
369    conn: &Connection,
370    transaction_manager: &TransactionManager,
371    rl: &mut DefaultEditor,
372    history_path: &Path,
373) -> Result<()> {
374    // Rollback any active transaction
375    if transaction_manager.is_active() {
376        println!("Rolling back active transaction...");
377        transaction_manager.rollback_transaction(conn)?;
378    }
379
380    // Save command history
381    if let Err(e) = rl.save_history(history_path) {
382        eprintln!("Warning: Could not save command history: {}", e);
383    }
384
385    Ok(())
386}
387
388fn handle_special_commands(
389    command: &str,
390    conn: &mut Connection,
391    db_path: &str,
392    bookmarks: &Arc<Mutex<BookmarkManager>>,
393    last_select_query: &Arc<Mutex<String>>,
394    transaction_manager: &TransactionManager,
395    query_options: &mut QueryOptions,
396) -> Result<bool> {
397    let command = command.trim();
398    let parts: Vec<&str> = command.split_whitespace().collect();
399    let base_command = parts.get(0).cloned().unwrap_or("");
400
401    match base_command {
402        ".help" => {
403            show_help();
404            Ok(true)
405        }
406        ".shell" => {
407            println!("Switching to shell mode...");
408            crate::shell::shell_mode(db_path)?;
409            println!("\nReturning to REPL mode.");
410            print_help_summary();
411            Ok(true)
412        }
413        ".exit" | ".quit" => Ok(false), // Signal to exit REPL
414        ".tables" => {
415            let tables = list_tables(db_path)?;
416            for table in tables {
417                println!("{}", table);
418            }
419            Ok(true)
420        }
421        ".clear" => {
422            print!("\x1B[2J\x1B[1;1H");
423            std::io::stdout()
424                .flush()
425                .context("Failed to flush stdout")?;
426            Ok(true)
427        }
428        ".info" => {
429            show_database_info(conn, db_path)?;
430            Ok(true)
431        }
432        ".format" => {
433            if parts.len() > 1 {
434                match parts[1] {
435                    "table" => query_options.format = OutputFormat::Table,
436                    "json" => query_options.format = OutputFormat::Json,
437                    "csv" => query_options.format = OutputFormat::Csv,
438                    _ => println!("Invalid format. Available: table, json, csv"),
439                }
440            } else {
441                println!("Current format: {:?}", query_options.format);
442                println!("Usage: .format [table|json|csv]");
443            }
444            Ok(true)
445        }
446        ".limit" => {
447            if parts.len() > 1 {
448                if let Ok(n) = parts[1].parse::<usize>() {
449                    if n == 0 {
450                        query_options.max_rows = None;
451                        println!("Row limit removed");
452                    } else {
453                        query_options.max_rows = Some(n);
454                        println!("Row limit set to {}", n);
455                    }
456                } else {
457                    println!("Invalid limit value. Use a positive number or 0 for no limit.");
458                }
459            } else {
460                match query_options.max_rows {
461                    None => println!("No row limit set"),
462                    Some(n) => println!("Current row limit: {}", n),
463                }
464            }
465            Ok(true)
466        }
467        ".timing" => {
468            query_options.show_timing = true;
469            println!("Query timing enabled");
470            Ok(true)
471        }
472        ".notiming" => {
473            query_options.show_timing = false;
474            println!("Query timing disabled");
475            Ok(true)
476        }
477        ".export" => {
478            if parts.len() > 1 {
479                let filename = parts[1];
480                let query = last_select_query.lock().unwrap().clone();
481                if query.is_empty() {
482                    println!("No SELECT query has been executed yet.");
483                } else {
484                    export_to_csv(conn, &query, filename)?;
485                }
486            } else {
487                println!("Usage: .export FILENAME");
488            }
489            Ok(true)
490        }
491        ".import" => {
492            if parts.len() >= 3 {
493                import_csv_to_table(conn, parts[1], parts[2])?;
494            } else {
495                println!("Usage: .import CSV_FILENAME TABLE_NAME");
496            }
497            Ok(true)
498        }
499        ".bookmark" => {
500            handle_bookmark_command(
501                command,
502                bookmarks,
503                last_select_query,
504                conn,
505                query_options,
506            )?;
507            Ok(true)
508        }
509        ".schema" => {
510            if parts.len() > 1 {
511                show_table_schema(conn, parts[1])?;
512            } else {
513                show_all_schemas(conn)?;
514            }
515            Ok(true)
516        }
517        ".status" => {
518            transaction_manager.show_status();
519            Ok(true)
520        }
521        _ => {
522            println!(
523                "Unknown command: '{}'. Type '.help' for a list of commands.",
524                command
525            );
526            Ok(true)
527        }
528    }
529}
530
531fn handle_single_line_command(
532    line: &str,
533    conn: &mut Connection,
534    transaction_manager: &TransactionManager,
535    query_options: &mut QueryOptions,
536    last_select_query: &Arc<Mutex<String>>,
537) -> Result<()> {
538    let line = line.trim();
539    match line.to_lowercase().as_str() {
540        "begin" | "begin transaction" => transaction_manager.begin_transaction(conn),
541        "commit" | "commit transaction" => transaction_manager.commit_transaction(conn),
542        "rollback" | "rollback transaction" => transaction_manager.rollback_transaction(conn),
543        _ => {
544            // Regular SQL query
545            execute_sql(conn, line, query_options, last_select_query)
546        }
547    }
548}
549
550fn handle_bookmark_command(
551    line: &str,
552    bookmarks: &Arc<Mutex<BookmarkManager>>,
553    last_select_query: &Arc<Mutex<String>>,
554    conn: &mut Connection,
555    query_options: &QueryOptions,
556) -> Result<()> {
557    let parts: Vec<&str> = line.split_whitespace().collect();
558    if parts.len() < 2 {
559        println!("Usage: .bookmark [save|list|run|show|delete] [args...]");
560        return Ok(());
561    }
562
563    let mut bookmarks = bookmarks.lock().unwrap();
564
565    match parts[1] {
566        "save" => {
567            if parts.len() < 3 {
568                println!("Usage: .bookmark save NAME [DESCRIPTION]");
569                return Ok(());
570            }
571            let name = parts[2].to_string();
572            let description = if parts.len() > 3 {
573                Some(parts[3..].join(" "))
574            } else {
575                None
576            };
577            let query = last_select_query.lock().unwrap().clone();
578            if query.is_empty() {
579                println!("No query to save. Execute a query first.");
580            } else {
581                bookmarks.save_bookmark(name.clone(), query, description)?;
582                println!("Bookmark '{}' saved.", name);
583            }
584        }
585        "list" => {
586            bookmarks.list_bookmarks();
587        }
588        "run" => {
589            if parts.len() < 3 {
590                println!("Usage: .bookmark run NAME");
591                return Ok(());
592            }
593            let name = parts[2];
594            if let Some(bookmark) = bookmarks.get_bookmark(name) {
595                println!("Executing bookmark '{}': {}", name, bookmark.query);
596                execute_sql(conn, &bookmark.query, query_options, last_select_query)?;
597            } else {
598                println!("Bookmark '{}' not found.", name);
599            }
600        }
601        "show" => {
602            if parts.len() < 3 {
603                println!("Usage: .bookmark show NAME");
604                return Ok(());
605            }
606            let name = parts[2];
607            if bookmarks.show_bookmark(name).is_none() {
608                println!("Bookmark '{}' not found.", name);
609            }
610        }
611        "delete" => {
612            if parts.len() < 3 {
613                println!("Usage: .bookmark delete NAME");
614                return Ok(());
615            }
616            let name = parts[2];
617            if bookmarks.delete_bookmark(name)? {
618                println!("Bookmark '{}' deleted.", name);
619            } else {
620                println!("Bookmark '{}' not found.", name);
621            }
622        }
623        _ => {
624            println!("Unknown bookmark command. Use: save, list, run, show, or delete");
625        }
626    }
627    Ok(())
628}
629
630/// Displays detailed help information for all REPL commands.
631///
632/// This function prints a comprehensive list of available special commands (`.commands`),
633/// SQL operations, and other features of the REPL to the console, helping users
634/// understand how to interact with the tool.
635pub fn show_help() {
636    println!("Enhanced REPL Commands:");
637    println!();
638    println!("SQL Operations:");
639    println!("  SQL statements - Any valid SQL statement ending with semicolon");
640    println!("  begin/commit/rollback - Transaction control");
641    println!();
642    println!("Database Information:");
643    println!("  tables - List all tables in the database");
644    println!("  schema [table_name] - Show schema for a table or all tables");
645    println!("  info - Show database information and statistics");
646    println!();
647    println!("Output Control:");
648    println!("  .format [table|json|csv] - Set output format (default: table)");
649    println!("  .limit [N] - Set row limit, 0 for no limit (default: 1000)");
650    println!("  .timing [on|off] - Toggle query timing (default: on)");
651    println!("  .export FILENAME - Export last SELECT query to CSV file");
652    println!("  .import CSV_FILENAME TABLE_NAME - Import CSV file into table");
653    println!();
654    println!("Bookmarks:");
655    println!("  .bookmark save NAME [DESC] - Save current query as bookmark");
656    println!("  .bookmark list - List all saved bookmarks");
657    println!("  .bookmark run NAME - Execute a saved bookmark");
658    println!("  .bookmark show NAME - Show bookmark details");
659    println!("  .bookmark delete NAME - Delete a bookmark");
660    println!();
661    println!("Session Management:");
662    println!("  .status - Show transaction status");
663    println!("  clear - Clear the screen");
664    println!("  help - Show this help message");
665    println!("  exit/quit - Exit the REPL");
666    println!();
667    println!("Features:");
668    println!("  • Multi-line input support (continue until semicolon)");
669    println!("  • Command history with arrow keys");
670    println!("  • Query timing and result pagination");
671    println!("  • Transaction status in prompt (* indicates active transaction)");
672    println!("  • Multiple output formats (table, JSON, CSV)");
673    println!("  • Query bookmarking system");
674}