Skip to main content

aperture_cli/config/
settings.rs

1//! Configuration settings management
2//!
3//! This module provides type-safe access to global configuration settings,
4//! supporting dot-notation keys for nested values and appropriate type validation.
5
6use crate::error::Error;
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use std::str::FromStr;
10
11/// Represents a valid configuration setting key.
12///
13/// Each variant maps to a specific path in the configuration file,
14/// with dot-notation used for nested values (e.g., `agent_defaults.json_errors`).
15///
16/// # Adding a New Setting
17///
18/// When adding a new setting, update the following locations:
19///
20/// 1. **This enum** - Add a new variant with doc comment
21/// 2. **`SettingKey::ALL`** - Add the variant to the array
22/// 3. **`SettingKey::as_str()`** - Return the dot-notation key string
23/// 4. **`SettingKey::type_name()`** - Return the type name for display
24/// 5. **`SettingKey::description()`** - Return a human-readable description
25/// 6. **`SettingKey::default_value_str()`** - Return the default value as string
26/// 7. **`SettingKey::value_from_config()`** - Extract value from `GlobalConfig`
27/// 8. **`FromStr for SettingKey`** - Parse the key string to variant
28/// 9. **`SettingValue`** - Add type variant if needed (e.g., `String`, `Vec`)
29/// 10. **`SettingValue::parse_for_key()`** - Parse string to value with validation
30/// 11. **`ConfigManager::set_setting()`** - Apply value to TOML document
31/// 12. **`GlobalConfig`** (models.rs) - Add the field to the config struct
32/// 13. **CLI help text** (cli.rs) - Update `config set` command's `long_about`
33/// 14. **Tests** - Add unit and integration tests for the new setting
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum SettingKey {
36    /// Default timeout for API requests in seconds (`default_timeout_secs`)
37    DefaultTimeoutSecs,
38    /// Whether to output errors as JSON by default (`agent_defaults.json_errors`)
39    AgentDefaultsJsonErrors,
40    /// Maximum number of retry attempts (`retry_defaults.max_attempts`)
41    RetryDefaultsMaxAttempts,
42    /// Initial delay between retries in milliseconds (`retry_defaults.initial_delay_ms`)
43    RetryDefaultsInitialDelayMs,
44    /// Maximum delay cap in milliseconds (`retry_defaults.max_delay_ms`)
45    RetryDefaultsMaxDelayMs,
46}
47
48impl SettingKey {
49    /// All available setting keys for enumeration.
50    pub const ALL: &'static [Self] = &[
51        Self::DefaultTimeoutSecs,
52        Self::AgentDefaultsJsonErrors,
53        Self::RetryDefaultsMaxAttempts,
54        Self::RetryDefaultsInitialDelayMs,
55        Self::RetryDefaultsMaxDelayMs,
56    ];
57
58    /// Returns the dot-notation key string for this setting.
59    #[must_use]
60    pub const fn as_str(&self) -> &'static str {
61        match self {
62            Self::DefaultTimeoutSecs => "default_timeout_secs",
63            Self::AgentDefaultsJsonErrors => "agent_defaults.json_errors",
64            Self::RetryDefaultsMaxAttempts => "retry_defaults.max_attempts",
65            Self::RetryDefaultsInitialDelayMs => "retry_defaults.initial_delay_ms",
66            Self::RetryDefaultsMaxDelayMs => "retry_defaults.max_delay_ms",
67        }
68    }
69
70    /// Returns the expected type name for this setting.
71    #[must_use]
72    pub const fn type_name(&self) -> &'static str {
73        match self {
74            Self::DefaultTimeoutSecs
75            | Self::RetryDefaultsMaxAttempts
76            | Self::RetryDefaultsInitialDelayMs
77            | Self::RetryDefaultsMaxDelayMs => "integer",
78            Self::AgentDefaultsJsonErrors => "boolean",
79        }
80    }
81
82    /// Returns a human-readable description of this setting.
83    #[must_use]
84    pub const fn description(&self) -> &'static str {
85        match self {
86            Self::DefaultTimeoutSecs => "Default timeout for API requests in seconds",
87            Self::AgentDefaultsJsonErrors => "Output errors as JSON by default",
88            Self::RetryDefaultsMaxAttempts => "Maximum retry attempts (0 = disabled)",
89            Self::RetryDefaultsInitialDelayMs => "Initial delay between retries in milliseconds",
90            Self::RetryDefaultsMaxDelayMs => "Maximum delay cap in milliseconds",
91        }
92    }
93
94    /// Returns the default value for this setting as a string.
95    #[must_use]
96    pub const fn default_value_str(&self) -> &'static str {
97        match self {
98            Self::DefaultTimeoutSecs => "30",
99            Self::AgentDefaultsJsonErrors => "false",
100            Self::RetryDefaultsMaxAttempts => "0",
101            Self::RetryDefaultsInitialDelayMs => "500",
102            Self::RetryDefaultsMaxDelayMs => "30000",
103        }
104    }
105
106    /// Extracts the current value for this setting from a `GlobalConfig`.
107    #[must_use]
108    pub const fn value_from_config(&self, config: &super::models::GlobalConfig) -> SettingValue {
109        match self {
110            Self::DefaultTimeoutSecs => SettingValue::U64(config.default_timeout_secs),
111            Self::AgentDefaultsJsonErrors => SettingValue::Bool(config.agent_defaults.json_errors),
112            Self::RetryDefaultsMaxAttempts => {
113                SettingValue::U64(config.retry_defaults.max_attempts as u64)
114            }
115            Self::RetryDefaultsInitialDelayMs => {
116                SettingValue::U64(config.retry_defaults.initial_delay_ms)
117            }
118            Self::RetryDefaultsMaxDelayMs => SettingValue::U64(config.retry_defaults.max_delay_ms),
119        }
120    }
121}
122
123impl fmt::Display for SettingKey {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        write!(f, "{}", self.as_str())
126    }
127}
128
129impl FromStr for SettingKey {
130    type Err = Error;
131
132    fn from_str(s: &str) -> Result<Self, Self::Err> {
133        match s {
134            "default_timeout_secs" => Ok(Self::DefaultTimeoutSecs),
135            "agent_defaults.json_errors" => Ok(Self::AgentDefaultsJsonErrors),
136            "retry_defaults.max_attempts" => Ok(Self::RetryDefaultsMaxAttempts),
137            "retry_defaults.initial_delay_ms" => Ok(Self::RetryDefaultsInitialDelayMs),
138            "retry_defaults.max_delay_ms" => Ok(Self::RetryDefaultsMaxDelayMs),
139            _ => Err(Error::unknown_setting_key(s)),
140        }
141    }
142}
143
144/// Type-safe representation of a configuration setting value.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum SettingValue {
147    /// Unsigned 64-bit integer value
148    U64(u64),
149    /// Boolean value
150    Bool(bool),
151}
152
153/// Maximum allowed timeout value (1 year in seconds).
154/// This prevents overflow when converting to i64 and catches obviously wrong values.
155const MAX_TIMEOUT_SECS: u64 = 365 * 24 * 60 * 60;
156
157/// Maximum retry attempts (reasonable upper bound).
158const MAX_RETRY_ATTEMPTS: u64 = 10;
159
160/// Maximum initial delay in milliseconds (1 minute).
161const MAX_INITIAL_DELAY_MS: u64 = 60_000;
162
163/// Maximum delay cap in milliseconds (5 minutes).
164const MAX_DELAY_CAP_MS: u64 = 300_000;
165
166impl SettingValue {
167    /// Parse a string value into the appropriate type for the given key.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if the value cannot be parsed as the expected type,
172    /// or if the value is outside the allowed range for the setting.
173    pub fn parse_for_key(key: SettingKey, value: &str) -> Result<Self, Error> {
174        match key {
175            SettingKey::DefaultTimeoutSecs => {
176                let parsed = value
177                    .parse::<u64>()
178                    .map_err(|_| Error::invalid_setting_value(key, value))?;
179
180                // Validate range: must be > 0 and <= MAX_TIMEOUT_SECS
181                if parsed == 0 {
182                    return Err(Error::setting_value_out_of_range(
183                        key,
184                        value,
185                        "timeout must be greater than 0",
186                    ));
187                }
188                if parsed > MAX_TIMEOUT_SECS {
189                    return Err(Error::setting_value_out_of_range(
190                        key,
191                        value,
192                        &format!("timeout cannot exceed {MAX_TIMEOUT_SECS} seconds (1 year)"),
193                    ));
194                }
195
196                Ok(Self::U64(parsed))
197            }
198            SettingKey::AgentDefaultsJsonErrors => {
199                let parsed = match value.to_lowercase().as_str() {
200                    "true" | "1" | "yes" | "on" => true,
201                    "false" | "0" | "no" | "off" => false,
202                    _ => return Err(Error::invalid_setting_value(key, value)),
203                };
204                Ok(Self::Bool(parsed))
205            }
206            SettingKey::RetryDefaultsMaxAttempts => {
207                let parsed = value
208                    .parse::<u64>()
209                    .map_err(|_| Error::invalid_setting_value(key, value))?;
210
211                if parsed > MAX_RETRY_ATTEMPTS {
212                    return Err(Error::setting_value_out_of_range(
213                        key,
214                        value,
215                        &format!("max_attempts cannot exceed {MAX_RETRY_ATTEMPTS}"),
216                    ));
217                }
218
219                Ok(Self::U64(parsed))
220            }
221            SettingKey::RetryDefaultsInitialDelayMs => {
222                let parsed = value
223                    .parse::<u64>()
224                    .map_err(|_| Error::invalid_setting_value(key, value))?;
225
226                if parsed == 0 {
227                    return Err(Error::setting_value_out_of_range(
228                        key,
229                        value,
230                        "initial_delay_ms must be greater than 0",
231                    ));
232                }
233                if parsed > MAX_INITIAL_DELAY_MS {
234                    return Err(Error::setting_value_out_of_range(
235                        key,
236                        value,
237                        &format!(
238                            "initial_delay_ms cannot exceed {MAX_INITIAL_DELAY_MS}ms (1 minute)"
239                        ),
240                    ));
241                }
242
243                Ok(Self::U64(parsed))
244            }
245            SettingKey::RetryDefaultsMaxDelayMs => {
246                let parsed = value
247                    .parse::<u64>()
248                    .map_err(|_| Error::invalid_setting_value(key, value))?;
249
250                if parsed == 0 {
251                    return Err(Error::setting_value_out_of_range(
252                        key,
253                        value,
254                        "max_delay_ms must be greater than 0",
255                    ));
256                }
257                if parsed > MAX_DELAY_CAP_MS {
258                    return Err(Error::setting_value_out_of_range(
259                        key,
260                        value,
261                        &format!("max_delay_ms cannot exceed {MAX_DELAY_CAP_MS}ms (5 minutes)"),
262                    ));
263                }
264
265                Ok(Self::U64(parsed))
266            }
267        }
268    }
269
270    /// Returns the value as a u64, if it is one.
271    #[must_use]
272    pub const fn as_u64(&self) -> Option<u64> {
273        match self {
274            Self::U64(v) => Some(*v),
275            Self::Bool(_) => None,
276        }
277    }
278
279    /// Returns the value as a bool, if it is one.
280    #[must_use]
281    pub const fn as_bool(&self) -> Option<bool> {
282        match self {
283            Self::Bool(v) => Some(*v),
284            Self::U64(_) => None,
285        }
286    }
287}
288
289impl fmt::Display for SettingValue {
290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291        match self {
292            Self::U64(v) => write!(f, "{v}"),
293            Self::Bool(v) => write!(f, "{v}"),
294        }
295    }
296}
297
298/// Information about a configuration setting for display purposes.
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct SettingInfo {
301    /// The setting key in dot-notation
302    pub key: String,
303    /// Current value as a string
304    pub value: String,
305    /// Expected type name
306    #[serde(rename = "type")]
307    pub type_name: String,
308    /// Human-readable description
309    pub description: String,
310    /// Default value as a string
311    pub default: String,
312}
313
314impl SettingInfo {
315    /// Create a new `SettingInfo` from a key and current value.
316    #[must_use]
317    pub fn new(key: SettingKey, current_value: &SettingValue) -> Self {
318        Self {
319            key: key.as_str().to_string(),
320            value: current_value.to_string(),
321            type_name: key.type_name().to_string(),
322            description: key.description().to_string(),
323            default: key.default_value_str().to_string(),
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn test_setting_key_from_str_valid() {
334        assert_eq!(
335            "default_timeout_secs".parse::<SettingKey>().unwrap(),
336            SettingKey::DefaultTimeoutSecs
337        );
338        assert_eq!(
339            "agent_defaults.json_errors".parse::<SettingKey>().unwrap(),
340            SettingKey::AgentDefaultsJsonErrors
341        );
342    }
343
344    #[test]
345    fn test_setting_key_from_str_invalid() {
346        let result = "unknown_key".parse::<SettingKey>();
347        assert!(result.is_err());
348    }
349
350    #[test]
351    fn test_setting_key_as_str() {
352        assert_eq!(
353            SettingKey::DefaultTimeoutSecs.as_str(),
354            "default_timeout_secs"
355        );
356        assert_eq!(
357            SettingKey::AgentDefaultsJsonErrors.as_str(),
358            "agent_defaults.json_errors"
359        );
360    }
361
362    #[test]
363    fn test_setting_value_parse_u64_valid() {
364        let value = SettingValue::parse_for_key(SettingKey::DefaultTimeoutSecs, "60").unwrap();
365        assert_eq!(value, SettingValue::U64(60));
366    }
367
368    #[test]
369    fn test_setting_value_parse_u64_invalid() {
370        let result = SettingValue::parse_for_key(SettingKey::DefaultTimeoutSecs, "abc");
371        assert!(result.is_err());
372    }
373
374    #[test]
375    fn test_setting_value_parse_bool_valid() {
376        let key = SettingKey::AgentDefaultsJsonErrors;
377
378        assert_eq!(
379            SettingValue::parse_for_key(key, "true").unwrap(),
380            SettingValue::Bool(true)
381        );
382        assert_eq!(
383            SettingValue::parse_for_key(key, "false").unwrap(),
384            SettingValue::Bool(false)
385        );
386        assert_eq!(
387            SettingValue::parse_for_key(key, "1").unwrap(),
388            SettingValue::Bool(true)
389        );
390        assert_eq!(
391            SettingValue::parse_for_key(key, "0").unwrap(),
392            SettingValue::Bool(false)
393        );
394        assert_eq!(
395            SettingValue::parse_for_key(key, "yes").unwrap(),
396            SettingValue::Bool(true)
397        );
398        assert_eq!(
399            SettingValue::parse_for_key(key, "no").unwrap(),
400            SettingValue::Bool(false)
401        );
402    }
403
404    #[test]
405    fn test_setting_value_parse_bool_invalid() {
406        let result = SettingValue::parse_for_key(SettingKey::AgentDefaultsJsonErrors, "maybe");
407        assert!(result.is_err());
408    }
409
410    #[test]
411    fn test_setting_value_display() {
412        assert_eq!(SettingValue::U64(30).to_string(), "30");
413        assert_eq!(SettingValue::Bool(true).to_string(), "true");
414        assert_eq!(SettingValue::Bool(false).to_string(), "false");
415    }
416
417    #[test]
418    fn test_setting_info_new() {
419        let info = SettingInfo::new(SettingKey::DefaultTimeoutSecs, &SettingValue::U64(60));
420        assert_eq!(info.key, "default_timeout_secs");
421        assert_eq!(info.value, "60");
422        assert_eq!(info.type_name, "integer");
423        assert_eq!(info.default, "30");
424    }
425
426    #[test]
427    fn test_setting_value_parse_timeout_zero_rejected() {
428        let result = SettingValue::parse_for_key(SettingKey::DefaultTimeoutSecs, "0");
429        assert!(result.is_err());
430    }
431
432    #[test]
433    fn test_setting_value_parse_timeout_max_boundary() {
434        // 1 year in seconds should be accepted
435        let result = SettingValue::parse_for_key(
436            SettingKey::DefaultTimeoutSecs,
437            &super::MAX_TIMEOUT_SECS.to_string(),
438        );
439        assert!(result.is_ok());
440    }
441
442    #[test]
443    fn test_setting_value_parse_timeout_over_max_rejected() {
444        // 1 year + 1 second should be rejected
445        let over_max = super::MAX_TIMEOUT_SECS + 1;
446        let result =
447            SettingValue::parse_for_key(SettingKey::DefaultTimeoutSecs, &over_max.to_string());
448        assert!(result.is_err());
449    }
450
451    #[test]
452    fn test_retry_settings_from_str() {
453        assert_eq!(
454            "retry_defaults.max_attempts".parse::<SettingKey>().unwrap(),
455            SettingKey::RetryDefaultsMaxAttempts
456        );
457        assert_eq!(
458            "retry_defaults.initial_delay_ms"
459                .parse::<SettingKey>()
460                .unwrap(),
461            SettingKey::RetryDefaultsInitialDelayMs
462        );
463        assert_eq!(
464            "retry_defaults.max_delay_ms".parse::<SettingKey>().unwrap(),
465            SettingKey::RetryDefaultsMaxDelayMs
466        );
467    }
468
469    #[test]
470    fn test_retry_max_attempts_valid_range() {
471        // 0 is valid (disabled)
472        let result = SettingValue::parse_for_key(SettingKey::RetryDefaultsMaxAttempts, "0");
473        assert_eq!(result.unwrap(), SettingValue::U64(0));
474
475        // 3 is valid
476        let result = SettingValue::parse_for_key(SettingKey::RetryDefaultsMaxAttempts, "3");
477        assert_eq!(result.unwrap(), SettingValue::U64(3));
478
479        // 10 is valid (max)
480        let result = SettingValue::parse_for_key(SettingKey::RetryDefaultsMaxAttempts, "10");
481        assert_eq!(result.unwrap(), SettingValue::U64(10));
482
483        // 11 is invalid (over max)
484        let result = SettingValue::parse_for_key(SettingKey::RetryDefaultsMaxAttempts, "11");
485        assert!(result.is_err());
486    }
487
488    #[test]
489    fn test_retry_initial_delay_ms_valid_range() {
490        // 0 is invalid
491        let result = SettingValue::parse_for_key(SettingKey::RetryDefaultsInitialDelayMs, "0");
492        assert!(result.is_err());
493
494        // 100 is valid
495        let result = SettingValue::parse_for_key(SettingKey::RetryDefaultsInitialDelayMs, "100");
496        assert_eq!(result.unwrap(), SettingValue::U64(100));
497
498        // 60000 is valid (max)
499        let result = SettingValue::parse_for_key(SettingKey::RetryDefaultsInitialDelayMs, "60000");
500        assert_eq!(result.unwrap(), SettingValue::U64(60000));
501
502        // 60001 is invalid (over max)
503        let result = SettingValue::parse_for_key(SettingKey::RetryDefaultsInitialDelayMs, "60001");
504        assert!(result.is_err());
505    }
506
507    #[test]
508    fn test_retry_max_delay_ms_valid_range() {
509        // 0 is invalid
510        let result = SettingValue::parse_for_key(SettingKey::RetryDefaultsMaxDelayMs, "0");
511        assert!(result.is_err());
512
513        // 1000 is valid
514        let result = SettingValue::parse_for_key(SettingKey::RetryDefaultsMaxDelayMs, "1000");
515        assert_eq!(result.unwrap(), SettingValue::U64(1000));
516
517        // 300000 is valid (max)
518        let result = SettingValue::parse_for_key(SettingKey::RetryDefaultsMaxDelayMs, "300000");
519        assert_eq!(result.unwrap(), SettingValue::U64(300_000));
520
521        // 300001 is invalid (over max)
522        let result = SettingValue::parse_for_key(SettingKey::RetryDefaultsMaxDelayMs, "300001");
523        assert!(result.is_err());
524    }
525
526    #[test]
527    fn test_retry_settings_descriptions() {
528        assert_eq!(
529            SettingKey::RetryDefaultsMaxAttempts.description(),
530            "Maximum retry attempts (0 = disabled)"
531        );
532        assert_eq!(
533            SettingKey::RetryDefaultsInitialDelayMs.description(),
534            "Initial delay between retries in milliseconds"
535        );
536        assert_eq!(
537            SettingKey::RetryDefaultsMaxDelayMs.description(),
538            "Maximum delay cap in milliseconds"
539        );
540    }
541
542    #[test]
543    fn test_retry_settings_defaults() {
544        assert_eq!(
545            SettingKey::RetryDefaultsMaxAttempts.default_value_str(),
546            "0"
547        );
548        assert_eq!(
549            SettingKey::RetryDefaultsInitialDelayMs.default_value_str(),
550            "500"
551        );
552        assert_eq!(
553            SettingKey::RetryDefaultsMaxDelayMs.default_value_str(),
554            "30000"
555        );
556    }
557}