1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
6#[serde(rename_all = "lowercase")]
7pub enum ColorMode {
8 #[default]
10 Auto,
11 Always,
13 Never,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
19#[serde(rename_all = "lowercase")]
20pub enum SymbolMode {
21 #[default]
23 Unicode,
24 Ascii,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
30#[serde(rename_all = "lowercase")]
31pub enum Verbosity {
32 Quiet,
34 #[default]
36 Normal,
37 Verbose,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
43#[serde(rename_all = "lowercase")]
44pub enum SafetyPreset {
45 Strict,
47 #[default]
49 Moderate,
50 Minimal,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct GeneralConfig {
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub editor: Option<String>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub shell: Option<String>,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub storage_path: Option<PathBuf>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72pub struct StyleConfig {
73 #[serde(default)]
75 pub colors: ColorMode,
76
77 #[serde(default)]
79 pub symbols: SymbolMode,
80
81 #[serde(default)]
83 pub verbosity: Verbosity,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct SafetyConfig {
89 #[serde(default)]
91 pub preset: SafetyPreset,
92
93 #[serde(default)]
95 pub custom_patterns: Vec<String>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, Default)]
117pub struct Config {
118 #[serde(default)]
120 pub general: GeneralConfig,
121
122 #[serde(default)]
124 pub style: StyleConfig,
125
126 #[serde(default)]
128 pub safety: SafetyConfig,
129}
130
131impl Config {
132 pub fn merge_with(&mut self, other: Config) {
138 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 self.style.colors = other.style.colors;
151 self.style.symbols = other.style.symbols;
152 self.style.verbosity = other.style.verbosity;
153
154 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
162pub 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
179pub 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
193pub 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#[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#[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}