Skip to main content

rec/config/
loader.rs

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/// Source of a configuration value.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ConfigSource {
13    /// Value comes from hardcoded defaults.
14    Default,
15    /// Value is explicitly set in the config file.
16    File,
17    /// Value is overridden by an environment variable.
18    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/// A resolved configuration value with source information.
32#[derive(Debug, Clone)]
33pub struct ConfigValue {
34    /// The dot-path key (e.g., "safety.preset").
35    pub key: String,
36    /// The current effective value.
37    pub value: String,
38    /// The value from the config file, if explicitly set.
39    pub file_value: Option<String>,
40    /// Environment variable override, if any: `(var_name, var_value)`.
41    pub env_override: Option<(String, String)>,
42    /// Where the effective value comes from.
43    pub source: ConfigSource,
44}
45
46/// Configuration loader with merge semantics.
47///
48/// Loads configuration from multiple sources with proper precedence:
49/// 1. Hardcoded defaults (`Config::default()`)
50/// 2. User config file (~/.config/rec/config.toml)
51/// 3. Environment variables (REC_*, `NO_COLOR`)
52/// 4. CLI flags (handled later in CLI layer)
53pub struct ConfigLoader {
54    paths: Paths,
55}
56
57impl ConfigLoader {
58    /// Create a new `ConfigLoader` with the given paths.
59    #[must_use]
60    pub fn new(paths: Paths) -> Self {
61        Self { paths }
62    }
63
64    /// Load config with merge semantics: defaults < user config < env vars.
65    ///
66    /// Starts with default config, merges user config if it exists,
67    /// then applies environment variable overrides.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the config file exists but is invalid TOML.
72    pub fn load(&self) -> Result<Config> {
73        let mut config = Config::default();
74
75        // Load user config if exists
76        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        // Apply environment variable overrides
82        self.apply_env_overrides(&mut config);
83
84        Ok(config)
85    }
86
87    /// Load config from a specific file path.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the file cannot be read or is invalid TOML.
92    #[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    /// Apply environment variable overrides to the config.
100    ///
101    /// Supported environment variables:
102    /// - `REC_EDITOR`: Override editor setting
103    /// - `REC_SHELL`: Override shell setting
104    /// - `REC_STORAGE_PATH`: Override storage path
105    /// - `NO_COLOR`: Disable colors (standard env var)
106    /// - `REC_VERBOSE`: Enable verbose mode
107    /// - `REC_QUIET`: Enable quiet mode
108    #[allow(clippy::unused_self)]
109    fn apply_env_overrides(&self, config: &mut Config) {
110        // REC_EDITOR overrides editor
111        if let Ok(editor) = std::env::var("REC_EDITOR") {
112            config.general.editor = Some(editor);
113        }
114
115        // REC_SHELL overrides shell
116        if let Ok(shell) = std::env::var("REC_SHELL") {
117            config.general.shell = Some(shell);
118        }
119
120        // REC_STORAGE_PATH overrides storage path
121        if let Ok(path) = std::env::var("REC_STORAGE_PATH") {
122            config.general.storage_path = Some(path.into());
123        }
124
125        // NO_COLOR disables colors (standard env var)
126        // https://no-color.org/
127        if std::env::var("NO_COLOR").is_ok() {
128            config.style.colors = ColorMode::Never;
129        }
130
131        // REC_VERBOSE enables verbose mode
132        if std::env::var("REC_VERBOSE").is_ok() {
133            config.style.verbosity = Verbosity::Verbose;
134        }
135
136        // REC_QUIET enables quiet mode (overrides REC_VERBOSE if both set)
137        if std::env::var("REC_QUIET").is_ok() {
138            config.style.verbosity = Verbosity::Quiet;
139        }
140    }
141
142    /// Save config to the default location.
143    ///
144    /// Creates the config directory if it doesn't exist.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if directory creation or file write fails.
149    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    /// Create a default config file if it doesn't exist.
160    ///
161    /// Returns `true` if the file was created, `false` if it already existed.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if directory creation or file write fails.
166    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    /// Get a single config key with source information.
176    ///
177    /// Reads the TOML file to find the file value, checks env overrides,
178    /// and returns the effective value with source annotation.
179    ///
180    /// # Errors
181    ///
182    /// Returns an error if the key is unknown or the file cannot be read.
183    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    /// Set a config key to a new value, preserving TOML comments and formatting.
208    ///
209    /// Uses `toml_edit::DocumentMut` for format-preserving writes. Creates
210    /// sections if they don't exist. Validates the full config after modification
211    /// by deserializing (round-trip check).
212    ///
213    /// # Errors
214    ///
215    /// Returns an error if:
216    /// - The key is unknown
217    /// - The key is an array type (use `--edit` instead)
218    /// - The value fails round-trip validation
219    /// - File I/O fails
220    pub fn set_key(&self, key: &str, value: &str) -> Result<()> {
221        let type_info = validate_key(key).map_err(RecError::Config)?;
222
223        // Reject array types
224        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        // Read existing file or start fresh
232        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        // Split "section.field" into parts
243        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        // Ensure section exists as a table
250        if doc.get(section).is_none() {
251            doc[section] = toml_edit::table();
252        }
253
254        // Set the value as a TOML string
255        doc[section][field] = toml_edit::value(value);
256
257        // Validate by deserializing full config (round-trip check)
258        let new_contents = doc.to_string();
259        toml::from_str::<Config>(&new_contents)?;
260
261        // Ensure parent directories exist
262        if let Some(parent) = self.paths.config_file.parent() {
263            fs::create_dir_all(parent)?;
264        }
265
266        // Write back (preserves comments!)
267        fs::write(&self.paths.config_file, new_contents)?;
268        Ok(())
269    }
270
271    /// List all config keys with their values and sources.
272    ///
273    /// For each known key, determines the effective value and whether it
274    /// comes from the default, file, or an environment variable.
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if the config file exists but cannot be read.
279    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    /// Read a raw value from the TOML file for a given dot-path key.
288    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                // Return the value without quotes for strings
309                match v.as_str() {
310                    Some(s) => s.to_string(),
311                    None => v.to_string(),
312                }
313            });
314
315        Ok(value)
316    }
317
318    /// Check if an environment variable overrides the given key.
319    #[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    /// Get the default value for a known key.
327    #[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
353/// Convenience function to load config with default paths.
354///
355/// Creates a Paths instance and loads config using `ConfigLoader`.
356///
357/// # Errors
358///
359/// Returns an error if config loading fails.
360pub 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        // No config file exists, should return defaults
388        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        // Create config file
403        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        // Verify file exists and is valid TOML
461        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        // First call should create the file
475        assert!(loader.create_default_if_missing().unwrap());
476        assert!(paths.config_file.exists());
477
478        // Second call should not create (already exists)
479        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        // Create invalid TOML file
488        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        // Create partial config (only some fields)
513        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        // User-specified values
532        assert_eq!(config.general.editor, Some("nvim".to_string()));
533        assert_eq!(config.style.colors, ColorMode::Always);
534
535        // Default values preserved
536        assert!(config.general.shell.is_none());
537        // Note: merge_with overwrites style entirely, so defaults from file
538        assert_eq!(config.style.symbols, SymbolMode::Unicode);
539        assert_eq!(config.style.verbosity, Verbosity::Normal);
540    }
541
542    // Note: Environment variable tests need to be run with env vars set.
543    // These are marked as ignore since they would affect other tests.
544    // Run with: cargo test env_override -- --ignored
545
546    #[test]
547    #[ignore = "requires NO_COLOR env var to be set before process start"]
548    fn test_no_color_env_override() {
549        // Set NO_COLOR before running this test
550        // NO_COLOR=1 cargo test test_no_color_env_override -- --ignored
551
552        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 NO_COLOR is set, colors should be Never
559        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        // This test just ensures the function compiles and runs
567        // It uses the system's actual XDG paths
568        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        // Create config with comments
578        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        // Comments must be preserved
594        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        // Value must be updated
603        assert!(result.contains("\"strict\""), "Value not updated: {result}");
604        // Old value must be gone
605        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        // Start with empty file (no sections)
617        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        // Verify round-trip: the file is valid config
631        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        // "invalid" is not a valid SafetyPreset enum value
645        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        // No config file at all
697
698        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        // Should have all KNOWN_KEYS entries
729        assert_eq!(values.len(), 8, "Should list all 8 known keys");
730
731        // Check a file-set value
732        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        // Check a default value
737        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        // Check another file-set value
742        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}