Skip to main content

hocon/
config.rs

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