1use crate::error::{RecError, Result};
2use crate::models::config::{KNOWN_KEYS, env_var_for_key, validate_key};
3use crate::models::{ColorMode, Config, Verbosity};
4use crate::storage::Paths;
5use std::fmt;
6use std::fs;
7use std::path::Path;
8use toml_edit::DocumentMut;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ConfigSource {
13 Default,
15 File,
17 Env(String),
19}
20
21impl fmt::Display for ConfigSource {
22 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23 match self {
24 ConfigSource::Default => write!(f, "default"),
25 ConfigSource::File => write!(f, "file"),
26 ConfigSource::Env(var) => write!(f, "env:{var}"),
27 }
28 }
29}
30
31#[derive(Debug, Clone)]
33pub struct ConfigValue {
34 pub key: String,
36 pub value: String,
38 pub file_value: Option<String>,
40 pub env_override: Option<(String, String)>,
42 pub source: ConfigSource,
44}
45
46pub struct ConfigLoader {
54 paths: Paths,
55}
56
57impl ConfigLoader {
58 #[must_use]
60 pub fn new(paths: Paths) -> Self {
61 Self { paths }
62 }
63
64 pub fn load(&self) -> Result<Config> {
73 let mut config = Config::default();
74
75 if self.paths.config_file.exists() {
77 let user_config = self.load_from_file(&self.paths.config_file)?;
78 config.merge_with(user_config);
79 }
80
81 self.apply_env_overrides(&mut config);
83
84 Ok(config)
85 }
86
87 #[allow(clippy::unused_self)]
93 fn load_from_file(&self, path: &Path) -> Result<Config> {
94 let contents = fs::read_to_string(path)?;
95 let config: Config = toml::from_str(&contents)?;
96 Ok(config)
97 }
98
99 #[allow(clippy::unused_self)]
109 fn apply_env_overrides(&self, config: &mut Config) {
110 if let Ok(editor) = std::env::var("REC_EDITOR") {
112 config.general.editor = Some(editor);
113 }
114
115 if let Ok(shell) = std::env::var("REC_SHELL") {
117 config.general.shell = Some(shell);
118 }
119
120 if let Ok(path) = std::env::var("REC_STORAGE_PATH") {
122 config.general.storage_path = Some(path.into());
123 }
124
125 if std::env::var("NO_COLOR").is_ok() {
128 config.style.colors = ColorMode::Never;
129 }
130
131 if std::env::var("REC_VERBOSE").is_ok() {
133 config.style.verbosity = Verbosity::Verbose;
134 }
135
136 if std::env::var("REC_QUIET").is_ok() {
138 config.style.verbosity = Verbosity::Quiet;
139 }
140 }
141
142 pub fn save(&self, config: &Config) -> Result<()> {
150 self.paths.ensure_dirs()?;
151
152 let contents = toml::to_string_pretty(config)
153 .map_err(|e| RecError::Config(format!("Serialization error: {e}")))?;
154
155 fs::write(&self.paths.config_file, contents)?;
156 Ok(())
157 }
158
159 pub fn create_default_if_missing(&self) -> Result<bool> {
167 if self.paths.config_file.exists() {
168 return Ok(false);
169 }
170
171 self.save(&Config::default())?;
172 Ok(true)
173 }
174
175 pub fn get_key(&self, key: &str) -> Result<ConfigValue> {
184 validate_key(key).map_err(RecError::Config)?;
185
186 let file_value = self.read_file_value(key)?;
187 let env_override = self.check_env_override(key);
188 let default_value = self.default_value_for_key(key);
189
190 let (value, source) = if let Some((ref var, ref val)) = env_override {
191 (val.clone(), ConfigSource::Env(var.clone()))
192 } else if let Some(ref fv) = file_value {
193 (fv.clone(), ConfigSource::File)
194 } else {
195 (default_value, ConfigSource::Default)
196 };
197
198 Ok(ConfigValue {
199 key: key.to_string(),
200 value,
201 file_value,
202 env_override,
203 source,
204 })
205 }
206
207 pub fn set_key(&self, key: &str, value: &str) -> Result<()> {
221 let type_info = validate_key(key).map_err(RecError::Config)?;
222
223 if type_info.starts_with("array:") {
225 return Err(RecError::Config(
226 "Array values cannot be set via --set. Use 'rec config --edit' instead."
227 .to_string(),
228 ));
229 }
230
231 let contents = if self.paths.config_file.exists() {
233 fs::read_to_string(&self.paths.config_file)?
234 } else {
235 String::new()
236 };
237
238 let mut doc: DocumentMut = contents
239 .parse()
240 .map_err(|e| RecError::Config(format!("Failed to parse config: {e}")))?;
241
242 let parts: Vec<&str> = key.split('.').collect();
244 if parts.len() != 2 {
245 return Err(RecError::Config(format!("Invalid key format: '{key}'")));
246 }
247 let (section, field) = (parts[0], parts[1]);
248
249 if doc.get(section).is_none() {
251 doc[section] = toml_edit::table();
252 }
253
254 doc[section][field] = toml_edit::value(value);
256
257 let new_contents = doc.to_string();
259 toml::from_str::<Config>(&new_contents)?;
260
261 if let Some(parent) = self.paths.config_file.parent() {
263 fs::create_dir_all(parent)?;
264 }
265
266 fs::write(&self.paths.config_file, new_contents)?;
268 Ok(())
269 }
270
271 pub fn list_config(&self) -> Result<Vec<ConfigValue>> {
280 let mut values = Vec::new();
281 for (key, _) in KNOWN_KEYS {
282 values.push(self.get_key(key)?);
283 }
284 Ok(values)
285 }
286
287 fn read_file_value(&self, key: &str) -> Result<Option<String>> {
289 if !self.paths.config_file.exists() {
290 return Ok(None);
291 }
292
293 let contents = fs::read_to_string(&self.paths.config_file)?;
294 let doc: DocumentMut = contents
295 .parse()
296 .map_err(|e| RecError::Config(format!("Failed to parse config: {e}")))?;
297
298 let parts: Vec<&str> = key.split('.').collect();
299 if parts.len() != 2 {
300 return Ok(None);
301 }
302
303 let value = doc
304 .get(parts[0])
305 .and_then(|section| section.get(parts[1]))
306 .and_then(|item| item.as_value())
307 .map(|v| {
308 match v.as_str() {
310 Some(s) => s.to_string(),
311 None => v.to_string(),
312 }
313 });
314
315 Ok(value)
316 }
317
318 #[allow(clippy::unused_self)]
320 fn check_env_override(&self, key: &str) -> Option<(String, String)> {
321 let env_var = env_var_for_key(key)?;
322 let env_value = std::env::var(env_var).ok()?;
323 Some((env_var.to_string(), env_value))
324 }
325
326 #[allow(clippy::unused_self)]
328 fn default_value_for_key(&self, key: &str) -> String {
329 let config = Config::default();
330 match key {
331 "general.editor" => config
332 .general
333 .editor
334 .unwrap_or_else(|| "(not set)".to_string()),
335 "general.shell" => config
336 .general
337 .shell
338 .unwrap_or_else(|| "(not set)".to_string()),
339 "general.storage_path" => config
340 .general
341 .storage_path
342 .map_or_else(|| "(not set)".to_string(), |p| p.display().to_string()),
343 "style.colors" => format!("{:?}", config.style.colors).to_lowercase(),
344 "style.symbols" => format!("{:?}", config.style.symbols).to_lowercase(),
345 "style.verbosity" => format!("{:?}", config.style.verbosity).to_lowercase(),
346 "safety.preset" => format!("{:?}", config.safety.preset).to_lowercase(),
347 "safety.custom_patterns" => "[]".to_string(),
348 _ => "(unknown)".to_string(),
349 }
350 }
351}
352
353pub fn load_config() -> Result<Config> {
361 let paths = Paths::new();
362 let loader = ConfigLoader::new(paths);
363 loader.load()
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::models::{GeneralConfig, SafetyConfig, SafetyPreset, StyleConfig, SymbolMode};
370 use tempfile::TempDir;
371
372 fn create_test_paths(temp_dir: &TempDir) -> Paths {
373 Paths {
374 data_dir: temp_dir.path().join("sessions"),
375 config_dir: temp_dir.path().join("config"),
376 config_file: temp_dir.path().join("config").join("config.toml"),
377 state_dir: temp_dir.path().join("state"),
378 }
379 }
380
381 #[test]
382 fn test_load_default_config() {
383 let temp_dir = TempDir::new().unwrap();
384 let paths = create_test_paths(&temp_dir);
385 let loader = ConfigLoader::new(paths);
386
387 let config = loader.load().unwrap();
389
390 assert!(config.general.editor.is_none());
391 assert!(config.general.shell.is_none());
392 assert_eq!(config.style.colors, ColorMode::Auto);
393 assert_eq!(config.style.verbosity, Verbosity::Normal);
394 assert_eq!(config.safety.preset, SafetyPreset::Moderate);
395 }
396
397 #[test]
398 fn test_load_user_config() {
399 let temp_dir = TempDir::new().unwrap();
400 let paths = create_test_paths(&temp_dir);
401
402 fs::create_dir_all(&paths.config_dir).unwrap();
404 fs::write(
405 &paths.config_file,
406 r#"
407[general]
408editor = "nvim"
409shell = "zsh"
410
411[style]
412colors = "always"
413symbols = "ascii"
414verbosity = "verbose"
415
416[safety]
417preset = "strict"
418custom_patterns = ["kubectl delete"]
419"#,
420 )
421 .unwrap();
422
423 let loader = ConfigLoader::new(paths);
424 let config = loader.load().unwrap();
425
426 assert_eq!(config.general.editor, Some("nvim".to_string()));
427 assert_eq!(config.general.shell, Some("zsh".to_string()));
428 assert_eq!(config.style.colors, ColorMode::Always);
429 assert_eq!(config.style.symbols, SymbolMode::Ascii);
430 assert_eq!(config.style.verbosity, Verbosity::Verbose);
431 assert_eq!(config.safety.preset, SafetyPreset::Strict);
432 assert_eq!(config.safety.custom_patterns, vec!["kubectl delete"]);
433 }
434
435 #[test]
436 fn test_save_config() {
437 let temp_dir = TempDir::new().unwrap();
438 let paths = create_test_paths(&temp_dir);
439 let loader = ConfigLoader::new(paths.clone());
440
441 let config = Config {
442 general: GeneralConfig {
443 editor: Some("vim".to_string()),
444 shell: Some("bash".to_string()),
445 storage_path: None,
446 },
447 style: StyleConfig {
448 colors: ColorMode::Never,
449 symbols: SymbolMode::Unicode,
450 verbosity: Verbosity::Quiet,
451 },
452 safety: SafetyConfig {
453 preset: SafetyPreset::Minimal,
454 custom_patterns: vec!["rm -rf".to_string()],
455 },
456 };
457
458 loader.save(&config).unwrap();
459
460 let contents = fs::read_to_string(&paths.config_file).unwrap();
462 assert!(contents.contains("editor = \"vim\""));
463 assert!(contents.contains("shell = \"bash\""));
464 assert!(contents.contains("colors = \"never\""));
465 assert!(contents.contains("preset = \"minimal\""));
466 }
467
468 #[test]
469 fn test_create_default_if_missing() {
470 let temp_dir = TempDir::new().unwrap();
471 let paths = create_test_paths(&temp_dir);
472 let loader = ConfigLoader::new(paths.clone());
473
474 assert!(loader.create_default_if_missing().unwrap());
476 assert!(paths.config_file.exists());
477
478 assert!(!loader.create_default_if_missing().unwrap());
480 }
481
482 #[test]
483 fn test_invalid_config_file() {
484 let temp_dir = TempDir::new().unwrap();
485 let paths = create_test_paths(&temp_dir);
486
487 fs::create_dir_all(&paths.config_dir).unwrap();
489 fs::write(&paths.config_file, "this is not { valid toml").unwrap();
490
491 let loader = ConfigLoader::new(paths);
492 let result = loader.load();
493
494 assert!(result.is_err());
495 match result {
496 Err(RecError::Toml(e)) => {
497 let msg = e.to_string();
498 assert!(
499 msg.contains("expected") || msg.contains("invalid"),
500 "TOML error should contain parse info: {msg}"
501 );
502 }
503 _ => panic!("Expected Toml error"),
504 }
505 }
506
507 #[test]
508 fn test_merge_semantics() {
509 let temp_dir = TempDir::new().unwrap();
510 let paths = create_test_paths(&temp_dir);
511
512 fs::create_dir_all(&paths.config_dir).unwrap();
514 fs::write(
515 &paths.config_file,
516 r#"
517[general]
518editor = "nvim"
519# shell is not set - should remain None (default)
520
521[style]
522colors = "always"
523# symbols, verbosity use defaults
524"#,
525 )
526 .unwrap();
527
528 let loader = ConfigLoader::new(paths);
529 let config = loader.load().unwrap();
530
531 assert_eq!(config.general.editor, Some("nvim".to_string()));
533 assert_eq!(config.style.colors, ColorMode::Always);
534
535 assert!(config.general.shell.is_none());
537 assert_eq!(config.style.symbols, SymbolMode::Unicode);
539 assert_eq!(config.style.verbosity, Verbosity::Normal);
540 }
541
542 #[test]
547 #[ignore = "requires NO_COLOR env var to be set before process start"]
548 fn test_no_color_env_override() {
549 let temp_dir = TempDir::new().unwrap();
553 let paths = create_test_paths(&temp_dir);
554 let loader = ConfigLoader::new(paths);
555
556 let config = loader.load().unwrap();
557
558 if std::env::var("NO_COLOR").is_ok() {
560 assert_eq!(config.style.colors, ColorMode::Never);
561 }
562 }
563
564 #[test]
565 fn test_load_config_convenience_function() {
566 let result = load_config();
569 assert!(result.is_ok());
570 }
571
572 #[test]
573 fn test_set_key_preserves_comments() {
574 let temp_dir = TempDir::new().unwrap();
575 let paths = create_test_paths(&temp_dir);
576
577 fs::create_dir_all(&paths.config_dir).unwrap();
579 let original = r#"# My rec configuration
580[general]
581# The editor to use
582editor = "vim"
583
584[safety]
585preset = "moderate"
586"#;
587 fs::write(&paths.config_file, original).unwrap();
588
589 let loader = ConfigLoader::new(paths.clone());
590 loader.set_key("safety.preset", "strict").unwrap();
591
592 let result = fs::read_to_string(&paths.config_file).unwrap();
593 assert!(
595 result.contains("# My rec configuration"),
596 "Top-level comment lost: {result}"
597 );
598 assert!(
599 result.contains("# The editor to use"),
600 "Inline comment lost: {result}"
601 );
602 assert!(result.contains("\"strict\""), "Value not updated: {result}");
604 assert!(
606 !result.contains("\"moderate\""),
607 "Old value still present: {result}"
608 );
609 }
610
611 #[test]
612 fn test_set_key_creates_missing_section() {
613 let temp_dir = TempDir::new().unwrap();
614 let paths = create_test_paths(&temp_dir);
615
616 fs::create_dir_all(&paths.config_dir).unwrap();
618 fs::write(&paths.config_file, "").unwrap();
619
620 let loader = ConfigLoader::new(paths.clone());
621 loader.set_key("general.editor", "nvim").unwrap();
622
623 let result = fs::read_to_string(&paths.config_file).unwrap();
624 assert!(
625 result.contains("[general]"),
626 "Section not created: {result}"
627 );
628 assert!(result.contains("\"nvim\""), "Value not set: {result}");
629
630 let config: Config = toml::from_str(&result).unwrap();
632 assert_eq!(config.general.editor, Some("nvim".to_string()));
633 }
634
635 #[test]
636 fn test_set_key_rejects_invalid_value() {
637 let temp_dir = TempDir::new().unwrap();
638 let paths = create_test_paths(&temp_dir);
639
640 fs::create_dir_all(&paths.config_dir).unwrap();
641 fs::write(&paths.config_file, "").unwrap();
642
643 let loader = ConfigLoader::new(paths);
644 let result = loader.set_key("safety.preset", "invalid");
646 assert!(result.is_err(), "Should reject invalid enum value");
647 }
648
649 #[test]
650 fn test_set_key_rejects_array_type() {
651 let temp_dir = TempDir::new().unwrap();
652 let paths = create_test_paths(&temp_dir);
653
654 fs::create_dir_all(&paths.config_dir).unwrap();
655 fs::write(&paths.config_file, "").unwrap();
656
657 let loader = ConfigLoader::new(paths);
658 let result = loader.set_key("safety.custom_patterns", "some value");
659 assert!(result.is_err(), "Should reject array type");
660 let err = result.unwrap_err().to_string();
661 assert!(
662 err.contains("Array values cannot be set via --set"),
663 "Unexpected error: {err}"
664 );
665 assert!(err.contains("--edit"), "Should suggest --edit: {err}");
666 }
667
668 #[test]
669 fn test_get_key_from_file() {
670 let temp_dir = TempDir::new().unwrap();
671 let paths = create_test_paths(&temp_dir);
672
673 fs::create_dir_all(&paths.config_dir).unwrap();
674 fs::write(
675 &paths.config_file,
676 r#"
677[safety]
678preset = "strict"
679"#,
680 )
681 .unwrap();
682
683 let loader = ConfigLoader::new(paths);
684 let cv = loader.get_key("safety.preset").unwrap();
685
686 assert_eq!(cv.key, "safety.preset");
687 assert_eq!(cv.value, "strict");
688 assert_eq!(cv.file_value, Some("strict".to_string()));
689 assert_eq!(cv.source, ConfigSource::File);
690 }
691
692 #[test]
693 fn test_get_key_not_set() {
694 let temp_dir = TempDir::new().unwrap();
695 let paths = create_test_paths(&temp_dir);
696 let loader = ConfigLoader::new(paths);
699 let cv = loader.get_key("general.editor").unwrap();
700
701 assert_eq!(cv.key, "general.editor");
702 assert_eq!(cv.value, "(not set)");
703 assert!(cv.file_value.is_none());
704 assert_eq!(cv.source, ConfigSource::Default);
705 }
706
707 #[test]
708 fn test_list_config_sources() {
709 let temp_dir = TempDir::new().unwrap();
710 let paths = create_test_paths(&temp_dir);
711
712 fs::create_dir_all(&paths.config_dir).unwrap();
713 fs::write(
714 &paths.config_file,
715 r#"
716[general]
717editor = "nvim"
718
719[safety]
720preset = "strict"
721"#,
722 )
723 .unwrap();
724
725 let loader = ConfigLoader::new(paths);
726 let values = loader.list_config().unwrap();
727
728 assert_eq!(values.len(), 8, "Should list all 8 known keys");
730
731 let editor = values.iter().find(|v| v.key == "general.editor").unwrap();
733 assert_eq!(editor.value, "nvim");
734 assert_eq!(editor.source, ConfigSource::File);
735
736 let symbols = values.iter().find(|v| v.key == "style.symbols").unwrap();
738 assert_eq!(symbols.value, "unicode");
739 assert_eq!(symbols.source, ConfigSource::Default);
740
741 let preset = values.iter().find(|v| v.key == "safety.preset").unwrap();
743 assert_eq!(preset.value, "strict");
744 assert_eq!(preset.source, ConfigSource::File);
745 }
746}