Skip to main content

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.2.3.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.2.3.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
58/// TODO: most of this really belongs in the REPL crate, not in oxur-cli
59impl TerminalConfig {
60    /// Create a new builder for TerminalConfig
61    pub fn builder() -> TerminalConfigBuilder {
62        TerminalConfigBuilder::new()
63    }
64
65    /// Get the prompt with optional color formatting
66    ///
67    /// When colors are enabled and the prompt starts with "oxur",
68    /// each letter is colored individually:
69    /// - "o" = bright orange
70    /// - "x" = ochre
71    /// - "u" = medium ochre
72    /// - "r" = dark ochre
73    /// - "> " = bright green
74    pub fn formatted_prompt(&self) -> String {
75        if self.color_enabled {
76            // Check if prompt starts with "oxur" for special coloring
77            if self.prompt.starts_with("oxur") {
78                let rest = &self.prompt[4..]; // Everything after "oxur"
79                                              // Color each letter individually using colored crate
80                format!(
81                    "{}{}{}{}{}",
82                    "o".truecolor(240, 120, 45),
83                    "x".truecolor(195, 90, 30),
84                    "u".truecolor(135, 60, 15),
85                    "r".truecolor(105, 45, 15),
86                    rest.truecolor(0, 255, 0),
87                )
88            } else {
89                self.prompt.green().to_string()
90            }
91        } else {
92            self.prompt.clone()
93        }
94    }
95
96    /// Get the continuation prompt with optional color formatting
97    pub fn formatted_continuation_prompt(&self) -> String {
98        if self.color_enabled {
99            self.continuation_prompt.green().to_string()
100        } else {
101            self.continuation_prompt.clone()
102        }
103    }
104
105    /// Merge another config into this one (other takes precedence for Some values)
106    pub fn merge(&mut self, other: TerminalConfig) {
107        if other.banner.is_some() {
108            self.banner = other.banner;
109        }
110        // For non-Option fields, we need partial config pattern
111        // For now, other always overrides if explicitly set in file
112        self.prompt = other.prompt;
113        self.continuation_prompt = other.continuation_prompt;
114        self.color_enabled = other.color_enabled;
115        self.edit_mode = other.edit_mode;
116    }
117}
118
119/// Builder for TerminalConfig
120#[derive(Debug, Clone)]
121pub struct TerminalConfigBuilder {
122    config: TerminalConfig,
123}
124
125impl TerminalConfigBuilder {
126    /// Create a new builder with default values
127    pub fn new() -> Self {
128        Self { config: TerminalConfig::default() }
129    }
130
131    /// Set a custom login banner
132    pub fn banner(mut self, banner: impl Into<String>) -> Self {
133        self.config.banner = Some(banner.into());
134        self
135    }
136
137    /// Set the primary prompt
138    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
139        self.config.prompt = prompt.into();
140        self
141    }
142
143    /// Set the continuation prompt for multi-line input
144    pub fn continuation_prompt(mut self, prompt: impl Into<String>) -> Self {
145        self.config.continuation_prompt = prompt.into();
146        self
147    }
148
149    /// Enable or disable ANSI colors
150    pub fn color(mut self, enabled: bool) -> Self {
151        self.config.color_enabled = enabled;
152        self
153    }
154
155    /// Set the line editing mode
156    pub fn edit_mode(mut self, mode: EditMode) -> Self {
157        self.config.edit_mode = mode;
158        self
159    }
160
161    /// Build the TerminalConfig
162    pub fn build(self) -> TerminalConfig {
163        self.config
164    }
165}
166
167impl Default for TerminalConfigBuilder {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_default_config() {
179        let config = TerminalConfig::default();
180        assert_eq!(config.prompt, "oxur> ");
181        assert_eq!(config.continuation_prompt, "....> ");
182        assert!(config.color_enabled);
183        assert_eq!(config.edit_mode, EditMode::Emacs);
184        assert!(config.banner.is_some());
185    }
186
187    #[test]
188    fn test_builder() {
189        let config = TerminalConfig::builder()
190            .banner("Welcome!")
191            .prompt("λ> ")
192            .continuation_prompt("  | ")
193            .color(false)
194            .edit_mode(EditMode::Vi)
195            .build();
196
197        assert_eq!(config.banner, Some("Welcome!".to_string()));
198        assert_eq!(config.prompt, "λ> ");
199        assert_eq!(config.continuation_prompt, "  | ");
200        assert!(!config.color_enabled);
201        assert_eq!(config.edit_mode, EditMode::Vi);
202    }
203
204    #[test]
205    #[serial_test::serial]
206    fn test_formatted_prompt_with_color() {
207        // Force colors on for testing
208        colored::control::set_override(true);
209
210        // Non-oxur prompt uses standard green
211        let config = TerminalConfig::builder().prompt("test> ").color(true).build();
212        let colored_prompt = config.formatted_prompt();
213        // Colored output should be different from plain text
214        assert_ne!(colored_prompt, "test> ");
215        // Should contain ANSI escape codes
216        assert!(colored_prompt.contains("\x1b["));
217        assert!(colored_prompt.contains("test> "));
218
219        // Reset color override
220        colored::control::unset_override();
221    }
222
223    #[test]
224    #[serial_test::serial]
225    fn test_formatted_prompt_oxur_colors() {
226        // Force colors on for testing
227        colored::control::set_override(true);
228
229        // oxur prompt uses individual colors for each letter + bright green for "> "
230        let config = TerminalConfig::builder().prompt("oxur> ").color(true).build();
231        let prompt = config.formatted_prompt();
232        // Colored output should be different from plain text
233        assert_ne!(prompt, "oxur> ");
234        // Should contain ANSI escape codes for colors
235        assert!(prompt.contains("\x1b["));
236        // Should contain all the letters
237        assert!(prompt.contains("o"));
238        assert!(prompt.contains("x"));
239        assert!(prompt.contains("u"));
240        assert!(prompt.contains("r"));
241        assert!(prompt.contains("> "));
242
243        // Reset color override
244        colored::control::unset_override();
245    }
246
247    #[test]
248    fn test_formatted_prompt_without_color() {
249        let config = TerminalConfig::builder().prompt("test> ").color(false).build();
250        assert_eq!(config.formatted_prompt(), "test> ");
251    }
252
253    #[test]
254    fn test_serde_roundtrip() {
255        let config = TerminalConfig::builder()
256            .banner("Test Banner")
257            .prompt(">>> ")
258            .edit_mode(EditMode::Vi)
259            .build();
260
261        let toml = toml::to_string(&config).unwrap();
262        let parsed: TerminalConfig = toml::from_str(&toml).unwrap();
263
264        assert_eq!(config.banner, parsed.banner);
265        assert_eq!(config.prompt, parsed.prompt);
266        assert_eq!(config.edit_mode, parsed.edit_mode);
267    }
268
269    #[test]
270    fn test_edit_mode_serde() {
271        // Test via a wrapper struct since TOML requires key-value pairs
272        #[derive(Debug, serde::Deserialize)]
273        struct Wrapper {
274            mode: EditMode,
275        }
276
277        let emacs: Wrapper = toml::from_str("mode = \"emacs\"").unwrap();
278        let vi: Wrapper = toml::from_str("mode = \"vi\"").unwrap();
279
280        assert_eq!(emacs.mode, EditMode::Emacs);
281        assert_eq!(vi.mode, EditMode::Vi);
282    }
283
284    #[test]
285    fn test_default_banner_embedded() {
286        let config = TerminalConfig::default();
287        let banner = config.banner.expect("Default config should have banner");
288
289        // Verify banner contains expected elements
290        assert!(banner.contains("Welcome to"));
291        assert!(banner.contains("oxur:"));
292        assert!(banner.contains("https://oxur.ελ/")); // Updated for v0.2.3 banner
293        assert!(banner.contains("https://github.com/oxur/oxur/"));
294        assert!(banner.contains("(help)"));
295        assert!(banner.contains("(quit)"));
296
297        // Verify banner is non-empty and reasonable size
298        // Note: v0.2.3 banner is ~21KB due to extensive ANSI color codes
299        assert!(banner.len() > 1000);
300        assert!(banner.len() < 30000);
301    }
302}