Skip to main content

hocon/
config.rs

1use crate::error::ConfigError;
2use crate::value::{HoconValue, ScalarType};
3use indexmap::IndexMap;
4
5/// A parsed HOCON configuration object.
6///
7/// `Config` wraps an ordered map of top-level keys to [`HoconValue`]s and
8/// provides typed getters that accept dot-separated paths
9/// (e.g., `"server.host"`).
10#[derive(Debug, Clone, PartialEq)]
11pub struct Config {
12    root: IndexMap<String, HoconValue>,
13}
14
15impl Config {
16    /// Create a `Config` from a pre-built ordered map of key-value pairs.
17    pub fn new(root: IndexMap<String, HoconValue>) -> Self {
18        Self { root }
19    }
20
21    // Walk the dot-separated path through nested objects.
22    fn lookup_node(&self, path: &str) -> Option<&HoconValue> {
23        let segments = split_config_path(path);
24        lookup_in_map_by_segments(&self.root, &segments)
25    }
26
27    /// Return the raw [`HoconValue`] at the given dot-separated path,
28    /// or `None` if the path does not exist.
29    pub fn get(&self, path: &str) -> Option<&HoconValue> {
30        self.lookup_node(path)
31    }
32
33    /// Return the value at `path` as a `String`.
34    ///
35    /// Returns the raw string for any scalar value (string, number, boolean,
36    /// or null). Returns [`ConfigError`] if the path is missing or the value
37    /// is an Object or Array.
38    pub fn get_string(&self, path: &str) -> Result<String, ConfigError> {
39        match self.lookup_node(path) {
40            None => Err(missing(path)),
41            Some(HoconValue::Scalar(sv)) => Ok(sv.raw.clone()),
42            _ => Err(type_mismatch(path, "String")),
43        }
44    }
45
46    /// Return the value at `path` as an `i64`.
47    ///
48    /// Whole-number floats and numeric strings are coerced automatically.
49    /// Returns [`ConfigError`] if the path is missing or the value cannot be
50    /// represented as `i64`.
51    pub fn get_i64(&self, path: &str) -> Result<i64, ConfigError> {
52        match self.lookup_node(path) {
53            None => Err(missing(path)),
54            Some(HoconValue::Scalar(sv)) => {
55                // Try direct i64 parse first
56                if let Ok(n) = sv.raw.parse::<i64>() {
57                    return Ok(n);
58                }
59                // Only use f64 fallback for float-like literals (contains '.' or exponent)
60                let is_float_like =
61                    sv.raw.contains('.') || sv.raw.contains('e') || sv.raw.contains('E');
62                if is_float_like {
63                    if let Ok(f) = sv.raw.parse::<f64>() {
64                        if f.fract() == 0.0
65                            && f.is_finite()
66                            && f >= i64::MIN as f64
67                            && f < (i64::MAX as f64)
68                        {
69                            return Ok(f as i64);
70                        }
71                    }
72                }
73                Err(type_mismatch(path, "i64"))
74            }
75            _ => Err(type_mismatch(path, "i64")),
76        }
77    }
78
79    /// Return the value at `path` as an `f64`.
80    ///
81    /// Integers and numeric strings are coerced automatically.
82    /// Returns [`ConfigError`] if the path is missing or the value cannot be
83    /// represented as `f64`.
84    pub fn get_f64(&self, path: &str) -> Result<f64, ConfigError> {
85        match self.lookup_node(path) {
86            None => Err(missing(path)),
87            Some(HoconValue::Scalar(sv)) => sv
88                .raw
89                .parse::<f64>()
90                .map_err(|_| type_mismatch(path, "f64")),
91            _ => Err(type_mismatch(path, "f64")),
92        }
93    }
94
95    /// Return the value at `path` as a `bool`.
96    ///
97    /// String values `"true"`, `"yes"`, `"on"` (case-insensitive) coerce to
98    /// `true`; `"false"`, `"no"`, `"off"` coerce to `false`.
99    /// Returns [`ConfigError`] if the path is missing or the value is not boolean-like.
100    pub fn get_bool(&self, path: &str) -> Result<bool, ConfigError> {
101        match self.lookup_node(path) {
102            None => Err(missing(path)),
103            Some(HoconValue::Scalar(sv)) => match sv.raw.to_lowercase().as_str() {
104                "true" | "yes" | "on" => Ok(true),
105                "false" | "no" | "off" => Ok(false),
106                _ => Err(type_mismatch(path, "bool")),
107            },
108            _ => Err(type_mismatch(path, "bool")),
109        }
110    }
111
112    /// Return the sub-object at `path` as a new [`Config`].
113    ///
114    /// Returns [`ConfigError`] if the path is missing or the value is not an object.
115    pub fn get_config(&self, path: &str) -> Result<Config, ConfigError> {
116        match self.lookup_node(path) {
117            None => Err(missing(path)),
118            Some(HoconValue::Object(map)) => Ok(Config::new(map.clone())),
119            _ => Err(type_mismatch(path, "Object")),
120        }
121    }
122
123    /// Return the array at `path` as a `Vec<HoconValue>`.
124    ///
125    /// Returns [`ConfigError`] if the path is missing or the value is not an array.
126    pub fn get_list(&self, path: &str) -> Result<Vec<HoconValue>, ConfigError> {
127        match self.lookup_node(path) {
128            None => Err(missing(path)),
129            Some(HoconValue::Array(items)) => Ok(items.clone()),
130            _ => Err(type_mismatch(path, "Array")),
131        }
132    }
133
134    /// Like [`get_string`](Self::get_string) but returns `None` instead of an error.
135    pub fn get_string_option(&self, path: &str) -> Option<String> {
136        self.get_string(path).ok()
137    }
138
139    /// Like [`get_i64`](Self::get_i64) but returns `None` instead of an error.
140    pub fn get_i64_option(&self, path: &str) -> Option<i64> {
141        self.get_i64(path).ok()
142    }
143
144    /// Like [`get_f64`](Self::get_f64) but returns `None` instead of an error.
145    pub fn get_f64_option(&self, path: &str) -> Option<f64> {
146        self.get_f64(path).ok()
147    }
148
149    /// Like [`get_bool`](Self::get_bool) but returns `None` instead of an error.
150    pub fn get_bool_option(&self, path: &str) -> Option<bool> {
151        self.get_bool(path).ok()
152    }
153
154    /// Like [`get_config`](Self::get_config) but returns `None` instead of an error.
155    pub fn get_config_option(&self, path: &str) -> Option<Config> {
156        self.get_config(path).ok()
157    }
158
159    /// Like [`get_list`](Self::get_list) but returns `None` instead of an error.
160    pub fn get_list_option(&self, path: &str) -> Option<Vec<HoconValue>> {
161        self.get_list(path).ok()
162    }
163
164    /// Return the value at `path` as a [`Duration`](std::time::Duration).
165    ///
166    /// Accepts HOCON duration strings (e.g., `"30 seconds"`, `"100ms"`,
167    /// `"2 hours"`). Bare integers are interpreted as milliseconds.
168    ///
169    /// Supported units: `ns`/`nano`/`nanos`/`nanosecond`/`nanoseconds`,
170    /// `us`/`micro`/`micros`/`microsecond`/`microseconds`,
171    /// `ms`/`milli`/`millis`/`millisecond`/`milliseconds`,
172    /// `s`/`second`/`seconds`, `m`/`minute`/`minutes`,
173    /// `h`/`hour`/`hours`, `d`/`day`/`days`, `w`/`week`/`weeks`.
174    pub fn get_duration(&self, path: &str) -> Result<std::time::Duration, ConfigError> {
175        match self.lookup_node(path) {
176            None => Err(missing(path)),
177            Some(HoconValue::Scalar(sv)) => {
178                // Try as duration string first
179                if let Some(d) = parse_duration(&sv.raw) {
180                    return Ok(d);
181                }
182                // Number types: bare integer = milliseconds, bare float = milliseconds
183                if sv.value_type == ScalarType::Number {
184                    if let Ok(n) = sv.raw.parse::<i64>() {
185                        if n < 0 {
186                            return Err(ConfigError {
187                                message: format!("negative duration at {}: {}", path, sv.raw),
188                                path: path.to_string(),
189                            });
190                        }
191                        return Ok(std::time::Duration::from_millis(n as u64));
192                    }
193                    if let Ok(f) = sv.raw.parse::<f64>() {
194                        if f < 0.0 || !f.is_finite() {
195                            return Err(ConfigError {
196                                message: format!("invalid duration at {}: {}", path, sv.raw),
197                                path: path.to_string(),
198                            });
199                        }
200                        let secs = f / 1000.0;
201                        if secs > u64::MAX as f64 {
202                            return Err(ConfigError {
203                                message: format!("duration too large at {}: {}", path, sv.raw),
204                                path: path.to_string(),
205                            });
206                        }
207                        return Ok(std::time::Duration::from_secs_f64(secs));
208                    }
209                }
210                Err(ConfigError {
211                    message: format!("invalid duration at {}: {}", path, sv.raw),
212                    path: path.to_string(),
213                })
214            }
215            _ => Err(ConfigError {
216                message: format!("expected duration at {}", path),
217                path: path.to_string(),
218            }),
219        }
220    }
221
222    /// Like [`get_duration`](Self::get_duration) but returns `None` instead of an error.
223    pub fn get_duration_option(&self, path: &str) -> Option<std::time::Duration> {
224        self.get_duration(path).ok()
225    }
226
227    /// Return the value at `path` as a byte count (`i64`).
228    ///
229    /// Accepts HOCON byte-size strings (e.g., `"512 MB"`, `"1 GiB"`).
230    /// Bare integers are returned as-is (assumed bytes).
231    ///
232    /// Supported units: `B`/`byte`/`bytes`, `K`/`KB`/`kilobyte`/`kilobytes`,
233    /// `KiB`/`kibibyte`/`kibibytes`, `M`/`MB`/`megabyte`/`megabytes`,
234    /// `MiB`/`mebibyte`/`mebibytes`, `G`/`GB`/`gigabyte`/`gigabytes`,
235    /// `GiB`/`gibibyte`/`gibibytes`, `T`/`TB`/`terabyte`/`terabytes`,
236    /// `TiB`/`tebibyte`/`tebibytes`. Fractional numbers (e.g. `0.5M`) are supported.
237    pub fn get_bytes(&self, path: &str) -> Result<i64, ConfigError> {
238        let v = self.lookup_node(path).ok_or_else(|| ConfigError {
239            message: format!("path not found: {}", path),
240            path: path.to_string(),
241        })?;
242        match v {
243            HoconValue::Scalar(sv) => {
244                // Bare integer number: return as-is (assumed bytes)
245                if sv.value_type == ScalarType::Number {
246                    if let Ok(n) = sv.raw.parse::<i64>() {
247                        return Ok(n);
248                    }
249                    // Bare float without unit (e.g. "1.5") is not valid for bytes
250                    return Err(ConfigError {
251                        message: format!("expected byte size at {}", path),
252                        path: path.to_string(),
253                    });
254                }
255                // String type: try byte-size string (e.g. "512 MB", "1.5 KiB")
256                parse_bytes(&sv.raw).ok_or_else(|| ConfigError {
257                    message: format!("invalid byte size at {}: {}", path, sv.raw),
258                    path: path.to_string(),
259                })
260            }
261            _ => Err(ConfigError {
262                message: format!("expected byte size at {}", path),
263                path: path.to_string(),
264            }),
265        }
266    }
267
268    /// Like [`get_bytes`](Self::get_bytes) but returns `None` instead of an error.
269    pub fn get_bytes_option(&self, path: &str) -> Option<i64> {
270        self.get_bytes(path).ok()
271    }
272
273    /// Return `true` if a value exists at the given dot-separated path.
274    pub fn has(&self, path: &str) -> bool {
275        self.lookup_node(path).is_some()
276    }
277
278    /// Return the top-level keys in insertion order.
279    pub fn keys(&self) -> Vec<&str> {
280        self.root.keys().map(|s| s.as_str()).collect()
281    }
282
283    /// Merge this config with a fallback. Keys present in `self` win;
284    /// missing keys are filled from `fallback`. Nested objects are deep-merged.
285    ///
286    /// ```rust
287    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
288    /// let app = hocon::parse(r#"server.port = 9090"#)?;
289    /// let defaults = hocon::parse(r#"server { host = "0.0.0.0", port = 8080 }"#)?;
290    /// let merged = app.with_fallback(&defaults);
291    ///
292    /// assert_eq!(merged.get_i64("server.port")?, 9090);       // app wins
293    /// assert_eq!(merged.get_string("server.host")?, "0.0.0.0"); // filled from defaults
294    /// # Ok(())
295    /// # }
296    /// ```
297    pub fn with_fallback(&self, fallback: &Config) -> Config {
298        let mut merged = self.root.clone();
299        for (key, fallback_val) in &fallback.root {
300            if let Some(receiver_val) = merged.get(key) {
301                // Both sides have this key — deep merge if both are objects
302                if let (HoconValue::Object(recv_map), HoconValue::Object(fb_map)) =
303                    (receiver_val, fallback_val)
304                {
305                    let recv_cfg = Config::new(recv_map.clone());
306                    let fb_cfg = Config::new(fb_map.clone());
307                    let deep = recv_cfg.with_fallback(&fb_cfg);
308                    merged.insert(key.clone(), HoconValue::Object(deep.root));
309                }
310                // else: receiver value wins, no insert needed
311            } else {
312                // Key missing in receiver — take from fallback
313                merged.insert(key.clone(), fallback_val.clone());
314            }
315        }
316        Config::new(merged)
317    }
318}
319
320/// Split a HOCON config path into segments, respecting quoted keys.
321/// e.g. `server."web.api".port` → `["server", "web.api", "port"]`
322/// Empty segments are preserved: `a..b` → `["a", "", "b"]`.
323/// Quoted segments process escape sequences (e.g. `\"` → `"`).
324fn split_config_path(path: &str) -> Vec<String> {
325    let mut segments = Vec::new();
326    let chars: Vec<char> = path.chars().collect();
327    let mut i = 0;
328    while i < chars.len() {
329        if chars[i] == '"' {
330            // Quoted segment — collect until closing quote, processing escapes
331            i += 1; // skip opening quote
332            let mut seg = String::new();
333            let mut closed = false;
334            while i < chars.len() {
335                if chars[i] == '\\' && i + 1 < chars.len() {
336                    seg.push(chars[i + 1]);
337                    i += 2;
338                    continue;
339                }
340                if chars[i] == '"' {
341                    closed = true;
342                    i += 1;
343                    break;
344                }
345                seg.push(chars[i]);
346                i += 1;
347            }
348            if !closed {
349                return vec![path.to_string()]; // treat as literal if unterminated
350            }
351            segments.push(seg);
352            // skip optional '.' separator
353            if i < chars.len() && chars[i] == '.' {
354                i += 1;
355            }
356        } else {
357            // Unquoted segment — collect until '.' or '"'
358            // Always push the segment (even empty) to preserve consecutive-dot semantics.
359            let start = i;
360            while i < chars.len() && chars[i] != '.' && chars[i] != '"' {
361                i += 1;
362            }
363            segments.push(chars[start..i].iter().collect());
364            // skip optional '.' separator
365            if i < chars.len() && chars[i] == '.' {
366                i += 1;
367            }
368        }
369    }
370    // A trailing dot means there is a final empty segment
371    if path.ends_with('.') {
372        segments.push(String::new());
373    }
374    segments
375}
376
377fn lookup_in_map_by_segments<'a>(
378    map: &'a IndexMap<String, HoconValue>,
379    segments: &[String],
380) -> Option<&'a HoconValue> {
381    if segments.is_empty() {
382        return None;
383    }
384    let key = &segments[0];
385    let rest = &segments[1..];
386    let value = map.get(key)?;
387    if rest.is_empty() {
388        Some(value)
389    } else {
390        match value {
391            HoconValue::Object(inner) => lookup_in_map_by_segments(inner, rest),
392            _ => None,
393        }
394    }
395}
396
397#[cfg(feature = "serde")]
398impl Config {
399    /// Deserialize this config into any type implementing [`serde::Deserialize`].
400    ///
401    /// Requires the `serde` feature. HOCON-aware coercion (e.g., string-to-number)
402    /// is applied during deserialization.
403    pub fn deserialize<T: ::serde::de::DeserializeOwned>(
404        &self,
405    ) -> Result<T, crate::serde::DeserializeError> {
406        let value = HoconValue::Object(self.root.clone());
407        T::deserialize(crate::serde::HoconDeserializer::new(&value))
408    }
409}
410
411fn parse_duration(s: &str) -> Option<std::time::Duration> {
412    let s = s.trim();
413    let num_end = s
414        .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
415        .unwrap_or(s.len());
416    let num_str = s[..num_end].trim();
417    let unit_str = s[num_end..].trim().to_lowercase();
418
419    let num: f64 = num_str.parse().ok()?;
420    if num < 0.0 || !num.is_finite() {
421        return None;
422    }
423
424    let nanos_per_unit: f64 = match unit_str.as_str() {
425        "ns" | "nano" | "nanos" | "nanosecond" | "nanoseconds" => 1.0,
426        "us" | "micro" | "micros" | "microsecond" | "microseconds" => 1_000.0,
427        "ms" | "milli" | "millis" | "millisecond" | "milliseconds" => 1_000_000.0,
428        "s" | "second" | "seconds" => 1_000_000_000.0,
429        "m" | "minute" | "minutes" => 60_000_000_000.0,
430        "h" | "hour" | "hours" => 3_600_000_000_000.0,
431        "d" | "day" | "days" => 86_400_000_000_000.0,
432        "w" | "week" | "weeks" => 604_800_000_000_000.0,
433        _ => return None,
434    };
435
436    Some(std::time::Duration::from_nanos(
437        (num * nanos_per_unit) as u64,
438    ))
439}
440
441fn parse_bytes(s: &str) -> Option<i64> {
442    let s = s.trim();
443    let num_end = s
444        .find(|c: char| !c.is_ascii_digit() && c != '.')
445        .unwrap_or(s.len());
446    let num_str = s[..num_end].trim();
447    let unit_str = s[num_end..].trim();
448
449    // Case-sensitive matching: KB vs KiB matters. Short forms (K, M, G, T) are
450    // treated as SI decimal units (KB, MB, GB, TB).
451    let multiplier: i64 = match unit_str {
452        "" | "B" | "byte" | "bytes" => 1,
453        "K" | "KB" | "kilobyte" | "kilobytes" => 1_000,
454        "KiB" | "kibibyte" | "kibibytes" => 1_024,
455        "M" | "MB" | "megabyte" | "megabytes" => 1_000_000,
456        "MiB" | "mebibyte" | "mebibytes" => 1_048_576,
457        "G" | "GB" | "gigabyte" | "gigabytes" => 1_000_000_000,
458        "GiB" | "gibibyte" | "gibibytes" => 1_073_741_824,
459        "T" | "TB" | "terabyte" | "terabytes" => 1_000_000_000_000,
460        "TiB" | "tebibyte" | "tebibytes" => 1_099_511_627_776,
461        _ => return None,
462    };
463
464    // Try lossless integer path first, fall back to f64 for fractional values
465    if let Ok(n) = num_str.parse::<i64>() {
466        n.checked_mul(multiplier)
467    } else {
468        let num: f64 = num_str.parse().ok()?;
469        let result = (num * multiplier as f64).round();
470        if !result.is_finite() || result > i64::MAX as f64 || result < i64::MIN as f64 {
471            return None;
472        }
473        Some(result as i64)
474    }
475}
476
477fn missing(path: &str) -> ConfigError {
478    ConfigError {
479        message: "key not found".to_string(),
480        path: path.to_string(),
481    }
482}
483
484fn type_mismatch(path: &str, expected: &str) -> ConfigError {
485    ConfigError {
486        message: format!("expected {}", expected),
487        path: path.to_string(),
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::value::{HoconValue, ScalarValue};
495    use indexmap::IndexMap;
496
497    fn make_config(entries: Vec<(&str, HoconValue)>) -> Config {
498        let mut map = IndexMap::new();
499        for (k, v) in entries {
500            map.insert(k.to_string(), v);
501        }
502        Config::new(map)
503    }
504
505    fn sv(s: &str) -> HoconValue {
506        HoconValue::Scalar(ScalarValue::string(s.into()))
507    }
508    fn iv(n: i64) -> HoconValue {
509        HoconValue::Scalar(ScalarValue::number(n.to_string()))
510    }
511    fn fv(n: f64) -> HoconValue {
512        HoconValue::Scalar(ScalarValue::number(n.to_string()))
513    }
514    fn bv(b: bool) -> HoconValue {
515        HoconValue::Scalar(ScalarValue::boolean(b))
516    }
517
518    #[test]
519    fn get_returns_value_at_path() {
520        let c = make_config(vec![("host", sv("localhost"))]);
521        assert!(c.get("host").is_some());
522    }
523
524    #[test]
525    fn get_returns_none_for_missing() {
526        let c = make_config(vec![]);
527        assert!(c.get("missing").is_none());
528    }
529
530    #[test]
531    fn get_string_returns_string() {
532        let c = make_config(vec![("host", sv("localhost"))]);
533        assert_eq!(c.get_string("host").unwrap(), "localhost");
534    }
535
536    #[test]
537    fn get_string_coerces_int() {
538        let c = make_config(vec![("port", iv(8080))]);
539        assert_eq!(c.get_string("port").unwrap(), "8080");
540    }
541
542    #[test]
543    fn get_string_coerces_float() {
544        let c = make_config(vec![("ratio", fv(3.14))]);
545        // f64::to_string may produce "3.14" or similar; just check it parses back
546        let s = c.get_string("ratio").unwrap();
547        let v: f64 = s.parse().unwrap();
548        assert!((v - 3.14).abs() < 1e-10);
549    }
550
551    #[test]
552    fn get_string_coerces_bool() {
553        let c = make_config(vec![("flag", bv(true))]);
554        assert_eq!(c.get_string("flag").unwrap(), "true");
555    }
556
557    #[test]
558    fn get_string_coerces_null() {
559        let c = make_config(vec![("v", HoconValue::Scalar(ScalarValue::null()))]);
560        assert_eq!(c.get_string("v").unwrap(), "null");
561    }
562
563    #[test]
564    fn get_string_error_on_object() {
565        let mut inner = IndexMap::new();
566        inner.insert("x".into(), iv(1));
567        let c = make_config(vec![("obj", HoconValue::Object(inner))]);
568        assert!(c.get_string("obj").is_err());
569    }
570
571    #[test]
572    fn get_i64_returns_number() {
573        let c = make_config(vec![("port", iv(8080))]);
574        assert_eq!(c.get_i64("port").unwrap(), 8080);
575    }
576
577    #[test]
578    fn get_i64_coerces_numeric_string() {
579        let c = make_config(vec![("port", sv("9999"))]);
580        assert_eq!(c.get_i64("port").unwrap(), 9999);
581    }
582
583    #[test]
584    fn get_i64_error_on_non_numeric() {
585        let c = make_config(vec![("host", sv("localhost"))]);
586        assert!(c.get_i64("host").is_err());
587    }
588
589    #[test]
590    fn get_i64_error_on_overflow() {
591        // "1e20" parses as f64 but overflows i64 range
592        let c = make_config(vec![("big", sv("1e20"))]);
593        assert!(c.get_i64("big").is_err());
594    }
595
596    #[test]
597    fn get_i64_error_on_i64_max_plus_one() {
598        // 9223372036854775808 == i64::MAX + 1, parses as f64 but must not saturate
599        let c = make_config(vec![("big", sv("9223372036854775808"))]);
600        assert!(c.get_i64("big").is_err());
601    }
602
603    #[test]
604    fn get_f64_returns_float() {
605        let c = make_config(vec![("rate", fv(3.14))]);
606        assert!((c.get_f64("rate").unwrap() - 3.14).abs() < f64::EPSILON);
607    }
608
609    #[test]
610    fn get_f64_coerces_numeric_string() {
611        let c = make_config(vec![("rate", sv("3.14"))]);
612        assert!((c.get_f64("rate").unwrap() - 3.14).abs() < f64::EPSILON);
613    }
614
615    #[test]
616    fn get_bool_returns_bool() {
617        let c = make_config(vec![("debug", bv(true))]);
618        assert!(c.get_bool("debug").unwrap());
619    }
620
621    #[test]
622    fn get_bool_coerces_string_true() {
623        let c = make_config(vec![("debug", sv("true"))]);
624        assert!(c.get_bool("debug").unwrap());
625    }
626
627    #[test]
628    fn get_bool_coerces_string_false() {
629        let c = make_config(vec![("debug", sv("false"))]);
630        assert!(!c.get_bool("debug").unwrap());
631    }
632
633    #[test]
634    fn get_bool_coerces_yes_no_on_off() {
635        let c1 = make_config(vec![("v", sv("yes"))]);
636        assert!(c1.get_bool("v").unwrap());
637        let c2 = make_config(vec![("v", sv("no"))]);
638        assert!(!c2.get_bool("v").unwrap());
639        let c3 = make_config(vec![("v", sv("on"))]);
640        assert!(c3.get_bool("v").unwrap());
641        let c4 = make_config(vec![("v", sv("off"))]);
642        assert!(!c4.get_bool("v").unwrap());
643    }
644
645    #[test]
646    fn get_bool_is_case_insensitive() {
647        let c = make_config(vec![("v", sv("TRUE"))]);
648        assert!(c.get_bool("v").unwrap());
649        let c2 = make_config(vec![("v", sv("Off"))]);
650        assert!(!c2.get_bool("v").unwrap());
651    }
652
653    #[test]
654    fn get_bool_error_on_non_boolean() {
655        let c = make_config(vec![("v", sv("maybe"))]);
656        assert!(c.get_bool("v").is_err());
657    }
658
659    #[test]
660    fn has_returns_true_for_existing() {
661        let c = make_config(vec![("host", sv("localhost"))]);
662        assert!(c.has("host"));
663    }
664
665    #[test]
666    fn has_returns_false_for_missing() {
667        let c = make_config(vec![]);
668        assert!(!c.has("missing"));
669    }
670
671    #[test]
672    fn keys_returns_in_order() {
673        let c = make_config(vec![("b", iv(2)), ("a", iv(1))]);
674        assert_eq!(c.keys(), vec!["b", "a"]);
675    }
676
677    #[test]
678    fn get_nested_dot_path() {
679        let mut inner = IndexMap::new();
680        inner.insert("host".into(), sv("localhost"));
681        let c = make_config(vec![("server", HoconValue::Object(inner))]);
682        assert_eq!(c.get_string("server.host").unwrap(), "localhost");
683    }
684
685    #[test]
686    fn get_config_returns_sub_config() {
687        let mut inner = IndexMap::new();
688        inner.insert("host".into(), sv("localhost"));
689        let c = make_config(vec![("server", HoconValue::Object(inner))]);
690        let sub = c.get_config("server").unwrap();
691        assert_eq!(sub.get_string("host").unwrap(), "localhost");
692    }
693
694    #[test]
695    fn get_list_returns_array() {
696        let items = vec![iv(1), iv(2), iv(3)];
697        let c = make_config(vec![("list", HoconValue::Array(items))]);
698        let list = c.get_list("list").unwrap();
699        assert_eq!(list.len(), 3);
700    }
701
702    #[test]
703    fn with_fallback_receiver_wins() {
704        let c1 = make_config(vec![("host", sv("prod"))]);
705        let c2 = make_config(vec![("host", sv("dev")), ("port", iv(8080))]);
706        let merged = c1.with_fallback(&c2);
707        assert_eq!(merged.get_string("host").unwrap(), "prod");
708        assert_eq!(merged.get_i64("port").unwrap(), 8080);
709    }
710
711    #[test]
712    fn option_variants_return_none_on_missing() {
713        let c = make_config(vec![]);
714        assert!(c.get_string_option("x").is_none());
715        assert!(c.get_i64_option("x").is_none());
716        assert!(c.get_f64_option("x").is_none());
717        assert!(c.get_bool_option("x").is_none());
718    }
719
720    #[test]
721    fn get_duration_nanoseconds() {
722        let c = make_config(vec![("t", sv("100 ns"))]);
723        assert_eq!(
724            c.get_duration("t").unwrap(),
725            std::time::Duration::from_nanos(100)
726        );
727    }
728
729    #[test]
730    fn get_duration_milliseconds() {
731        let c = make_config(vec![("t", sv("500 ms"))]);
732        assert_eq!(
733            c.get_duration("t").unwrap(),
734            std::time::Duration::from_millis(500)
735        );
736    }
737
738    #[test]
739    fn get_duration_seconds() {
740        let c = make_config(vec![("t", sv("30 seconds"))]);
741        assert_eq!(
742            c.get_duration("t").unwrap(),
743            std::time::Duration::from_secs(30)
744        );
745    }
746
747    #[test]
748    fn get_duration_minutes() {
749        let c = make_config(vec![("t", sv("5 m"))]);
750        assert_eq!(
751            c.get_duration("t").unwrap(),
752            std::time::Duration::from_secs(300)
753        );
754    }
755
756    #[test]
757    fn get_duration_hours() {
758        let c = make_config(vec![("t", sv("2 hours"))]);
759        assert_eq!(
760            c.get_duration("t").unwrap(),
761            std::time::Duration::from_secs(7200)
762        );
763    }
764
765    #[test]
766    fn get_duration_days() {
767        let c = make_config(vec![("t", sv("1 d"))]);
768        assert_eq!(
769            c.get_duration("t").unwrap(),
770            std::time::Duration::from_secs(86400)
771        );
772    }
773
774    #[test]
775    fn get_duration_fractional() {
776        let c = make_config(vec![("t", sv("1.5 hours"))]);
777        assert_eq!(
778            c.get_duration("t").unwrap(),
779            std::time::Duration::from_secs(5400)
780        );
781    }
782
783    #[test]
784    fn get_duration_no_space() {
785        let c = make_config(vec![("t", sv("100ms"))]);
786        assert_eq!(
787            c.get_duration("t").unwrap(),
788            std::time::Duration::from_millis(100)
789        );
790    }
791
792    #[test]
793    fn get_duration_singular_unit() {
794        let c = make_config(vec![("t", sv("1 second"))]);
795        assert_eq!(
796            c.get_duration("t").unwrap(),
797            std::time::Duration::from_secs(1)
798        );
799    }
800
801    #[test]
802    fn get_duration_error_invalid_unit() {
803        let c = make_config(vec![("t", sv("100 foos"))]);
804        assert!(c.get_duration("t").is_err());
805    }
806
807    #[test]
808    fn get_duration_option_missing() {
809        let c = make_config(vec![]);
810        assert!(c.get_duration_option("t").is_none());
811    }
812
813    #[test]
814    fn get_bytes_plain() {
815        let c = make_config(vec![("s", sv("100 B"))]);
816        assert_eq!(c.get_bytes("s").unwrap(), 100);
817    }
818
819    #[test]
820    fn get_bytes_kilobytes() {
821        let c = make_config(vec![("s", sv("10 KB"))]);
822        assert_eq!(c.get_bytes("s").unwrap(), 10_000);
823    }
824
825    #[test]
826    fn get_bytes_kibibytes() {
827        let c = make_config(vec![("s", sv("1 KiB"))]);
828        assert_eq!(c.get_bytes("s").unwrap(), 1_024);
829    }
830
831    #[test]
832    fn get_bytes_megabytes() {
833        let c = make_config(vec![("s", sv("5 MB"))]);
834        assert_eq!(c.get_bytes("s").unwrap(), 5_000_000);
835    }
836
837    #[test]
838    fn get_bytes_mebibytes() {
839        let c = make_config(vec![("s", sv("1 MiB"))]);
840        assert_eq!(c.get_bytes("s").unwrap(), 1_048_576);
841    }
842
843    #[test]
844    fn get_bytes_gigabytes() {
845        let c = make_config(vec![("s", sv("2 GB"))]);
846        assert_eq!(c.get_bytes("s").unwrap(), 2_000_000_000);
847    }
848
849    #[test]
850    fn get_bytes_gibibytes() {
851        let c = make_config(vec![("s", sv("1 GiB"))]);
852        assert_eq!(c.get_bytes("s").unwrap(), 1_073_741_824);
853    }
854
855    #[test]
856    fn get_bytes_terabytes() {
857        let c = make_config(vec![("s", sv("1 TB"))]);
858        assert_eq!(c.get_bytes("s").unwrap(), 1_000_000_000_000);
859    }
860
861    #[test]
862    fn get_bytes_tebibytes() {
863        let c = make_config(vec![("s", sv("1 TiB"))]);
864        assert_eq!(c.get_bytes("s").unwrap(), 1_099_511_627_776);
865    }
866
867    #[test]
868    fn get_bytes_no_space() {
869        let c = make_config(vec![("s", sv("512MB"))]);
870        assert_eq!(c.get_bytes("s").unwrap(), 512_000_000);
871    }
872
873    #[test]
874    fn get_bytes_long_unit() {
875        let c = make_config(vec![("s", sv("2 megabytes"))]);
876        assert_eq!(c.get_bytes("s").unwrap(), 2_000_000);
877    }
878
879    #[test]
880    fn get_bytes_error_invalid_unit() {
881        let c = make_config(vec![("s", sv("100 XB"))]);
882        assert!(c.get_bytes("s").is_err());
883    }
884
885    #[test]
886    fn get_bytes_option_missing() {
887        let c = make_config(vec![]);
888        assert!(c.get_bytes_option("s").is_none());
889    }
890
891    #[test]
892    fn get_bytes_fractional_rounds() {
893        // 1.5 KiB = 1536 bytes exactly; rounding should not change it
894        let c = make_config(vec![("s", sv("1.5 KiB"))]);
895        assert_eq!(c.get_bytes("s").unwrap(), 1536);
896    }
897
898    #[test]
899    fn split_config_path_consecutive_dots_preserve_empty() {
900        let segs = split_config_path("a..b");
901        assert_eq!(segs, vec!["a", "", "b"]);
902    }
903
904    #[test]
905    fn split_config_path_trailing_dot_empty_segment() {
906        let segs = split_config_path("a.b.");
907        assert_eq!(segs, vec!["a", "b", ""]);
908    }
909
910    #[test]
911    fn split_config_path_quoted_escape() {
912        // "a\"b" as a path key should produce the key: a"b
913        let segs = split_config_path(r#""a\"b""#);
914        assert_eq!(segs, vec!["a\"b"]);
915    }
916
917    #[test]
918    fn split_config_path_quoted_with_dot() {
919        let segs = split_config_path(r#"server."web.api".port"#);
920        assert_eq!(segs, vec!["server", "web.api", "port"]);
921    }
922}