oxur_cli/config/
terminal.rs

1//! Terminal configuration for the REPL
2//!
3//! Provides configuration for terminal appearance including prompts,
4//! colors, banners, and editing mode.
5
6use colored::Colorize;
7use serde::{Deserialize, Serialize};
8
9/// Default ASCII art banner for the REPL
10///
11/// Embedded from assets/banners/banner0.1.0.txt at compile time.
12/// Contains ANSI color codes and ASCII art.
13///
14/// Users can override this banner via:
15/// - Config file: `[terminal]` section, `banner` field
16/// - Environment variable: `OXUR_REPL_BANNER`
17const DEFAULT_BANNER: &str = include_str!("../../assets/banners/banner0.1.0.txt");
18
19/// Terminal configuration
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(default)]
22pub struct TerminalConfig {
23    /// Custom login banner (None uses default)
24    pub banner: Option<String>,
25    /// Primary prompt string
26    pub prompt: String,
27    /// Continuation prompt for multi-line input
28    pub continuation_prompt: String,
29    /// Whether ANSI colors are enabled
30    pub color_enabled: bool,
31    /// Line editing mode
32    pub edit_mode: EditMode,
33}
34
35/// Line editing mode
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
37#[serde(rename_all = "lowercase")]
38pub enum EditMode {
39    /// Emacs-style keybindings (default)
40    #[default]
41    Emacs,
42    /// Vi-style keybindings
43    Vi,
44}
45
46impl Default for TerminalConfig {
47    fn default() -> Self {
48        Self {
49            banner: Some(DEFAULT_BANNER.to_string()),
50            prompt: "oxur> ".to_string(),
51            continuation_prompt: "....> ".to_string(),
52            color_enabled: true,
53            edit_mode: EditMode::Emacs,
54        }
55    }
56}
57
58impl TerminalConfig {
59    /// Create a new builder for TerminalConfig
60    pub fn builder() -> TerminalConfigBuilder {
61        TerminalConfigBuilder::new()
62    }
63
64    /// Get the prompt with optional color formatting
65    ///
66    /// When colors are enabled and the prompt starts with "oxur",
67    /// each letter is colored individually:
68    /// - "o" = bright yellow
69    /// - "x" = regular yellow
70    /// - "u" = bright red
71    /// - "r" = dark red
72    /// - "> " = bright green
73    pub fn formatted_prompt(&self) -> String {
74        if self.color_enabled {
75            // Check if prompt starts with "oxur" for special coloring
76            if self.prompt.starts_with("oxur") {
77                let rest = &self.prompt[4..]; // Everything after "oxur"
78                                              // Color each letter individually using colored crate
79                format!(
80                    "{}{}{}{}{}",
81                    "o".bright_yellow().bold(),
82                    "x".bright_yellow(),
83                    "u".yellow(),
84                    "r".red(),
85                    rest.bright_green()
86                )
87            } else {
88                self.prompt.green().to_string()
89            }
90        } else {
91            self.prompt.clone()
92        }
93    }
94
95    /// Get the continuation prompt with optional color formatting
96    pub fn formatted_continuation_prompt(&self) -> String {
97        if self.color_enabled {
98            self.continuation_prompt.green().to_string()
99        } else {
100            self.continuation_prompt.clone()
101        }
102    }
103
104    /// Merge another config into this one (other takes precedence for Some values)
105    pub fn merge(&mut self, other: TerminalConfig) {
106        if other.banner.is_some() {
107            self.banner = other.banner;
108        }
109        // For non-Option fields, we need partial config pattern
110        // For now, other always overrides if explicitly set in file
111        self.prompt = other.prompt;
112        self.continuation_prompt = other.continuation_prompt;
113        self.color_enabled = other.color_enabled;
114        self.edit_mode = other.edit_mode;
115    }
116}
117
118/// Builder for TerminalConfig
119#[derive(Debug, Clone)]
120pub struct TerminalConfigBuilder {
121    config: TerminalConfig,
122}
123
124impl TerminalConfigBuilder {
125    /// Create a new builder with default values
126    pub fn new() -> Self {
127        Self { config: TerminalConfig::default() }
128    }
129
130    /// Set a custom login banner
131    pub fn banner(mut self, banner: impl Into<String>) -> Self {
132        self.config.banner = Some(banner.into());
133        self
134    }
135
136    /// Set the primary prompt
137    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
138        self.config.prompt = prompt.into();
139        self
140    }
141
142    /// Set the continuation prompt for multi-line input
143    pub fn continuation_prompt(mut self, prompt: impl Into<String>) -> Self {
144        self.config.continuation_prompt = prompt.into();
145        self
146    }
147
148    /// Enable or disable ANSI colors
149    pub fn color(mut self, enabled: bool) -> Self {
150        self.config.color_enabled = enabled;
151        self
152    }
153
154    /// Set the line editing mode
155    pub fn edit_mode(mut self, mode: EditMode) -> Self {
156        self.config.edit_mode = mode;
157        self
158    }
159
160    /// Build the TerminalConfig
161    pub fn build(self) -> TerminalConfig {
162        self.config
163    }
164}
165
166impl Default for TerminalConfigBuilder {
167    fn default() -> Self {
168        Self::new()
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_default_config() {
178        let config = TerminalConfig::default();
179        assert_eq!(config.prompt, "oxur> ");
180        assert_eq!(config.continuation_prompt, "....> ");
181        assert!(config.color_enabled);
182        assert_eq!(config.edit_mode, EditMode::Emacs);
183        assert!(config.banner.is_some());
184    }
185
186    #[test]
187    fn test_builder() {
188        let config = TerminalConfig::builder()
189            .banner("Welcome!")
190            .prompt("λ> ")
191            .continuation_prompt("  | ")
192            .color(false)
193            .edit_mode(EditMode::Vi)
194            .build();
195
196        assert_eq!(config.banner, Some("Welcome!".to_string()));
197        assert_eq!(config.prompt, "λ> ");
198        assert_eq!(config.continuation_prompt, "  | ");
199        assert!(!config.color_enabled);
200        assert_eq!(config.edit_mode, EditMode::Vi);
201    }
202
203    #[test]
204    #[serial_test::serial]
205    fn test_formatted_prompt_with_color() {
206        // Force colors on for testing
207        colored::control::set_override(true);
208
209        // Non-oxur prompt uses standard green
210        let config = TerminalConfig::builder().prompt("test> ").color(true).build();
211        let colored_prompt = config.formatted_prompt();
212        // Colored output should be different from plain text
213        assert_ne!(colored_prompt, "test> ");
214        // Should contain ANSI escape codes
215        assert!(colored_prompt.contains("\x1b["));
216        assert!(colored_prompt.contains("test> "));
217
218        // Reset color override
219        colored::control::unset_override();
220    }
221
222    #[test]
223    #[serial_test::serial]
224    fn test_formatted_prompt_oxur_colors() {
225        // Force colors on for testing
226        colored::control::set_override(true);
227
228        // oxur prompt uses individual colors for each letter + bright green for "> "
229        let config = TerminalConfig::builder().prompt("oxur> ").color(true).build();
230        let prompt = config.formatted_prompt();
231        // Colored output should be different from plain text
232        assert_ne!(prompt, "oxur> ");
233        // Should contain ANSI escape codes for colors
234        assert!(prompt.contains("\x1b["));
235        // Should contain all the letters
236        assert!(prompt.contains("o"));
237        assert!(prompt.contains("x"));
238        assert!(prompt.contains("u"));
239        assert!(prompt.contains("r"));
240        assert!(prompt.contains("> "));
241
242        // Reset color override
243        colored::control::unset_override();
244    }
245
246    #[test]
247    fn test_formatted_prompt_without_color() {
248        let config = TerminalConfig::builder().prompt("test> ").color(false).build();
249        assert_eq!(config.formatted_prompt(), "test> ");
250    }
251
252    #[test]
253    fn test_serde_roundtrip() {
254        let config = TerminalConfig::builder()
255            .banner("Test Banner")
256            .prompt(">>> ")
257            .edit_mode(EditMode::Vi)
258            .build();
259
260        let toml = toml::to_string(&config).unwrap();
261        let parsed: TerminalConfig = toml::from_str(&toml).unwrap();
262
263        assert_eq!(config.banner, parsed.banner);
264        assert_eq!(config.prompt, parsed.prompt);
265        assert_eq!(config.edit_mode, parsed.edit_mode);
266    }
267
268    #[test]
269    fn test_edit_mode_serde() {
270        // Test via a wrapper struct since TOML requires key-value pairs
271        #[derive(Debug, serde::Deserialize)]
272        struct Wrapper {
273            mode: EditMode,
274        }
275
276        let emacs: Wrapper = toml::from_str("mode = \"emacs\"").unwrap();
277        let vi: Wrapper = toml::from_str("mode = \"vi\"").unwrap();
278
279        assert_eq!(emacs.mode, EditMode::Emacs);
280        assert_eq!(vi.mode, EditMode::Vi);
281    }
282
283    #[test]
284    fn test_default_banner_embedded() {
285        let config = TerminalConfig::default();
286        let banner = config.banner.expect("Default config should have banner");
287
288        // Verify banner contains expected elements
289        assert!(banner.contains("Welcome to"));
290        assert!(banner.contains("oxur:"));
291        assert!(banner.contains("http://oxur.li/"));
292        assert!(banner.contains("(help)"));
293        assert!(banner.contains("(quit)"));
294
295        // Verify banner is non-empty and reasonable size
296        assert!(banner.len() > 1000);
297        assert!(banner.len() < 10000);
298    }
299}