Skip to main content

rch_common/config/
source.rs

1//! Configuration source tracking.
2//!
3//! Tracks where each configuration value came from for debugging.
4
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use std::path::PathBuf;
8
9/// Where a configuration value originated.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum ConfigSource {
13    /// Built-in default value.
14    Default,
15    /// User-level config file (~/.config/rch/config.toml).
16    UserConfig,
17    /// Project-level config file (.rch/config.toml).
18    ProjectConfig,
19    /// Loaded from .env or .rch.env file.
20    DotEnv,
21    /// From a profile (dev/prod/test).
22    Profile,
23    /// Environment variable.
24    Environment,
25    /// Command-line argument (highest precedence).
26    CommandLine,
27}
28
29impl ConfigSource {
30    /// Get the precedence level (higher = takes priority).
31    pub fn precedence(&self) -> u8 {
32        match self {
33            ConfigSource::Default => 0,
34            ConfigSource::UserConfig => 1,
35            ConfigSource::ProjectConfig => 2,
36            ConfigSource::DotEnv => 3,
37            ConfigSource::Profile => 4,
38            ConfigSource::Environment => 5,
39            ConfigSource::CommandLine => 6,
40        }
41    }
42
43    /// Get a human-readable name for this source.
44    pub fn display_name(&self) -> &'static str {
45        match self {
46            ConfigSource::Default => "default",
47            ConfigSource::UserConfig => "user config",
48            ConfigSource::ProjectConfig => "project config",
49            ConfigSource::DotEnv => ".env file",
50            ConfigSource::Profile => "profile",
51            ConfigSource::Environment => "environment",
52            ConfigSource::CommandLine => "command line",
53        }
54    }
55}
56
57impl fmt::Display for ConfigSource {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        write!(f, "{}", self.display_name())
60    }
61}
62
63/// Detailed source for a specific configuration value.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum ConfigValueSource {
67    /// Built-in default value.
68    Default,
69    /// User-level config file (~/.config/rch/config.toml).
70    UserConfig(PathBuf),
71    /// Project-level config file (.rch/config.toml).
72    ProjectConfig(PathBuf),
73    /// Environment variable name.
74    EnvVar(String),
75}
76
77impl ConfigValueSource {
78    /// Render a human-friendly label for CLI output.
79    pub fn label(&self) -> String {
80        match self {
81            ConfigValueSource::Default => "default".to_string(),
82            ConfigValueSource::UserConfig(path) => {
83                format!("user:{}", path.display())
84            }
85            ConfigValueSource::ProjectConfig(path) => {
86                format!("project:{}", path.display())
87            }
88            ConfigValueSource::EnvVar(name) => format!("env:{}", name),
89        }
90    }
91}
92
93impl fmt::Display for ConfigValueSource {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        write!(f, "{}", self.label())
96    }
97}
98
99/// A configuration value with its source.
100#[derive(Debug, Clone)]
101pub struct Sourced<T> {
102    /// The actual value.
103    pub value: T,
104    /// Where this value came from.
105    pub source: ConfigSource,
106    /// Optional environment variable name if from environment.
107    pub env_var: Option<String>,
108}
109
110impl<T> Sourced<T> {
111    /// Create a new sourced value.
112    pub fn new(value: T, source: ConfigSource) -> Self {
113        Self {
114            value,
115            source,
116            env_var: None,
117        }
118    }
119
120    /// Create a sourced value from an environment variable.
121    pub fn from_env(value: T, var_name: impl Into<String>) -> Self {
122        Self {
123            value,
124            source: ConfigSource::Environment,
125            env_var: Some(var_name.into()),
126        }
127    }
128
129    /// Create a default sourced value.
130    pub fn default_value(value: T) -> Self {
131        Self::new(value, ConfigSource::Default)
132    }
133
134    /// Map the value while preserving source.
135    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Sourced<U> {
136        Sourced {
137            value: f(self.value),
138            source: self.source,
139            env_var: self.env_var,
140        }
141    }
142
143    /// Merge with another sourced value, taking the higher precedence one.
144    pub fn merge(self, other: Self) -> Self {
145        if other.source.precedence() >= self.source.precedence() {
146            other
147        } else {
148            self
149        }
150    }
151}
152
153impl<T: Default> Default for Sourced<T> {
154    fn default() -> Self {
155        Self::default_value(T::default())
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_source_precedence() {
165        assert!(ConfigSource::Environment.precedence() > ConfigSource::UserConfig.precedence());
166        assert!(ConfigSource::CommandLine.precedence() > ConfigSource::Environment.precedence());
167        assert!(ConfigSource::Default.precedence() < ConfigSource::ProjectConfig.precedence());
168    }
169
170    #[test]
171    fn test_sourced_merge() {
172        let default = Sourced::new(10, ConfigSource::Default);
173        let env = Sourced::new(20, ConfigSource::Environment);
174
175        let merged = default.merge(env);
176        assert_eq!(merged.value, 20);
177        assert_eq!(merged.source, ConfigSource::Environment);
178    }
179
180    #[test]
181    fn test_sourced_map() {
182        let sourced = Sourced::from_env("42".to_string(), "MY_VAR");
183        let mapped = sourced.map(|s| s.parse::<i32>().unwrap());
184
185        assert_eq!(mapped.value, 42);
186        assert_eq!(mapped.source, ConfigSource::Environment);
187        assert_eq!(mapped.env_var.as_deref(), Some("MY_VAR"));
188    }
189
190    // ========================
191    // ConfigSource tests
192    // ========================
193
194    #[test]
195    fn test_config_source_precedence_order() {
196        let sources = [
197            ConfigSource::Default,
198            ConfigSource::UserConfig,
199            ConfigSource::ProjectConfig,
200            ConfigSource::DotEnv,
201            ConfigSource::Profile,
202            ConfigSource::Environment,
203            ConfigSource::CommandLine,
204        ];
205
206        for i in 0..sources.len() - 1 {
207            assert!(
208                sources[i].precedence() < sources[i + 1].precedence(),
209                "{:?} should have lower precedence than {:?}",
210                sources[i],
211                sources[i + 1]
212            );
213        }
214    }
215
216    #[test]
217    fn test_config_source_display_name_all_variants() {
218        assert_eq!(ConfigSource::Default.display_name(), "default");
219        assert_eq!(ConfigSource::UserConfig.display_name(), "user config");
220        assert_eq!(ConfigSource::ProjectConfig.display_name(), "project config");
221        assert_eq!(ConfigSource::DotEnv.display_name(), ".env file");
222        assert_eq!(ConfigSource::Profile.display_name(), "profile");
223        assert_eq!(ConfigSource::Environment.display_name(), "environment");
224        assert_eq!(ConfigSource::CommandLine.display_name(), "command line");
225    }
226
227    #[test]
228    fn test_config_source_display_trait() {
229        assert_eq!(format!("{}", ConfigSource::Default), "default");
230        assert_eq!(format!("{}", ConfigSource::CommandLine), "command line");
231    }
232
233    #[test]
234    fn test_config_source_serialization() {
235        let source = ConfigSource::Environment;
236        let json = serde_json::to_string(&source).unwrap();
237        assert_eq!(json, "\"environment\"");
238
239        let deserialized: ConfigSource = serde_json::from_str(&json).unwrap();
240        assert_eq!(deserialized, ConfigSource::Environment);
241    }
242
243    #[test]
244    fn test_config_source_all_variants_serialize_roundtrip() {
245        let sources = [
246            ConfigSource::Default,
247            ConfigSource::UserConfig,
248            ConfigSource::ProjectConfig,
249            ConfigSource::DotEnv,
250            ConfigSource::Profile,
251            ConfigSource::Environment,
252            ConfigSource::CommandLine,
253        ];
254
255        for source in sources {
256            let json = serde_json::to_string(&source).unwrap();
257            let restored: ConfigSource = serde_json::from_str(&json).unwrap();
258            assert_eq!(restored, source);
259        }
260    }
261
262    #[test]
263    fn test_config_source_copy_trait() {
264        let source = ConfigSource::Environment;
265        let copied = source;
266        assert_eq!(source, copied);
267    }
268
269    // ========================
270    // ConfigValueSource tests
271    // ========================
272
273    #[test]
274    fn test_config_value_source_default_label() {
275        let source = ConfigValueSource::Default;
276        assert_eq!(source.label(), "default");
277    }
278
279    #[test]
280    fn test_config_value_source_user_config_label() {
281        let source =
282            ConfigValueSource::UserConfig(PathBuf::from("/home/user/.config/rch/config.toml"));
283        assert!(source.label().starts_with("user:"));
284        assert!(source.label().contains("config.toml"));
285    }
286
287    #[test]
288    fn test_config_value_source_project_config_label() {
289        let source = ConfigValueSource::ProjectConfig(PathBuf::from("/project/.rch/config.toml"));
290        assert!(source.label().starts_with("project:"));
291        assert!(source.label().contains("config.toml"));
292    }
293
294    #[test]
295    fn test_config_value_source_env_var_label() {
296        let source = ConfigValueSource::EnvVar("RCH_WORKERS".to_string());
297        assert_eq!(source.label(), "env:RCH_WORKERS");
298    }
299
300    #[test]
301    fn test_config_value_source_display_trait() {
302        let source = ConfigValueSource::EnvVar("MY_VAR".to_string());
303        assert_eq!(format!("{}", source), "env:MY_VAR");
304    }
305
306    #[test]
307    fn test_config_value_source_serialization_default() {
308        let source = ConfigValueSource::Default;
309        let json = serde_json::to_string(&source).unwrap();
310        let restored: ConfigValueSource = serde_json::from_str(&json).unwrap();
311        assert_eq!(restored, ConfigValueSource::Default);
312    }
313
314    #[test]
315    fn test_config_value_source_serialization_env_var() {
316        let source = ConfigValueSource::EnvVar("TEST_VAR".to_string());
317        let json = serde_json::to_string(&source).unwrap();
318        let restored: ConfigValueSource = serde_json::from_str(&json).unwrap();
319        assert_eq!(restored, source);
320    }
321
322    #[test]
323    fn test_config_value_source_serialization_user_config() {
324        let source = ConfigValueSource::UserConfig(PathBuf::from("/test/path"));
325        let json = serde_json::to_string(&source).unwrap();
326        let restored: ConfigValueSource = serde_json::from_str(&json).unwrap();
327        assert_eq!(restored, source);
328    }
329
330    #[test]
331    fn test_config_value_source_equality() {
332        let source1 = ConfigValueSource::EnvVar("VAR1".to_string());
333        let source2 = ConfigValueSource::EnvVar("VAR1".to_string());
334        let source3 = ConfigValueSource::EnvVar("VAR2".to_string());
335
336        assert_eq!(source1, source2);
337        assert_ne!(source1, source3);
338    }
339
340    // ========================
341    // Sourced<T> tests
342    // ========================
343
344    #[test]
345    fn test_sourced_new() {
346        let sourced = Sourced::new(42, ConfigSource::UserConfig);
347        assert_eq!(sourced.value, 42);
348        assert_eq!(sourced.source, ConfigSource::UserConfig);
349        assert!(sourced.env_var.is_none());
350    }
351
352    #[test]
353    fn test_sourced_from_env() {
354        let sourced = Sourced::from_env("value".to_string(), "MY_ENV_VAR");
355        assert_eq!(sourced.value, "value");
356        assert_eq!(sourced.source, ConfigSource::Environment);
357        assert_eq!(sourced.env_var.as_deref(), Some("MY_ENV_VAR"));
358    }
359
360    #[test]
361    fn test_sourced_default_value() {
362        let sourced = Sourced::default_value(100);
363        assert_eq!(sourced.value, 100);
364        assert_eq!(sourced.source, ConfigSource::Default);
365        assert!(sourced.env_var.is_none());
366    }
367
368    #[test]
369    fn test_sourced_default_trait() {
370        let sourced: Sourced<i32> = Sourced::default();
371        assert_eq!(sourced.value, 0);
372        assert_eq!(sourced.source, ConfigSource::Default);
373    }
374
375    #[test]
376    fn test_sourced_default_trait_string() {
377        let sourced: Sourced<String> = Sourced::default();
378        assert_eq!(sourced.value, "");
379        assert_eq!(sourced.source, ConfigSource::Default);
380    }
381
382    #[test]
383    fn test_sourced_merge_higher_precedence_wins() {
384        let lower = Sourced::new(10, ConfigSource::Default);
385        let higher = Sourced::new(20, ConfigSource::CommandLine);
386
387        let result = lower.merge(higher);
388        assert_eq!(result.value, 20);
389        assert_eq!(result.source, ConfigSource::CommandLine);
390    }
391
392    #[test]
393    fn test_sourced_merge_equal_precedence_takes_other() {
394        let first = Sourced::new(10, ConfigSource::Environment);
395        let second = Sourced::new(20, ConfigSource::Environment);
396
397        let result = first.merge(second);
398        assert_eq!(result.value, 20);
399    }
400
401    #[test]
402    fn test_sourced_merge_lower_precedence_keeps_self() {
403        let higher = Sourced::new(10, ConfigSource::CommandLine);
404        let lower = Sourced::new(20, ConfigSource::Default);
405
406        let result = higher.merge(lower);
407        assert_eq!(result.value, 10);
408        assert_eq!(result.source, ConfigSource::CommandLine);
409    }
410
411    #[test]
412    fn test_sourced_merge_preserves_env_var() {
413        let default = Sourced::new(10, ConfigSource::Default);
414        let env = Sourced::from_env(20, "MY_VAR");
415
416        let result = default.merge(env);
417        assert_eq!(result.env_var.as_deref(), Some("MY_VAR"));
418    }
419
420    #[test]
421    fn test_sourced_map_preserves_source() {
422        let sourced = Sourced::new(42, ConfigSource::ProjectConfig);
423        let mapped = sourced.map(|v| v.to_string());
424
425        assert_eq!(mapped.value, "42");
426        assert_eq!(mapped.source, ConfigSource::ProjectConfig);
427    }
428
429    #[test]
430    fn test_sourced_map_preserves_env_var() {
431        let sourced = Sourced::from_env(42, "NUMBER");
432        let mapped = sourced.map(|v| v * 2);
433
434        assert_eq!(mapped.value, 84);
435        assert_eq!(mapped.env_var.as_deref(), Some("NUMBER"));
436    }
437
438    #[test]
439    fn test_sourced_map_chain() {
440        let sourced = Sourced::new("hello".to_string(), ConfigSource::UserConfig);
441        let mapped = sourced.map(|s| s.len()).map(|n| n * 2);
442
443        assert_eq!(mapped.value, 10);
444        assert_eq!(mapped.source, ConfigSource::UserConfig);
445    }
446
447    #[test]
448    fn test_sourced_with_complex_type() {
449        #[derive(Debug, Clone, PartialEq)]
450        struct Config {
451            timeout: u64,
452            retries: u32,
453        }
454
455        let config = Config {
456            timeout: 30,
457            retries: 3,
458        };
459        let sourced = Sourced::new(config.clone(), ConfigSource::Environment);
460
461        assert_eq!(sourced.value.timeout, 30);
462        assert_eq!(sourced.value.retries, 3);
463
464        let mapped = sourced.map(|c| c.timeout);
465        assert_eq!(mapped.value, 30);
466    }
467}