vapor_cli/
shell.rs

1//! # Interactive Shell Mode
2//!
3//! This module implements an interactive shell for `vapor-cli`, providing a different
4//! mode of operation from the SQL REPL. The shell allows users to run standard system
5//! commands and a few built-in commands, all while maintaining the context of the
6//! connected database.
7//!
8//! ## Features:
9//! - **System Command Execution**: Run any command available in the system's `PATH`.
10//! - **Built-in Commands**: Includes `cd`, `pwd`, `history`, and `help`.
11//! - **Database Context**: The shell is aware of the connected database, which can be referenced via `.dbinfo`.
12//! - **REPL Integration**: Seamlessly switch back to the SQL REPL using the `.vrepl` command.
13//! - **Command Completion**: Provides basic completion for built-in commands and file paths.
14//! - **Persistent History**: Saves shell command history across sessions.
15
16use crate::config;
17use anyhow::{Context, Result};
18use ctrlc;
19use rustyline::completion::{Completer, FilenameCompleter};
20use rustyline::error::ReadlineError;
21use rustyline::highlight::Highlighter;
22use rustyline::hint::Hinter;
23use rustyline::validate::Validator;
24use rustyline::{Editor, Helper};
25use std::env;
26use std::io::{self};
27use std::path::Path;
28use std::process::Command;
29
30/// Defines the possible actions that can be returned from the shell session.
31/// This is used to signal whether the user wants to exit the application entirely
32/// or switch back to the SQL REPL.
33#[derive(Debug, PartialEq, Eq)]
34pub enum ShellAction {
35    Exit,
36    SwitchToRepl,
37}
38
39const BUILTIN_COMMANDS: &[&str] = &["cd", "pwd", "history", "help", "exit", ".vrepl", ".dbinfo"];
40
41struct ShellHelper {
42    filename_completer: FilenameCompleter,
43}
44
45impl Helper for ShellHelper {}
46
47impl Completer for ShellHelper {
48    type Candidate = String;
49
50    fn complete(
51        &self,
52        line: &str,
53        pos: usize,
54        _ctx: &rustyline::Context<'_>,
55    ) -> rustyline::Result<(usize, Vec<String>)> {
56        let line = &line[..pos];
57        let words: Vec<&str> = line.split_whitespace().collect();
58
59        if words.is_empty() {
60            // Complete with built-in commands
61            return Ok((0, BUILTIN_COMMANDS.iter().map(|&s| s.to_string()).collect()));
62        }
63
64        if words.len() == 1 {
65            // First word - complete with commands
66            let prefix = words[0];
67            let candidates: Vec<String> = BUILTIN_COMMANDS
68                .iter()
69                .filter(|&&cmd| cmd.starts_with(prefix))
70                .map(|&s| s.to_string())
71                .collect();
72
73            if !candidates.is_empty() {
74                return Ok((0, candidates));
75            }
76        }
77
78        // For other cases, use filename completion
79        if line.trim_start().starts_with("cd ") || line.trim_start().starts_with("ls ") {
80            let (start, pairs) = self.filename_completer.complete(line, pos, _ctx)?;
81            let candidates: Vec<String> = pairs.into_iter().map(|p| p.replacement).collect();
82            return Ok((start, candidates));
83        }
84
85        Ok((pos, Vec::new()))
86    }
87}
88
89impl Highlighter for ShellHelper {}
90
91impl Hinter for ShellHelper {
92    type Hint = String;
93}
94
95impl Validator for ShellHelper {}
96
97/// Represents the state of the interactive shell.
98///
99/// This struct holds the `rustyline` editor instance, the original directory from which
100/// the shell was started, the path to the history file, and the path to the connected
101/// database.
102pub struct Shell {
103    editor: Editor<ShellHelper, rustyline::history::FileHistory>,
104    original_dir: std::path::PathBuf,
105    history_path: std::path::PathBuf,
106    db_path: String, // To store the database path
107}
108
109impl Shell {
110        /// Creates a new `Shell` instance.
111    ///
112    /// This function initializes the `rustyline` editor, sets up the command completer,
113    /// loads the command history, and establishes a Ctrl+C handler.
114    ///
115    /// # Arguments
116    ///
117    /// * `db_path` - The path to the database file, which is kept for context.
118    ///
119    /// # Returns
120    ///
121    /// A `Result` containing the new `Shell` instance, or an `Err` if initialization fails.
122    pub fn new(db_path: &str) -> Result<Self> {
123        let helper = ShellHelper {
124            filename_completer: FilenameCompleter::new(),
125        };
126
127        let mut editor = Editor::new().unwrap();
128        editor.set_helper(Some(helper));
129
130        let original_dir = env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
131
132        // Set up history path in home directory
133        let history_path = config::get_shell_history_path()?;
134
135        // Load history if available
136        if history_path.exists() {
137            if let Err(e) = editor.load_history(&history_path) {
138                eprintln!("Warning: Could not load shell history: {}", e);
139            }
140        }
141
142        // Set up Ctrl+C handler
143        if let Err(e) = ctrlc::set_handler(move || {
144            println!("\nUse 'exit' to return to the REPL");
145        }) {
146            eprintln!("Warning: Could not set up Ctrl+C handler: {}", e);
147        }
148
149        Ok(Shell {
150            editor,
151            original_dir,
152            history_path,
153            db_path: db_path.to_string(),
154        })
155    }
156
157    fn get_prompt(&self) -> String {
158        let cwd = env::current_dir().unwrap_or_default();
159        let home = env::var("HOME")
160            .map(std::path::PathBuf::from)
161            .unwrap_or_default();
162
163        let display_path = if cwd == home {
164            "~".to_string()
165        } else if let Ok(stripped) = cwd.strip_prefix(&home) {
166            format!("~/{}", stripped.display())
167        } else {
168            cwd.display().to_string()
169        };
170
171        format!("[vapor-shell {}]$ ", display_path)
172    }
173
174        /// Runs the main loop of the shell.
175    ///
176    /// This function displays the prompt, reads user input, and executes the entered
177    /// commands. It handles both built-in commands and external system commands.
178    /// The loop continues until the user enters `exit` or `.vrepl`.
179    ///
180    /// # Returns
181    ///
182    /// A `ShellAction` indicating the user's intent to either exit the application
183    /// or switch back to the REPL.
184    pub fn run(&mut self) -> ShellAction {
185        println!("Welcome to Vapor Shell! Type 'exit' to return to the REPL.");
186        println!("Type 'help' for available commands.");
187
188        loop {
189            let prompt = self.get_prompt();
190            let readline = self.editor.readline(&prompt);
191            match readline {
192                Ok(line) => {
193                    let line = line.trim();
194                    if line.is_empty() {
195                        continue;
196                    }
197
198                    if let Err(e) = self.editor.add_history_entry(line) {
199                        eprintln!("Warning: Could not add to history: {}", e);
200                    }
201
202                    if line == "exit" {
203                        return ShellAction::Exit;
204                    }
205
206                    if line == ".vrepl" {
207                        return ShellAction::SwitchToRepl;
208                    }
209
210                    if line == ".dbinfo" {
211                        println!("Connected to database: {}", self.db_path);
212                        continue;
213                    }
214
215                    if line == "help" {
216                        self.show_help();
217                        continue;
218                    }
219
220                    self.execute_command(line);
221                }
222                Err(ReadlineError::Interrupted) => {
223                    println!("^C");
224                    continue;
225                }
226                Err(ReadlineError::Eof) => {
227                    return ShellAction::Exit; // Treat EOF as a normal exit
228                }
229                Err(err) => {
230                    eprintln!("Input error: {}", err);
231                    continue;
232                }
233            }
234        }
235    }
236
237    fn execute_command(&mut self, command: &str) {
238        let parts: Vec<&str> = command.split_whitespace().collect();
239        if parts.is_empty() {
240            return;
241        }
242
243        match parts[0] {
244            "cd" => {
245                let path = if parts.len() > 1 {
246                    let p = parts[1];
247                    if p == "~" {
248                        env::var("HOME").unwrap_or_else(|_| ".".to_string())
249                    } else if p.starts_with("~/") {
250                        env::var("HOME")
251                            .map(|home| format!("{}/{}", home, &p[2..]))
252                            .unwrap_or_else(|_| p.to_string())
253                    } else {
254                        p.to_string()
255                    }
256                } else {
257                    env::var("HOME").unwrap_or_else(|_| ".".to_string())
258                };
259
260                if let Err(e) = env::set_current_dir(Path::new(&path)) {
261                    eprintln!("cd: {}: {}", path, e);
262                }
263            }
264            "pwd" => {
265                if let Ok(current_dir) = env::current_dir() {
266                    println!("{}", current_dir.display());
267                }
268            }
269            "history" => {
270                for (i, entry) in self.editor.history().iter().enumerate() {
271                    println!("{}: {}", i + 1, entry);
272                }
273            }
274            _ => {
275                let status = Command::new(parts[0]).args(&parts[1..]).status();
276
277                match status {
278                    Ok(status) => {
279                        if !status.success() {
280                            if let Some(code) = status.code() {
281                                eprintln!("Command failed with exit code: {}", code);
282                            } else {
283                                eprintln!("Command terminated by signal");
284                            }
285                        }
286                    }
287                    Err(e) => {
288                        eprintln!("Error executing command: {}", e);
289                        if e.kind() == io::ErrorKind::NotFound {
290                            eprintln!("Command not found: {}", parts[0]);
291                        }
292                    }
293                }
294            }
295        }
296    }
297
298    fn show_help(&self) {
299        println!("Vapor Shell - Available Commands:");
300        println!("  .vrepl         - Switch back to the SQL REPL");
301        println!("  .dbinfo        - Show information about the connected database");
302        println!("  cd <dir>       - Change directory");
303        println!("  ls [dir]       - List directory contents");
304        println!("  pwd            - Print working directory");
305        println!("  history        - Show command history");
306        println!("  help           - Show this help message");
307        println!("  exit           - Exit the shell and return to the REPL");
308    }
309
310    fn save_history(&mut self) -> Result<()> {
311        self.editor
312            .save_history(&self.history_path)
313            .context("Failed to save shell history")
314    }
315}
316
317/// Starts the interactive shell mode.
318///
319/// This function initializes and runs the `Shell`. It ensures that the command history
320/// is saved and the original working directory is restored after the shell session ends.
321///
322/// # Arguments
323///
324/// * `db_path` - The path to the database file, which provides context to the shell.
325///
326/// # Returns
327///
328/// A `Result` containing the `ShellAction` that indicates the next step for the calling code
329/// (e.g., exit or switch to REPL).
330pub fn shell_mode(db_path: &str) -> Result<ShellAction> {
331    println!("Starting shell mode for database: {}", db_path);
332
333    let mut shell = Shell::new(db_path)?;
334    let action = shell.run();
335
336    // Save history before exiting
337    if let Err(e) = shell.save_history() {
338        eprintln!("Warning: Could not save shell history: {}", e);
339    }
340
341    // Restore original directory
342    if let Err(e) = env::set_current_dir(&shell.original_dir) {
343        eprintln!("Warning: Could not restore original directory: {}", e);
344    }
345
346    Ok(action)
347}