Skip to main content

rec/models/
config.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4/// Color output mode for terminal display.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
6#[serde(rename_all = "lowercase")]
7pub enum ColorMode {
8    /// Auto-detect based on terminal capabilities and `NO_COLOR` env var
9    #[default]
10    Auto,
11    /// Always use colors
12    Always,
13    /// Never use colors
14    Never,
15}
16
17/// Symbol mode for terminal output.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "lowercase")]
20pub enum SymbolMode {
21    /// Use modern Unicode symbols (checkmark, cross, etc.)
22    #[default]
23    Unicode,
24    /// Use ASCII-only symbols for compatibility
25    Ascii,
26}
27
28/// Verbosity level for output.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
30#[serde(rename_all = "lowercase")]
31pub enum Verbosity {
32    /// Only show errors
33    Quiet,
34    /// Normal output level
35    #[default]
36    Normal,
37    /// Show debug information
38    Verbose,
39}
40
41/// Safety preset for dangerous command detection.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
43#[serde(rename_all = "lowercase")]
44pub enum SafetyPreset {
45    /// Maximum protection - warns on many potentially dangerous commands
46    Strict,
47    /// Balanced protection (default)
48    #[default]
49    Moderate,
50    /// Minimal protection - only warns on most dangerous commands
51    Minimal,
52}
53
54/// General configuration options.
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct GeneralConfig {
57    /// Editor for editing sessions (falls back to $EDITOR)
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub editor: Option<String>,
60
61    /// Default shell for replay (falls back to $SHELL)
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub shell: Option<String>,
64
65    /// Custom storage path (falls back to XDG data directory)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub storage_path: Option<PathBuf>,
68}
69
70/// Style configuration for terminal output.
71#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72pub struct StyleConfig {
73    /// Color output mode
74    #[serde(default)]
75    pub colors: ColorMode,
76
77    /// Symbol mode (unicode or ascii)
78    #[serde(default)]
79    pub symbols: SymbolMode,
80
81    /// Output verbosity level
82    #[serde(default)]
83    pub verbosity: Verbosity,
84}
85
86/// Safety configuration for dangerous command detection.
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct SafetyConfig {
89    /// Built-in safety preset
90    #[serde(default)]
91    pub preset: SafetyPreset,
92
93    /// Custom patterns to warn about (in addition to preset)
94    #[serde(default)]
95    pub custom_patterns: Vec<String>,
96}
97
98/// Complete application configuration.
99///
100/// Matches the TOML schema from CONTEXT.md:
101/// ```toml
102/// [general]
103/// editor = "vim"
104/// shell = "bash"
105/// storage_path = ""
106///
107/// [style]
108/// colors = "auto"
109/// symbols = "unicode"
110/// verbosity = "normal"
111///
112/// [safety]
113/// preset = "moderate"
114/// custom_patterns = ["kubectl delete", "terraform destroy"]
115/// ```
116#[derive(Debug, Clone, Serialize, Deserialize, Default)]
117pub struct Config {
118    /// General settings
119    #[serde(default)]
120    pub general: GeneralConfig,
121
122    /// Style settings for terminal output
123    #[serde(default)]
124    pub style: StyleConfig,
125
126    /// Safety settings for command warnings
127    #[serde(default)]
128    pub safety: SafetyConfig,
129}
130
131impl Config {
132    /// Merge another config into this one.
133    ///
134    /// Values from `other` override values in `self` where `other` has
135    /// non-default values. Useful for layering config from multiple sources
136    /// (defaults -> user config -> CLI args).
137    pub fn merge_with(&mut self, other: Config) {
138        // Merge general config
139        if other.general.editor.is_some() {
140            self.general.editor = other.general.editor;
141        }
142        if other.general.shell.is_some() {
143            self.general.shell = other.general.shell;
144        }
145        if other.general.storage_path.is_some() {
146            self.general.storage_path = other.general.storage_path;
147        }
148
149        // Merge style config (always override if explicitly set)
150        self.style.colors = other.style.colors;
151        self.style.symbols = other.style.symbols;
152        self.style.verbosity = other.style.verbosity;
153
154        // Merge safety config
155        self.safety.preset = other.safety.preset;
156        if !other.safety.custom_patterns.is_empty() {
157            self.safety.custom_patterns = other.safety.custom_patterns;
158        }
159    }
160}
161
162/// Known configuration keys with their type descriptors.
163///
164/// Each entry is `(dot_path, type_descriptor)` where `type_descriptor` is:
165/// - `"string"` — free-form string value
166/// - `"enum:val1,val2,..."` — one of the listed values
167/// - `"array:string"` — array of strings (cannot be set via `--set`)
168pub const KNOWN_KEYS: &[(&str, &str)] = &[
169    ("general.editor", "string"),
170    ("general.shell", "string"),
171    ("general.storage_path", "string"),
172    ("style.colors", "enum:auto,always,never"),
173    ("style.symbols", "enum:unicode,ascii"),
174    ("style.verbosity", "enum:quiet,normal,verbose"),
175    ("safety.preset", "enum:strict,moderate,minimal"),
176    ("safety.custom_patterns", "array:string"),
177];
178
179/// Environment variable overrides for config keys.
180///
181/// Each entry is `(dot_path, env_var_name)`.
182/// Special cases:
183/// - `NO_COLOR`: any value → `style.colors = "never"`
184/// - `REC_VERBOSE`: any value → `style.verbosity = "verbose"`
185pub const ENV_VAR_MAPPING: &[(&str, &str)] = &[
186    ("general.editor", "REC_EDITOR"),
187    ("general.shell", "REC_SHELL"),
188    ("general.storage_path", "REC_STORAGE_PATH"),
189    ("style.colors", "NO_COLOR"),
190    ("style.verbosity", "REC_VERBOSE"),
191];
192
193/// Validate that a config key is known.
194///
195/// Returns the type descriptor on success, or a helpful error message
196/// with fuzzy suggestions on failure.
197///
198/// # Errors
199///
200/// Returns an error string if the key is not a known configuration key.
201pub fn validate_key(key: &str) -> std::result::Result<&'static str, String> {
202    KNOWN_KEYS
203        .iter()
204        .find(|(k, _)| *k == key)
205        .map(|(_, type_info)| *type_info)
206        .ok_or_else(|| {
207            let suggestion = suggest_config_key(key);
208            if suggestion.is_empty() {
209                format!("Unknown config key '{key}'.")
210            } else {
211                format!("Unknown config key '{key}'. {suggestion}")
212            }
213        })
214}
215
216/// Look up the environment variable name for a config key.
217///
218/// Returns `Some(env_var_name)` if the key has an env override, `None` otherwise.
219#[must_use]
220pub fn env_var_for_key(key: &str) -> Option<&'static str> {
221    ENV_VAR_MAPPING
222        .iter()
223        .find(|(k, _)| *k == key)
224        .map(|(_, env_var)| *env_var)
225}
226
227/// Suggest a similar config key using fuzzy matching.
228///
229/// Returns a suggestion string like `"Did you mean 'safety.preset'?"` or empty string.
230#[must_use]
231pub fn suggest_config_key(key: &str) -> String {
232    use strsim::levenshtein;
233
234    let mut best: Option<(&str, usize)> = None;
235    for (known_key, _) in KNOWN_KEYS {
236        let dist = levenshtein(key, known_key);
237        if dist <= 3 {
238            if let Some((_, best_dist)) = best {
239                if dist < best_dist {
240                    best = Some((known_key, dist));
241                }
242            } else {
243                best = Some((known_key, dist));
244            }
245        }
246    }
247    match best {
248        Some((suggestion, _)) => format!("Did you mean '{suggestion}'?"),
249        None => String::new(),
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_config_defaults() {
259        let config = Config::default();
260
261        assert!(config.general.editor.is_none());
262        assert!(config.general.shell.is_none());
263        assert!(config.general.storage_path.is_none());
264        assert_eq!(config.style.colors, ColorMode::Auto);
265        assert_eq!(config.style.symbols, SymbolMode::Unicode);
266        assert_eq!(config.style.verbosity, Verbosity::Normal);
267        assert_eq!(config.safety.preset, SafetyPreset::Moderate);
268        assert!(config.safety.custom_patterns.is_empty());
269    }
270
271    #[test]
272    fn test_config_toml_serialization() {
273        let config = Config {
274            general: GeneralConfig {
275                editor: Some("vim".to_string()),
276                shell: Some("bash".to_string()),
277                storage_path: None,
278            },
279            style: StyleConfig {
280                colors: ColorMode::Always,
281                symbols: SymbolMode::Ascii,
282                verbosity: Verbosity::Verbose,
283            },
284            safety: SafetyConfig {
285                preset: SafetyPreset::Strict,
286                custom_patterns: vec!["kubectl delete".to_string()],
287            },
288        };
289
290        let toml_str = toml::to_string_pretty(&config).expect("Failed to serialize");
291        assert!(toml_str.contains("editor = \"vim\""));
292        assert!(toml_str.contains("colors = \"always\""));
293        assert!(toml_str.contains("preset = \"strict\""));
294
295        let deserialized: Config = toml::from_str(&toml_str).expect("Failed to deserialize");
296        assert_eq!(deserialized.general.editor, Some("vim".to_string()));
297        assert_eq!(deserialized.style.colors, ColorMode::Always);
298    }
299
300    #[test]
301    fn test_config_merge() {
302        let mut base = Config::default();
303        let override_config = Config {
304            general: GeneralConfig {
305                editor: Some("nvim".to_string()),
306                shell: None,
307                storage_path: Some(PathBuf::from("/custom/path")),
308            },
309            style: StyleConfig {
310                colors: ColorMode::Never,
311                symbols: SymbolMode::Ascii,
312                verbosity: Verbosity::Quiet,
313            },
314            safety: SafetyConfig {
315                preset: SafetyPreset::Strict,
316                custom_patterns: vec!["rm -rf".to_string()],
317            },
318        };
319
320        base.merge_with(override_config);
321
322        assert_eq!(base.general.editor, Some("nvim".to_string()));
323        assert!(base.general.shell.is_none());
324        assert_eq!(
325            base.general.storage_path,
326            Some(PathBuf::from("/custom/path"))
327        );
328        assert_eq!(base.style.colors, ColorMode::Never);
329        assert_eq!(base.safety.preset, SafetyPreset::Strict);
330        assert_eq!(base.safety.custom_patterns, vec!["rm -rf".to_string()]);
331    }
332
333    #[test]
334    fn test_color_mode_serialization() {
335        assert_eq!(serde_json::to_string(&ColorMode::Auto).unwrap(), "\"auto\"");
336        assert_eq!(
337            serde_json::to_string(&ColorMode::Always).unwrap(),
338            "\"always\""
339        );
340        assert_eq!(
341            serde_json::to_string(&ColorMode::Never).unwrap(),
342            "\"never\""
343        );
344    }
345
346    #[test]
347    fn test_safety_preset_serialization() {
348        assert_eq!(
349            serde_json::to_string(&SafetyPreset::Strict).unwrap(),
350            "\"strict\""
351        );
352        assert_eq!(
353            serde_json::to_string(&SafetyPreset::Moderate).unwrap(),
354            "\"moderate\""
355        );
356        assert_eq!(
357            serde_json::to_string(&SafetyPreset::Minimal).unwrap(),
358            "\"minimal\""
359        );
360    }
361
362    #[test]
363    fn test_validate_known_key() {
364        assert_eq!(
365            validate_key("safety.preset").unwrap(),
366            "enum:strict,moderate,minimal"
367        );
368        assert_eq!(validate_key("general.editor").unwrap(), "string");
369        assert_eq!(
370            validate_key("safety.custom_patterns").unwrap(),
371            "array:string"
372        );
373        assert_eq!(
374            validate_key("style.colors").unwrap(),
375            "enum:auto,always,never"
376        );
377    }
378
379    #[test]
380    fn test_validate_unknown_key_with_suggestion() {
381        let err = validate_key("safety.presets").unwrap_err();
382        assert!(
383            err.contains("Unknown config key 'safety.presets'"),
384            "got: {err}"
385        );
386        assert!(err.contains("Did you mean 'safety.preset'?"), "got: {err}");
387
388        let err = validate_key("bogus.key").unwrap_err();
389        assert!(err.contains("Unknown config key"), "got: {err}");
390    }
391
392    #[test]
393    fn test_env_var_mapping() {
394        assert_eq!(env_var_for_key("general.editor"), Some("REC_EDITOR"));
395        assert_eq!(env_var_for_key("style.colors"), Some("NO_COLOR"));
396        assert_eq!(env_var_for_key("safety.preset"), None);
397    }
398}