Skip to main content

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/// Calculate the visible width of a string, ignoring ANSI escape codes
39///
40/// This strips all ANSI escape sequences to get the actual displayed width.
41fn visible_width(s: &str) -> usize {
42    // Regular expression to match ANSI escape codes: ESC [ ... m
43    // Also matches true color codes like ESC[38;2;r;g;bm
44    let mut width = 0;
45    let mut chars = s.chars().peekable();
46
47    while let Some(ch) = chars.next() {
48        if ch == '\x1b' {
49            // Skip the escape sequence
50            if chars.peek() == Some(&'[') {
51                chars.next(); // consume '['
52                              // Skip until we find 'm'
53                for esc_ch in chars.by_ref() {
54                    if esc_ch == 'm' {
55                        break;
56                    }
57                }
58            }
59        } else {
60            // Count regular character
61            width += 1;
62        }
63    }
64
65    width
66}
67
68/// Substitute a placeholder in a line while preserving visual width
69///
70/// This replaces the placeholder with the actual value and adjusts padding
71/// to maintain the same visual column width (accounting for ANSI codes).
72fn substitute_placeholder_in_line(line: &str, placeholder: &str, value: &str) -> String {
73    if !line.contains(placeholder) {
74        return line.to_string();
75    }
76
77    // Calculate the original visible width
78    let original_visible_width = visible_width(line);
79
80    // Do the replacement
81    let result = line.replace(placeholder, value);
82
83    // Calculate new visible width after replacement
84    let new_visible_width = visible_width(&result);
85
86    if new_visible_width == original_visible_width {
87        // Perfect match, no adjustment needed
88        return result;
89    }
90
91    // Find the position where we'll adjust spacing
92    // Strategy: Look for common border characters (║, |, ]) or last ANSI escape sequence
93    let border_pos = result
94        .rfind('\x1b')
95        .or_else(|| result.rfind('║'))
96        .or_else(|| result.rfind('│'))
97        .or_else(|| result.rfind('|'))
98        .or_else(|| result.rfind(']'));
99
100    let (before_border, border_and_after) = if let Some(pos) = border_pos {
101        (&result[..pos], &result[pos..])
102    } else {
103        // No border found - work with entire string
104        (&result[..], "")
105    };
106
107    if new_visible_width < original_visible_width {
108        // Need to add spaces
109        let spaces_needed = original_visible_width - new_visible_width;
110        format!("{}{}{}", before_border, " ".repeat(spaces_needed), border_and_after)
111    } else {
112        // Need to remove spaces (new_visible_width > original_visible_width)
113        let spaces_to_remove = new_visible_width - original_visible_width;
114
115        // Count trailing spaces in before_border
116        let trimmed = before_border.trim_end();
117        let trailing_space_count = before_border.len() - trimmed.len();
118
119        if trailing_space_count >= spaces_to_remove {
120            // We have enough spaces to remove
121            let keep_len = trimmed.len() + (trailing_space_count - spaces_to_remove);
122            format!("{}{}", &before_border[..keep_len], border_and_after)
123        } else {
124            // Not enough trailing spaces - just remove what we have
125            format!("{}{}", trimmed, border_and_after)
126        }
127    }
128}
129
130/// Substitute version placeholders in banner text
131///
132/// Replaces template placeholders with actual version information while
133/// preserving the visual column alignment of borders and decorative elements.
134///
135/// - `N.N.N` → Oxur version (e.g., "0.1.0")
136/// - `M.M.M` → Rust version info (e.g., "1.75.0 (82e1608df 2023-12-21)")
137/// - `L.L.L` → Cargo version info (e.g., "1.75.0 (1d8b05cdd 2023-11-20)")
138fn substitute_banner_versions(
139    banner: &str,
140    metadata: &oxur_repl::metadata::SystemMetadata,
141) -> String {
142    banner
143        .lines()
144        .map(|line| {
145            let line = substitute_placeholder_in_line(line, "N.N.N", &metadata.oxur_version);
146            let line = substitute_placeholder_in_line(
147                &line,
148                "M.M.M",
149                &format_version(&metadata.rust_version),
150            );
151            substitute_placeholder_in_line(&line, "L.L.L", &format_version(&metadata.cargo_version))
152        })
153        .collect::<Vec<_>>()
154        .join("\n")
155}
156
157/// Add Tab keybinding for completion menu
158fn add_completion_keybinding(keybindings: &mut Keybindings) {
159    keybindings.add_binding(
160        KeyModifiers::NONE,
161        KeyCode::Tab,
162        ReedlineEvent::UntilFound(vec![
163            ReedlineEvent::Menu("completion_menu".to_string()),
164            ReedlineEvent::MenuNext,
165        ]),
166    );
167}
168
169/// REPL terminal interface with line editing and history
170pub struct ReplTerminal {
171    editor: Reedline,
172    #[allow(dead_code)] // Kept for API compatibility and future use
173    history_path: PathBuf,
174    terminal_config: TerminalConfig,
175}
176
177impl ReplTerminal {
178    /// Create a new REPL terminal with configuration
179    ///
180    /// # Arguments
181    ///
182    /// * `terminal_config` - Terminal appearance configuration
183    /// * `history_config` - Command history configuration
184    ///
185    /// # Errors
186    ///
187    /// Returns error if reedline initialization fails.
188    pub fn with_config(
189        terminal_config: TerminalConfig,
190        history_config: HistoryConfig,
191    ) -> Result<Self> {
192        // Convert edit mode to reedline's EditMode trait object with Tab completion
193        let edit_mode: Box<dyn reedline::EditMode> = match terminal_config.edit_mode {
194            EditMode::Emacs => {
195                let mut keybindings = default_emacs_keybindings();
196                add_completion_keybinding(&mut keybindings);
197                Box::new(Emacs::new(keybindings))
198            }
199            EditMode::Vi => {
200                let mut insert_keybindings = default_vi_insert_keybindings();
201                let mut normal_keybindings = default_vi_normal_keybindings();
202                add_completion_keybinding(&mut insert_keybindings);
203                add_completion_keybinding(&mut normal_keybindings);
204                Box::new(Vi::new(insert_keybindings, normal_keybindings))
205            }
206        };
207
208        // Determine history file path
209        let history_path = history_config.path.unwrap_or_else(paths::default_history_path);
210
211        // Create history backend
212        // Note: FileBackedHistory is used for both enabled and disabled cases.
213        // When disabled, we use a temp path that won't persist between sessions.
214        let history_path_for_backend = if history_config.enabled {
215            history_path.clone()
216        } else {
217            // Use a temporary path that won't be loaded or saved
218            std::env::temp_dir().join("oxur-repl-temp-history")
219        };
220
221        let history = Box::new(
222            FileBackedHistory::with_file(
223                history_config.max_size.unwrap_or(10000),
224                history_path_for_backend,
225            )
226            .context("Failed to create history backend")?,
227        );
228
229        // Create completion menu
230        let completion_menu = ColumnarMenu::default()
231            .with_name("completion_menu")
232            .with_columns(4)
233            .with_column_width(Some(20))
234            .with_column_padding(2);
235
236        // Build reedline editor with syntax highlighting, validation, and completion
237        let editor = Reedline::create()
238            .with_history(history)
239            .with_edit_mode(edit_mode)
240            .with_highlighter(Box::new(SExpHighlighter::new(terminal_config.color_enabled)))
241            .with_validator(Box::new(SExpValidator::new()))
242            .with_completer(Box::new(OxurCompleter::new()))
243            .with_menu(ReedlineMenu::EngineCompleter(Box::new(completion_menu)));
244
245        Ok(Self { editor, history_path, terminal_config })
246    }
247
248    /// Read a line of input from the user
249    ///
250    /// Returns:
251    /// - `Ok(Some(line))` - User entered a line
252    /// - `Ok(None)` - User pressed Ctrl-C (interrupt)
253    /// - `Err(_)` - User pressed Ctrl-D (exit) or other error
254    pub fn read_line(&mut self, prompt: &str) -> Result<Option<String>> {
255        let oxur_prompt = OxurPrompt::new(
256            prompt.to_string(),
257            self.terminal_config.formatted_continuation_prompt(),
258        );
259
260        match self.editor.read_line(&oxur_prompt) {
261            Ok(Signal::Success(line)) => Ok(Some(line)),
262            Ok(Signal::CtrlC) => Ok(None),
263            Ok(Signal::CtrlD) => Err(anyhow::anyhow!("EOF")),
264            Err(e) => Err(anyhow::anyhow!("Input error: {}", e)),
265        }
266    }
267
268    /// Read a line using the default prompt
269    pub fn read_line_default(&mut self) -> Result<Option<String>> {
270        let prompt = self.prompt();
271        self.read_line(&prompt)
272    }
273
274    /// Get the formatted prompt string
275    pub fn prompt(&self) -> String {
276        self.terminal_config.formatted_prompt()
277    }
278
279    /// Get the formatted continuation prompt for multi-line input
280    #[allow(dead_code)]
281    pub fn continuation_prompt(&self) -> String {
282        self.terminal_config.formatted_continuation_prompt()
283    }
284
285    /// Save command history to disk
286    ///
287    /// With FileBackedHistory, history is automatically saved.
288    /// This method is retained for API compatibility.
289    pub fn save_history(&mut self) -> Result<()> {
290        // FileBackedHistory auto-saves - this is a no-op
291        Ok(())
292    }
293
294    /// Check if colors are enabled
295    #[allow(dead_code)]
296    pub fn color_enabled(&self) -> bool {
297        self.terminal_config.color_enabled
298    }
299
300    /// Print an error message with appropriate formatting
301    pub fn print_error(&self, msg: &str) {
302        if self.terminal_config.color_enabled {
303            eprintln!("\x1b[31mError:\x1b[0m {}", msg);
304        } else {
305            eprintln!("Error: {}", msg);
306        }
307    }
308
309    /// Print a result value with appropriate formatting
310    pub fn print_result(&self, value: &str) {
311        if self.terminal_config.color_enabled {
312            println!("\x1b[36m{}\x1b[0m", value);
313        } else {
314            println!("{}", value);
315        }
316    }
317
318    /// Print output (stdout from evaluation)
319    pub fn print_output(&self, output: &str) {
320        print!("{}", output);
321    }
322
323    /// Print help content with appropriate formatting
324    ///
325    /// Automatically pages content if it exceeds terminal height.
326    pub fn print_help(&self, content: &str) {
327        if let Err(e) = pager::page_text(content) {
328            // Fallback to direct print if paging fails
329            eprintln!("Warning: Pager failed ({}), printing directly", e);
330            println!("{}", content);
331        }
332    }
333
334    /// Print the welcome banner with system metadata
335    pub fn print_banner(&self, metadata: &oxur_repl::metadata::SystemMetadata) {
336        if let Some(ref banner) = self.terminal_config.banner {
337            // Custom banner with version substitution
338            let banner_with_versions = substitute_banner_versions(banner, metadata);
339            println!("{}", banner_with_versions);
340        } else {
341            // Default banner with version information
342            if self.terminal_config.color_enabled {
343                println!(
344                    "\x1b[1mOxur REPL\x1b[0m v{} | \x1b[90mRust: {} | Cargo: {}\x1b[0m",
345                    metadata.oxur_version,
346                    format_version(&metadata.rust_version),
347                    format_version(&metadata.cargo_version)
348                );
349            } else {
350                println!(
351                    "Oxur REPL v{} | Rust: {} | Cargo: {}",
352                    metadata.oxur_version,
353                    format_version(&metadata.rust_version),
354                    format_version(&metadata.cargo_version)
355                );
356            }
357            println!("Type (help) for assistance, Ctrl-D to exit.");
358        }
359        println!();
360    }
361
362    /// Print a goodbye message
363    pub fn print_goodbye(&self) {
364        println!();
365        if self.terminal_config.color_enabled {
366            println!("\x1b[33mGoodbye!\x1b[0m");
367        } else {
368            println!("Goodbye!");
369        }
370    }
371
372    /// Clear the terminal screen and move cursor to top
373    pub fn clear_screen(&self) -> Result<()> {
374        execute!(io::stdout(), terminal::Clear(terminal::ClearType::All))?;
375        execute!(io::stdout(), crossterm::cursor::MoveTo(0, 0))?;
376        Ok(())
377    }
378
379    /// Get the terminal configuration
380    pub fn config(&self) -> &TerminalConfig {
381        &self.terminal_config
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_default_history_path() {
391        let path = paths::default_history_path();
392        assert!(path.ends_with("repl_history"));
393    }
394
395    #[test]
396    fn test_terminal_config_prompt() {
397        let config = TerminalConfig::builder().prompt("test> ").color(false).build();
398        assert_eq!(config.formatted_prompt(), "test> ");
399    }
400
401    #[test]
402    #[serial_test::serial]
403    fn test_terminal_config_colored_prompt() {
404        // Force colors on for testing
405        colored::control::set_override(true);
406
407        // Non-oxur prompt uses standard green
408        let config = TerminalConfig::builder().prompt("test> ").color(true).build();
409        let test_prompt = config.formatted_prompt();
410        assert_ne!(test_prompt, "test> ");
411        assert!(test_prompt.contains("\x1b["));
412        assert!(test_prompt.contains("test> "));
413
414        // oxur prompt uses special coloring (bright yellow, yellow, bright red, dark red, bright green)
415        let oxur_config = TerminalConfig::builder().prompt("oxur> ").color(true).build();
416        let oxur_prompt = oxur_config.formatted_prompt();
417        // Colored output should be different from plain text
418        assert_ne!(oxur_prompt, "oxur> ");
419        // Should contain ANSI escape codes
420        assert!(oxur_prompt.contains("\x1b["));
421        // Should contain all letters
422        assert!(oxur_prompt.contains("o"));
423        assert!(oxur_prompt.contains("x"));
424        assert!(oxur_prompt.contains("u"));
425        assert!(oxur_prompt.contains("r"));
426
427        // Reset color override
428        colored::control::unset_override();
429    }
430
431    #[test]
432    fn test_continuation_prompt() {
433        let config = TerminalConfig::builder().continuation_prompt("... ").color(false).build();
434        assert_eq!(config.formatted_continuation_prompt(), "... ");
435    }
436
437    #[test]
438    fn test_custom_banner() {
439        let config = TerminalConfig::builder().banner("Custom Welcome!").build();
440        assert_eq!(config.banner, Some("Custom Welcome!".to_string()));
441    }
442
443    #[test]
444    fn test_format_version_rustc() {
445        let version = "rustc 1.75.0 (82e1608df 2023-12-21)";
446        assert_eq!(format_version(version), "1.75.0 (82e1608df 2023-12-21)");
447    }
448
449    #[test]
450    fn test_format_version_cargo() {
451        let version = "cargo 1.75.0 (1d8b05cdd 2023-11-20)";
452        assert_eq!(format_version(version), "1.75.0 (1d8b05cdd 2023-11-20)");
453    }
454
455    #[test]
456    fn test_format_version_unknown() {
457        let version = "unknown";
458        assert_eq!(format_version(version), "unknown");
459    }
460
461    #[test]
462    fn test_visible_width_plain_text() {
463        assert_eq!(visible_width("Hello"), 5);
464        assert_eq!(visible_width(""), 0);
465        assert_eq!(visible_width("Test 123"), 8);
466    }
467
468    #[test]
469    fn test_visible_width_with_ansi_codes() {
470        // Basic color codes
471        assert_eq!(visible_width("\x1b[31mRed\x1b[0m"), 3); // "Red"
472        assert_eq!(visible_width("\x1b[1;32mGreen\x1b[0m"), 5); // "Green"
473
474        // True color codes (like in the banner)
475        assert_eq!(visible_width("\x1b[38;2;255;0;0mRed\x1b[0m"), 3); // "Red"
476        assert_eq!(visible_width("\x1b[38;2;138;59;13m║\x1b[0m"), 1); // "║"
477    }
478
479    #[test]
480    fn test_visible_width_complex_banner_line() {
481        // Simplified version of actual banner line structure
482        let line = "\x1b[38;2;138;59;13m║\x1b[0m text \x1b[38;2;138;59;13m║\x1b[0m";
483        // Should count: ║ + " text " (6 chars) + ║ = 8
484        assert_eq!(visible_width(line), 8);
485    }
486
487    #[test]
488    fn test_substitute_placeholder_in_line_no_placeholder() {
489        let line = "This is a test line";
490        let result = substitute_placeholder_in_line(line, "N.N.N", "1.2.3");
491        assert_eq!(result, line);
492    }
493
494    #[test]
495    fn test_substitute_placeholder_in_line_simple() {
496        let line = "Version: N.N.N    ║";
497        let result = substitute_placeholder_in_line(line, "N.N.N", "1.2.3");
498        assert_eq!(visible_width(&result), visible_width(line));
499        assert!(result.contains("1.2.3"));
500        assert!(!result.contains("N.N.N"));
501    }
502
503    #[test]
504    fn test_substitute_placeholder_in_line_shorter_value() {
505        let line = "oxur: N.N.N     ║";
506        let result = substitute_placeholder_in_line(line, "N.N.N", "1.0");
507        // "N.N.N" (5 chars) -> "1.0" (3 chars) = need to add 2 spaces
508        assert_eq!(visible_width(&result), visible_width(line));
509        assert!(result.contains("1.0"));
510    }
511
512    #[test]
513    fn test_substitute_placeholder_in_line_longer_value() {
514        let line = "oxur: N.N.N     ║";
515        let result = substitute_placeholder_in_line(line, "N.N.N", "1.0.0-beta");
516        // "N.N.N" (5 chars) -> "1.0.0-beta" (10 chars) = need to remove 5 spaces
517        assert_eq!(visible_width(&result), visible_width(line));
518        assert!(result.contains("1.0.0-beta"));
519    }
520
521    #[test]
522    fn test_substitute_placeholder_in_line_with_ansi() {
523        let line = "\x1b[32moxur: N.N.N\x1b[37m     \x1b[38;2;138;59;13m║\x1b[0m";
524        let result = substitute_placeholder_in_line(line, "N.N.N", "1.2.3");
525        // Visual width should remain the same
526        assert_eq!(visible_width(&result), visible_width(line));
527        assert!(result.contains("1.2.3"));
528        assert!(!result.contains("N.N.N"));
529    }
530
531    #[test]
532    fn test_substitute_banner_versions() {
533        let banner = "oxur: N.N.N\nrustc: M.M.M\ncargo: L.L.L";
534        let metadata = oxur_repl::metadata::SystemMetadata {
535            oxur_version: "0.1.0".to_string(),
536            rust_version: "rustc 1.75.0 (82e1608df 2023-12-21)".to_string(),
537            cargo_version: "cargo 1.75.0 (1d8b05cdd 2023-11-20)".to_string(),
538            os_name: "Test".to_string(),
539            os_version: "1.0".to_string(),
540            arch: "x86_64".to_string(),
541            hostname: "test".to_string(),
542            pid: 1234,
543            cwd: std::path::PathBuf::from("/test"),
544            started_at: std::time::SystemTime::now(),
545        };
546
547        let result = substitute_banner_versions(banner, &metadata);
548        assert!(result.contains("oxur: 0.1.0"));
549        assert!(result.contains("rustc: 1.75.0 (82e1608df 2023-12-21)"));
550        assert!(result.contains("cargo: 1.75.0 (1d8b05cdd 2023-11-20)"));
551        assert!(!result.contains("N.N.N"));
552        assert!(!result.contains("M.M.M"));
553        assert!(!result.contains("L.L.L"));
554    }
555
556    #[test]
557    fn test_substitute_banner_versions_preserves_width() {
558        // Test with lines that have borders at specific columns
559        let banner = "║ oxur: N.N.N     ║\n║ rustc: M.M.M    ║\n║ cargo: L.L.L    ║";
560        let metadata = oxur_repl::metadata::SystemMetadata {
561            oxur_version: "0.2.0".to_string(),
562            rust_version: "rustc 1.76.0".to_string(),
563            cargo_version: "cargo 1.76.0".to_string(),
564            os_name: "Test".to_string(),
565            os_version: "1.0".to_string(),
566            arch: "x86_64".to_string(),
567            hostname: "test".to_string(),
568            pid: 1234,
569            cwd: std::path::PathBuf::from("/test"),
570            started_at: std::time::SystemTime::now(),
571        };
572
573        let result = substitute_banner_versions(banner, &metadata);
574        let original_lines: Vec<&str> = banner.lines().collect();
575        let result_lines: Vec<&str> = result.lines().collect();
576
577        // Each line should maintain the same visible width
578        assert_eq!(original_lines.len(), result_lines.len());
579        for (orig, res) in original_lines.iter().zip(result_lines.iter()) {
580            assert_eq!(
581                visible_width(orig),
582                visible_width(res),
583                "Line width mismatch:\nOriginal: {}\nResult: {}",
584                orig,
585                res
586            );
587        }
588    }
589
590    #[test]
591    fn test_substitute_banner_versions_with_real_banner() {
592        // Test with the actual default banner to ensure it works in practice
593        let config = crate::config::TerminalConfig::default();
594        let banner = config.banner.expect("Default banner should exist");
595
596        let metadata = oxur_repl::metadata::SystemMetadata {
597            oxur_version: "0.2.0".to_string(),
598            rust_version: "rustc 1.76.0 (07dca489a 2024-02-04)".to_string(),
599            cargo_version: "cargo 1.76.0 (c84b36747 2024-01-18)".to_string(),
600            os_name: "Test".to_string(),
601            os_version: "1.0".to_string(),
602            arch: "x86_64".to_string(),
603            hostname: "test".to_string(),
604            pid: 1234,
605            cwd: std::path::PathBuf::from("/test"),
606            started_at: std::time::SystemTime::now(),
607        };
608
609        let result = substitute_banner_versions(&banner, &metadata);
610        let original_lines: Vec<&str> = banner.lines().collect();
611        let result_lines: Vec<&str> = result.lines().collect();
612
613        // Check that line count matches
614        assert_eq!(original_lines.len(), result_lines.len());
615
616        // Check that version lines maintain their width
617        for (i, (orig, res)) in original_lines.iter().zip(result_lines.iter()).enumerate() {
618            let orig_width = visible_width(orig);
619            let res_width = visible_width(res);
620            assert_eq!(
621                orig_width,
622                res_width,
623                "Line {} width mismatch (orig={}, res={}):\nOriginal: {}\nResult: {}",
624                i + 1,
625                orig_width,
626                res_width,
627                orig,
628                res
629            );
630        }
631
632        // Verify substitutions happened
633        assert!(result.contains("0.2.0"));
634        assert!(result.contains("1.76.0"));
635        assert!(!result.contains("N.N.N"));
636        assert!(!result.contains("M.M.M"));
637        assert!(!result.contains("L.L.L"));
638    }
639
640    // ===== Additional coverage tests =====
641
642    #[test]
643    fn test_format_version_empty() {
644        let version = "";
645        assert_eq!(format_version(version), "");
646    }
647
648    #[test]
649    fn test_format_version_single_word() {
650        let version = "1.75.0";
651        assert_eq!(format_version(version), "1.75.0");
652    }
653
654    #[test]
655    fn test_format_version_many_parts() {
656        let version = "tool 1.0.0 extra info here";
657        assert_eq!(format_version(version), "1.0.0 extra info here");
658    }
659
660    #[test]
661    fn test_substitute_banner_no_placeholders() {
662        let banner = "Welcome to the REPL!";
663        let metadata = oxur_repl::metadata::SystemMetadata {
664            oxur_version: "0.1.0".to_string(),
665            rust_version: "rustc 1.75.0".to_string(),
666            cargo_version: "cargo 1.75.0".to_string(),
667            os_name: "Test".to_string(),
668            os_version: "1.0".to_string(),
669            arch: "x86_64".to_string(),
670            hostname: "test".to_string(),
671            pid: 1234,
672            cwd: std::path::PathBuf::from("/test"),
673            started_at: std::time::SystemTime::now(),
674        };
675
676        let result = substitute_banner_versions(banner, &metadata);
677        assert_eq!(result, "Welcome to the REPL!");
678    }
679
680    #[test]
681    fn test_substitute_banner_partial_placeholders() {
682        let banner = "Oxur N.N.N only";
683        let metadata = oxur_repl::metadata::SystemMetadata {
684            oxur_version: "0.2.0".to_string(),
685            rust_version: "rustc 1.76.0".to_string(),
686            cargo_version: "cargo 1.76.0".to_string(),
687            os_name: "Test".to_string(),
688            os_version: "1.0".to_string(),
689            arch: "x86_64".to_string(),
690            hostname: "test".to_string(),
691            pid: 1234,
692            cwd: std::path::PathBuf::from("/test"),
693            started_at: std::time::SystemTime::now(),
694        };
695
696        let result = substitute_banner_versions(banner, &metadata);
697        assert_eq!(result, "Oxur 0.2.0 only");
698    }
699
700    #[test]
701    fn test_add_completion_keybinding() {
702        let mut keybindings = default_emacs_keybindings();
703        // Should not panic
704        add_completion_keybinding(&mut keybindings);
705    }
706
707    #[test]
708    fn test_add_completion_keybinding_vi_insert() {
709        let mut keybindings = default_vi_insert_keybindings();
710        add_completion_keybinding(&mut keybindings);
711    }
712
713    #[test]
714    fn test_add_completion_keybinding_vi_normal() {
715        let mut keybindings = default_vi_normal_keybindings();
716        add_completion_keybinding(&mut keybindings);
717    }
718
719    #[test]
720    fn test_terminal_config_default_banner() {
721        let config = TerminalConfig::default();
722        // Default config has the DEFAULT_BANNER set
723        assert!(config.banner.is_some());
724    }
725
726    #[test]
727    fn test_terminal_config_color_disabled() {
728        let config = TerminalConfig::builder().color(false).build();
729        assert!(!config.color_enabled);
730    }
731
732    #[test]
733    fn test_terminal_config_color_enabled() {
734        let config = TerminalConfig::builder().color(true).build();
735        assert!(config.color_enabled);
736    }
737
738    #[test]
739    fn test_terminal_config_edit_mode_emacs() {
740        let config = TerminalConfig::builder().edit_mode(EditMode::Emacs).build();
741        assert!(matches!(config.edit_mode, EditMode::Emacs));
742    }
743
744    #[test]
745    fn test_terminal_config_edit_mode_vi() {
746        let config = TerminalConfig::builder().edit_mode(EditMode::Vi).build();
747        assert!(matches!(config.edit_mode, EditMode::Vi));
748    }
749
750    #[test]
751    fn test_history_config_default() {
752        let config = HistoryConfig::default();
753        assert!(config.enabled);
754        assert!(config.path.is_none());
755        // Default has max_size of 10000
756        assert_eq!(config.max_size, Some(10000));
757    }
758
759    #[test]
760    fn test_history_config_disabled() {
761        let config = HistoryConfig { enabled: false, path: None, max_size: None };
762        assert!(!config.enabled);
763    }
764
765    #[test]
766    fn test_history_config_custom_path() {
767        let path = PathBuf::from("/custom/history");
768        let config = HistoryConfig { enabled: true, path: Some(path.clone()), max_size: None };
769        assert_eq!(config.path, Some(path));
770    }
771
772    #[test]
773    fn test_history_config_custom_max_size() {
774        let config = HistoryConfig { enabled: true, path: None, max_size: Some(5000) };
775        assert_eq!(config.max_size, Some(5000));
776    }
777
778    // Tests for ReplTerminal that don't require actual terminal interaction
779    // These test the creation path and configuration access
780
781    #[test]
782    #[serial_test::serial]
783    fn test_repl_terminal_with_config_emacs() {
784        // Create with emacs mode - tests line 91-94
785        let terminal_config =
786            TerminalConfig::builder().edit_mode(EditMode::Emacs).color(false).build();
787        let history_config = HistoryConfig { enabled: false, path: None, max_size: Some(100) };
788
789        let result = ReplTerminal::with_config(terminal_config, history_config);
790        assert!(result.is_ok());
791        let terminal = result.unwrap();
792        assert!(!terminal.config().color_enabled);
793    }
794
795    #[test]
796    #[serial_test::serial]
797    fn test_repl_terminal_with_config_vi() {
798        // Create with vi mode - tests line 96-102
799        let terminal_config =
800            TerminalConfig::builder().edit_mode(EditMode::Vi).color(false).build();
801        let history_config = HistoryConfig { enabled: false, path: None, max_size: Some(100) };
802
803        let result = ReplTerminal::with_config(terminal_config, history_config);
804        assert!(result.is_ok());
805    }
806
807    #[test]
808    #[serial_test::serial]
809    fn test_repl_terminal_with_history_enabled() {
810        // Test with history enabled - tests line 111-112
811        let terminal_config = TerminalConfig::builder().color(false).build();
812        let temp_dir = std::env::temp_dir();
813        let history_path = temp_dir.join("test-oxur-history");
814        let history_config =
815            HistoryConfig { enabled: true, path: Some(history_path.clone()), max_size: Some(500) };
816
817        let result = ReplTerminal::with_config(terminal_config, history_config);
818        assert!(result.is_ok());
819
820        // Cleanup
821        let _ = std::fs::remove_file(history_path);
822    }
823
824    #[test]
825    #[serial_test::serial]
826    fn test_repl_terminal_with_history_disabled() {
827        // Test with history disabled - tests line 114-116
828        let terminal_config = TerminalConfig::builder().color(false).build();
829        let history_config = HistoryConfig { enabled: false, path: None, max_size: None };
830
831        let result = ReplTerminal::with_config(terminal_config, history_config);
832        assert!(result.is_ok());
833    }
834
835    #[test]
836    #[serial_test::serial]
837    fn test_repl_terminal_config_accessor() {
838        let terminal_config = TerminalConfig::builder()
839            .prompt("test> ")
840            .continuation_prompt("..> ")
841            .color(false)
842            .build();
843        let history_config = HistoryConfig::default();
844
845        let terminal = ReplTerminal::with_config(terminal_config.clone(), history_config).unwrap();
846
847        // Test config() accessor - line 277-279
848        let config = terminal.config();
849        assert_eq!(config.prompt, "test> ");
850        assert_eq!(config.continuation_prompt, "..> ");
851        assert!(!config.color_enabled);
852    }
853
854    #[test]
855    #[serial_test::serial]
856    fn test_repl_terminal_prompt() {
857        let terminal_config = TerminalConfig::builder().prompt("custom> ").color(false).build();
858        let history_config = HistoryConfig::default();
859
860        let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
861
862        // Test prompt() method - line 172-174
863        let prompt = terminal.prompt();
864        assert_eq!(prompt, "custom> ");
865    }
866
867    #[test]
868    #[serial_test::serial]
869    fn test_repl_terminal_continuation_prompt() {
870        let terminal_config =
871            TerminalConfig::builder().continuation_prompt(">>> ").color(false).build();
872        let history_config = HistoryConfig::default();
873
874        let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
875
876        // Test continuation_prompt() method - line 178-180
877        let cont_prompt = terminal.continuation_prompt();
878        assert_eq!(cont_prompt, ">>> ");
879    }
880
881    #[test]
882    #[serial_test::serial]
883    fn test_repl_terminal_color_enabled() {
884        let terminal_config = TerminalConfig::builder().color(true).build();
885        let history_config = HistoryConfig::default();
886
887        let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
888
889        // Test color_enabled() method - line 193-195
890        assert!(terminal.color_enabled());
891    }
892
893    #[test]
894    #[serial_test::serial]
895    fn test_repl_terminal_color_disabled() {
896        let terminal_config = TerminalConfig::builder().color(false).build();
897        let history_config = HistoryConfig::default();
898
899        let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
900
901        assert!(!terminal.color_enabled());
902    }
903
904    #[test]
905    #[serial_test::serial]
906    fn test_repl_terminal_save_history() {
907        let terminal_config = TerminalConfig::builder().color(false).build();
908        let history_config = HistoryConfig::default();
909
910        let mut terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
911
912        // Test save_history() method - line 186-189
913        let result = terminal.save_history();
914        assert!(result.is_ok());
915    }
916}