oxur_cli/repl/
terminal.rs

1//! Terminal interface for REPL interaction
2//!
3//! Provides line editing, command history, and terminal handling
4//! using reedline.
5
6use crate::config::{paths, EditMode, HistoryConfig, TerminalConfig};
7use anyhow::{Context, Result};
8use crossterm::{execute, terminal};
9use reedline::{
10    default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,
11    ColumnarMenu, Emacs, FileBackedHistory, KeyCode, KeyModifiers, Keybindings, MenuBuilder,
12    Reedline, ReedlineEvent, ReedlineMenu, Signal, Vi,
13};
14use std::io;
15use std::path::PathBuf;
16
17use crate::repl::completer::OxurCompleter;
18use crate::repl::oxur_prompt::OxurPrompt;
19use crate::repl::pager;
20use crate::repl::sexp_highlighter::SExpHighlighter;
21use crate::repl::sexp_validator::SExpValidator;
22
23/// Extract version info from version string (without tool name)
24///
25/// Converts "rustc 1.75.0 (hash)" to "1.75.0 (hash)", or "cargo 1.75.0 (date)" to "1.75.0 (date)"
26/// Keeps the full version string but removes the tool name prefix.
27fn format_version(version_str: &str) -> String {
28    // Split by whitespace and skip the first element (tool name)
29    // e.g., "rustc 1.75.0 (82e1608df 2023-12-21)" -> "1.75.0 (82e1608df 2023-12-21)"
30    let parts: Vec<&str> = version_str.split_whitespace().collect();
31    if parts.len() > 1 {
32        parts[1..].join(" ")
33    } else {
34        version_str.to_string()
35    }
36}
37
38/// Substitute version placeholders in banner text
39///
40/// Replaces template placeholders with actual version information:
41/// - `N.N.N` → Oxur version (e.g., "0.1.0")
42/// - `M.M.M` → Rust version info (e.g., "1.75.0 (82e1608df 2023-12-21)")
43/// - `L.L.L` → Cargo version info (e.g., "1.75.0 (1d8b05cdd 2023-11-20)")
44fn substitute_banner_versions(
45    banner: &str,
46    metadata: &oxur_repl::metadata::SystemMetadata,
47) -> String {
48    banner
49        .replace("N.N.N", &metadata.oxur_version)
50        .replace("M.M.M", &format_version(&metadata.rust_version))
51        .replace("L.L.L", &format_version(&metadata.cargo_version))
52}
53
54/// Add Tab keybinding for completion menu
55fn add_completion_keybinding(keybindings: &mut Keybindings) {
56    keybindings.add_binding(
57        KeyModifiers::NONE,
58        KeyCode::Tab,
59        ReedlineEvent::UntilFound(vec![
60            ReedlineEvent::Menu("completion_menu".to_string()),
61            ReedlineEvent::MenuNext,
62        ]),
63    );
64}
65
66/// REPL terminal interface with line editing and history
67pub struct ReplTerminal {
68    editor: Reedline,
69    #[allow(dead_code)] // Kept for API compatibility and future use
70    history_path: PathBuf,
71    terminal_config: TerminalConfig,
72}
73
74impl ReplTerminal {
75    /// Create a new REPL terminal with configuration
76    ///
77    /// # Arguments
78    ///
79    /// * `terminal_config` - Terminal appearance configuration
80    /// * `history_config` - Command history configuration
81    ///
82    /// # Errors
83    ///
84    /// Returns error if reedline initialization fails.
85    pub fn with_config(
86        terminal_config: TerminalConfig,
87        history_config: HistoryConfig,
88    ) -> Result<Self> {
89        // Convert edit mode to reedline's EditMode trait object with Tab completion
90        let edit_mode: Box<dyn reedline::EditMode> = match terminal_config.edit_mode {
91            EditMode::Emacs => {
92                let mut keybindings = default_emacs_keybindings();
93                add_completion_keybinding(&mut keybindings);
94                Box::new(Emacs::new(keybindings))
95            }
96            EditMode::Vi => {
97                let mut insert_keybindings = default_vi_insert_keybindings();
98                let mut normal_keybindings = default_vi_normal_keybindings();
99                add_completion_keybinding(&mut insert_keybindings);
100                add_completion_keybinding(&mut normal_keybindings);
101                Box::new(Vi::new(insert_keybindings, normal_keybindings))
102            }
103        };
104
105        // Determine history file path
106        let history_path = history_config.path.unwrap_or_else(paths::default_history_path);
107
108        // Create history backend
109        // Note: FileBackedHistory is used for both enabled and disabled cases.
110        // When disabled, we use a temp path that won't persist between sessions.
111        let history_path_for_backend = if history_config.enabled {
112            history_path.clone()
113        } else {
114            // Use a temporary path that won't be loaded or saved
115            std::env::temp_dir().join("oxur-repl-temp-history")
116        };
117
118        let history = Box::new(
119            FileBackedHistory::with_file(
120                history_config.max_size.unwrap_or(10000),
121                history_path_for_backend,
122            )
123            .context("Failed to create history backend")?,
124        );
125
126        // Create completion menu
127        let completion_menu = ColumnarMenu::default()
128            .with_name("completion_menu")
129            .with_columns(4)
130            .with_column_width(Some(20))
131            .with_column_padding(2);
132
133        // Build reedline editor with syntax highlighting, validation, and completion
134        let editor = Reedline::create()
135            .with_history(history)
136            .with_edit_mode(edit_mode)
137            .with_highlighter(Box::new(SExpHighlighter::new(terminal_config.color_enabled)))
138            .with_validator(Box::new(SExpValidator::new()))
139            .with_completer(Box::new(OxurCompleter::new()))
140            .with_menu(ReedlineMenu::EngineCompleter(Box::new(completion_menu)));
141
142        Ok(Self { editor, history_path, terminal_config })
143    }
144
145    /// Read a line of input from the user
146    ///
147    /// Returns:
148    /// - `Ok(Some(line))` - User entered a line
149    /// - `Ok(None)` - User pressed Ctrl-C (interrupt)
150    /// - `Err(_)` - User pressed Ctrl-D (exit) or other error
151    pub fn read_line(&mut self, prompt: &str) -> Result<Option<String>> {
152        let oxur_prompt = OxurPrompt::new(
153            prompt.to_string(),
154            self.terminal_config.formatted_continuation_prompt(),
155        );
156
157        match self.editor.read_line(&oxur_prompt) {
158            Ok(Signal::Success(line)) => Ok(Some(line)),
159            Ok(Signal::CtrlC) => Ok(None),
160            Ok(Signal::CtrlD) => Err(anyhow::anyhow!("EOF")),
161            Err(e) => Err(anyhow::anyhow!("Input error: {}", e)),
162        }
163    }
164
165    /// Read a line using the default prompt
166    pub fn read_line_default(&mut self) -> Result<Option<String>> {
167        let prompt = self.prompt();
168        self.read_line(&prompt)
169    }
170
171    /// Get the formatted prompt string
172    pub fn prompt(&self) -> String {
173        self.terminal_config.formatted_prompt()
174    }
175
176    /// Get the formatted continuation prompt for multi-line input
177    #[allow(dead_code)]
178    pub fn continuation_prompt(&self) -> String {
179        self.terminal_config.formatted_continuation_prompt()
180    }
181
182    /// Save command history to disk
183    ///
184    /// With FileBackedHistory, history is automatically saved.
185    /// This method is retained for API compatibility.
186    pub fn save_history(&mut self) -> Result<()> {
187        // FileBackedHistory auto-saves - this is a no-op
188        Ok(())
189    }
190
191    /// Check if colors are enabled
192    #[allow(dead_code)]
193    pub fn color_enabled(&self) -> bool {
194        self.terminal_config.color_enabled
195    }
196
197    /// Print an error message with appropriate formatting
198    pub fn print_error(&self, msg: &str) {
199        if self.terminal_config.color_enabled {
200            eprintln!("\x1b[31mError:\x1b[0m {}", msg);
201        } else {
202            eprintln!("Error: {}", msg);
203        }
204    }
205
206    /// Print a result value with appropriate formatting
207    pub fn print_result(&self, value: &str) {
208        if self.terminal_config.color_enabled {
209            println!("\x1b[36m{}\x1b[0m", value);
210        } else {
211            println!("{}", value);
212        }
213    }
214
215    /// Print output (stdout from evaluation)
216    pub fn print_output(&self, output: &str) {
217        print!("{}", output);
218    }
219
220    /// Print help content with appropriate formatting
221    ///
222    /// Automatically pages content if it exceeds terminal height.
223    pub fn print_help(&self, content: &str) {
224        if let Err(e) = pager::page_text(content) {
225            // Fallback to direct print if paging fails
226            eprintln!("Warning: Pager failed ({}), printing directly", e);
227            println!("{}", content);
228        }
229    }
230
231    /// Print the welcome banner with system metadata
232    pub fn print_banner(&self, metadata: &oxur_repl::metadata::SystemMetadata) {
233        if let Some(ref banner) = self.terminal_config.banner {
234            // Custom banner with version substitution
235            let banner_with_versions = substitute_banner_versions(banner, metadata);
236            println!("{}", banner_with_versions);
237        } else {
238            // Default banner with version information
239            if self.terminal_config.color_enabled {
240                println!(
241                    "\x1b[1mOxur REPL\x1b[0m v{} | \x1b[90mRust: {} | Cargo: {}\x1b[0m",
242                    metadata.oxur_version,
243                    format_version(&metadata.rust_version),
244                    format_version(&metadata.cargo_version)
245                );
246            } else {
247                println!(
248                    "Oxur REPL v{} | Rust: {} | Cargo: {}",
249                    metadata.oxur_version,
250                    format_version(&metadata.rust_version),
251                    format_version(&metadata.cargo_version)
252                );
253            }
254            println!("Type (help) for assistance, Ctrl-D to exit.");
255        }
256        println!();
257    }
258
259    /// Print a goodbye message
260    pub fn print_goodbye(&self) {
261        println!();
262        if self.terminal_config.color_enabled {
263            println!("\x1b[33mGoodbye!\x1b[0m");
264        } else {
265            println!("Goodbye!");
266        }
267    }
268
269    /// Clear the terminal screen and move cursor to top
270    pub fn clear_screen(&self) -> Result<()> {
271        execute!(io::stdout(), terminal::Clear(terminal::ClearType::All))?;
272        execute!(io::stdout(), crossterm::cursor::MoveTo(0, 0))?;
273        Ok(())
274    }
275
276    /// Get the terminal configuration
277    pub fn config(&self) -> &TerminalConfig {
278        &self.terminal_config
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_default_history_path() {
288        let path = paths::default_history_path();
289        assert!(path.ends_with("repl_history"));
290    }
291
292    #[test]
293    fn test_terminal_config_prompt() {
294        let config = TerminalConfig::builder().prompt("test> ").color(false).build();
295        assert_eq!(config.formatted_prompt(), "test> ");
296    }
297
298    #[test]
299    #[serial_test::serial]
300    fn test_terminal_config_colored_prompt() {
301        // Force colors on for testing
302        colored::control::set_override(true);
303
304        // Non-oxur prompt uses standard green
305        let config = TerminalConfig::builder().prompt("test> ").color(true).build();
306        let test_prompt = config.formatted_prompt();
307        assert_ne!(test_prompt, "test> ");
308        assert!(test_prompt.contains("\x1b["));
309        assert!(test_prompt.contains("test> "));
310
311        // oxur prompt uses special coloring (bright yellow, yellow, bright red, dark red, bright green)
312        let oxur_config = TerminalConfig::builder().prompt("oxur> ").color(true).build();
313        let oxur_prompt = oxur_config.formatted_prompt();
314        // Colored output should be different from plain text
315        assert_ne!(oxur_prompt, "oxur> ");
316        // Should contain ANSI escape codes
317        assert!(oxur_prompt.contains("\x1b["));
318        // Should contain all letters
319        assert!(oxur_prompt.contains("o"));
320        assert!(oxur_prompt.contains("x"));
321        assert!(oxur_prompt.contains("u"));
322        assert!(oxur_prompt.contains("r"));
323
324        // Reset color override
325        colored::control::unset_override();
326    }
327
328    #[test]
329    fn test_continuation_prompt() {
330        let config = TerminalConfig::builder().continuation_prompt("... ").color(false).build();
331        assert_eq!(config.formatted_continuation_prompt(), "... ");
332    }
333
334    #[test]
335    fn test_custom_banner() {
336        let config = TerminalConfig::builder().banner("Custom Welcome!").build();
337        assert_eq!(config.banner, Some("Custom Welcome!".to_string()));
338    }
339
340    #[test]
341    fn test_format_version_rustc() {
342        let version = "rustc 1.75.0 (82e1608df 2023-12-21)";
343        assert_eq!(format_version(version), "1.75.0 (82e1608df 2023-12-21)");
344    }
345
346    #[test]
347    fn test_format_version_cargo() {
348        let version = "cargo 1.75.0 (1d8b05cdd 2023-11-20)";
349        assert_eq!(format_version(version), "1.75.0 (1d8b05cdd 2023-11-20)");
350    }
351
352    #[test]
353    fn test_format_version_unknown() {
354        let version = "unknown";
355        assert_eq!(format_version(version), "unknown");
356    }
357
358    #[test]
359    fn test_substitute_banner_versions() {
360        let banner = "oxur: N.N.N\nrustc: M.M.M\ncargo: L.L.L";
361        let metadata = oxur_repl::metadata::SystemMetadata {
362            oxur_version: "0.1.0".to_string(),
363            rust_version: "rustc 1.75.0 (82e1608df 2023-12-21)".to_string(),
364            cargo_version: "cargo 1.75.0 (1d8b05cdd 2023-11-20)".to_string(),
365            os_name: "Test".to_string(),
366            os_version: "1.0".to_string(),
367            arch: "x86_64".to_string(),
368            hostname: "test".to_string(),
369            pid: 1234,
370            cwd: std::path::PathBuf::from("/test"),
371            started_at: std::time::SystemTime::now(),
372        };
373
374        let result = substitute_banner_versions(banner, &metadata);
375        assert!(result.contains("oxur: 0.1.0"));
376        assert!(result.contains("rustc: 1.75.0 (82e1608df 2023-12-21)"));
377        assert!(result.contains("cargo: 1.75.0 (1d8b05cdd 2023-11-20)"));
378        assert!(!result.contains("N.N.N"));
379        assert!(!result.contains("M.M.M"));
380        assert!(!result.contains("L.L.L"));
381    }
382
383    // ===== Additional coverage tests =====
384
385    #[test]
386    fn test_format_version_empty() {
387        let version = "";
388        assert_eq!(format_version(version), "");
389    }
390
391    #[test]
392    fn test_format_version_single_word() {
393        let version = "1.75.0";
394        assert_eq!(format_version(version), "1.75.0");
395    }
396
397    #[test]
398    fn test_format_version_many_parts() {
399        let version = "tool 1.0.0 extra info here";
400        assert_eq!(format_version(version), "1.0.0 extra info here");
401    }
402
403    #[test]
404    fn test_substitute_banner_no_placeholders() {
405        let banner = "Welcome to the REPL!";
406        let metadata = oxur_repl::metadata::SystemMetadata {
407            oxur_version: "0.1.0".to_string(),
408            rust_version: "rustc 1.75.0".to_string(),
409            cargo_version: "cargo 1.75.0".to_string(),
410            os_name: "Test".to_string(),
411            os_version: "1.0".to_string(),
412            arch: "x86_64".to_string(),
413            hostname: "test".to_string(),
414            pid: 1234,
415            cwd: std::path::PathBuf::from("/test"),
416            started_at: std::time::SystemTime::now(),
417        };
418
419        let result = substitute_banner_versions(banner, &metadata);
420        assert_eq!(result, "Welcome to the REPL!");
421    }
422
423    #[test]
424    fn test_substitute_banner_partial_placeholders() {
425        let banner = "Oxur N.N.N only";
426        let metadata = oxur_repl::metadata::SystemMetadata {
427            oxur_version: "0.2.0".to_string(),
428            rust_version: "rustc 1.76.0".to_string(),
429            cargo_version: "cargo 1.76.0".to_string(),
430            os_name: "Test".to_string(),
431            os_version: "1.0".to_string(),
432            arch: "x86_64".to_string(),
433            hostname: "test".to_string(),
434            pid: 1234,
435            cwd: std::path::PathBuf::from("/test"),
436            started_at: std::time::SystemTime::now(),
437        };
438
439        let result = substitute_banner_versions(banner, &metadata);
440        assert_eq!(result, "Oxur 0.2.0 only");
441    }
442
443    #[test]
444    fn test_add_completion_keybinding() {
445        let mut keybindings = default_emacs_keybindings();
446        // Should not panic
447        add_completion_keybinding(&mut keybindings);
448    }
449
450    #[test]
451    fn test_add_completion_keybinding_vi_insert() {
452        let mut keybindings = default_vi_insert_keybindings();
453        add_completion_keybinding(&mut keybindings);
454    }
455
456    #[test]
457    fn test_add_completion_keybinding_vi_normal() {
458        let mut keybindings = default_vi_normal_keybindings();
459        add_completion_keybinding(&mut keybindings);
460    }
461
462    #[test]
463    fn test_terminal_config_default_banner() {
464        let config = TerminalConfig::default();
465        // Default config has the DEFAULT_BANNER set
466        assert!(config.banner.is_some());
467    }
468
469    #[test]
470    fn test_terminal_config_color_disabled() {
471        let config = TerminalConfig::builder().color(false).build();
472        assert!(!config.color_enabled);
473    }
474
475    #[test]
476    fn test_terminal_config_color_enabled() {
477        let config = TerminalConfig::builder().color(true).build();
478        assert!(config.color_enabled);
479    }
480
481    #[test]
482    fn test_terminal_config_edit_mode_emacs() {
483        let config = TerminalConfig::builder().edit_mode(EditMode::Emacs).build();
484        assert!(matches!(config.edit_mode, EditMode::Emacs));
485    }
486
487    #[test]
488    fn test_terminal_config_edit_mode_vi() {
489        let config = TerminalConfig::builder().edit_mode(EditMode::Vi).build();
490        assert!(matches!(config.edit_mode, EditMode::Vi));
491    }
492
493    #[test]
494    fn test_history_config_default() {
495        let config = HistoryConfig::default();
496        assert!(config.enabled);
497        assert!(config.path.is_none());
498        // Default has max_size of 10000
499        assert_eq!(config.max_size, Some(10000));
500    }
501
502    #[test]
503    fn test_history_config_disabled() {
504        let config = HistoryConfig { enabled: false, path: None, max_size: None };
505        assert!(!config.enabled);
506    }
507
508    #[test]
509    fn test_history_config_custom_path() {
510        let path = PathBuf::from("/custom/history");
511        let config = HistoryConfig { enabled: true, path: Some(path.clone()), max_size: None };
512        assert_eq!(config.path, Some(path));
513    }
514
515    #[test]
516    fn test_history_config_custom_max_size() {
517        let config = HistoryConfig { enabled: true, path: None, max_size: Some(5000) };
518        assert_eq!(config.max_size, Some(5000));
519    }
520
521    // Tests for ReplTerminal that don't require actual terminal interaction
522    // These test the creation path and configuration access
523
524    #[test]
525    #[serial_test::serial]
526    fn test_repl_terminal_with_config_emacs() {
527        // Create with emacs mode - tests line 91-94
528        let terminal_config =
529            TerminalConfig::builder().edit_mode(EditMode::Emacs).color(false).build();
530        let history_config = HistoryConfig { enabled: false, path: None, max_size: Some(100) };
531
532        let result = ReplTerminal::with_config(terminal_config, history_config);
533        assert!(result.is_ok());
534        let terminal = result.unwrap();
535        assert!(!terminal.config().color_enabled);
536    }
537
538    #[test]
539    #[serial_test::serial]
540    fn test_repl_terminal_with_config_vi() {
541        // Create with vi mode - tests line 96-102
542        let terminal_config =
543            TerminalConfig::builder().edit_mode(EditMode::Vi).color(false).build();
544        let history_config = HistoryConfig { enabled: false, path: None, max_size: Some(100) };
545
546        let result = ReplTerminal::with_config(terminal_config, history_config);
547        assert!(result.is_ok());
548    }
549
550    #[test]
551    #[serial_test::serial]
552    fn test_repl_terminal_with_history_enabled() {
553        // Test with history enabled - tests line 111-112
554        let terminal_config = TerminalConfig::builder().color(false).build();
555        let temp_dir = std::env::temp_dir();
556        let history_path = temp_dir.join("test-oxur-history");
557        let history_config =
558            HistoryConfig { enabled: true, path: Some(history_path.clone()), max_size: Some(500) };
559
560        let result = ReplTerminal::with_config(terminal_config, history_config);
561        assert!(result.is_ok());
562
563        // Cleanup
564        let _ = std::fs::remove_file(history_path);
565    }
566
567    #[test]
568    #[serial_test::serial]
569    fn test_repl_terminal_with_history_disabled() {
570        // Test with history disabled - tests line 114-116
571        let terminal_config = TerminalConfig::builder().color(false).build();
572        let history_config = HistoryConfig { enabled: false, path: None, max_size: None };
573
574        let result = ReplTerminal::with_config(terminal_config, history_config);
575        assert!(result.is_ok());
576    }
577
578    #[test]
579    #[serial_test::serial]
580    fn test_repl_terminal_config_accessor() {
581        let terminal_config = TerminalConfig::builder()
582            .prompt("test> ")
583            .continuation_prompt("..> ")
584            .color(false)
585            .build();
586        let history_config = HistoryConfig::default();
587
588        let terminal = ReplTerminal::with_config(terminal_config.clone(), history_config).unwrap();
589
590        // Test config() accessor - line 277-279
591        let config = terminal.config();
592        assert_eq!(config.prompt, "test> ");
593        assert_eq!(config.continuation_prompt, "..> ");
594        assert!(!config.color_enabled);
595    }
596
597    #[test]
598    #[serial_test::serial]
599    fn test_repl_terminal_prompt() {
600        let terminal_config = TerminalConfig::builder().prompt("custom> ").color(false).build();
601        let history_config = HistoryConfig::default();
602
603        let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
604
605        // Test prompt() method - line 172-174
606        let prompt = terminal.prompt();
607        assert_eq!(prompt, "custom> ");
608    }
609
610    #[test]
611    #[serial_test::serial]
612    fn test_repl_terminal_continuation_prompt() {
613        let terminal_config =
614            TerminalConfig::builder().continuation_prompt(">>> ").color(false).build();
615        let history_config = HistoryConfig::default();
616
617        let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
618
619        // Test continuation_prompt() method - line 178-180
620        let cont_prompt = terminal.continuation_prompt();
621        assert_eq!(cont_prompt, ">>> ");
622    }
623
624    #[test]
625    #[serial_test::serial]
626    fn test_repl_terminal_color_enabled() {
627        let terminal_config = TerminalConfig::builder().color(true).build();
628        let history_config = HistoryConfig::default();
629
630        let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
631
632        // Test color_enabled() method - line 193-195
633        assert!(terminal.color_enabled());
634    }
635
636    #[test]
637    #[serial_test::serial]
638    fn test_repl_terminal_color_disabled() {
639        let terminal_config = TerminalConfig::builder().color(false).build();
640        let history_config = HistoryConfig::default();
641
642        let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
643
644        assert!(!terminal.color_enabled());
645    }
646
647    #[test]
648    #[serial_test::serial]
649    fn test_repl_terminal_save_history() {
650        let terminal_config = TerminalConfig::builder().color(false).build();
651        let history_config = HistoryConfig::default();
652
653        let mut terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
654
655        // Test save_history() method - line 186-189
656        let result = terminal.save_history();
657        assert!(result.is_ok());
658    }
659}