Skip to main content

hocon/
config.rs

1use crate::error::{ConfigError, NotResolvedError};
2use crate::lexer::is_hocon_whitespace;
3use crate::numeric_array::numeric_object_to_array;
4use crate::value::{HoconValue, ScalarType};
5use indexmap::IndexMap;
6use std::path::PathBuf;
7
8/// A calendar period with year, month, and day components.
9///
10/// Returned by [`Config::get_period`] and [`Config::get_period_option`].
11/// All fields are `i32` to support negative periods (matching Lightbend behaviour).
12///
13/// The struct is `#[non_exhaustive]` so that new fields (e.g. weeks, hours) can
14/// be added in a future minor version without a breaking change.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16#[non_exhaustive]
17pub struct Period {
18    pub years: i32,
19    pub months: i32,
20    pub days: i32,
21}
22
23impl Period {
24    /// Construct a `Period` from year, month, and day components.
25    pub fn new(years: i32, months: i32, days: i32) -> Self {
26        Self {
27            years,
28            months,
29            days,
30        }
31    }
32}
33
34/// A parsed HOCON configuration object.
35///
36/// `Config` wraps an ordered map of top-level keys to [`HoconValue`]s and
37/// provides typed getters that accept dot-separated paths
38/// (e.g., `"server.host"`).
39///
40/// After E12, `Config` may be *resolved* (no substitution placeholders remain)
41/// or *unresolved* (placeholders remain; call [`Config::resolve`] before
42/// accessing values that touch a placeholder). Check with [`Config::is_resolved`].
43#[derive(Debug, Clone)]
44pub struct Config {
45    pub(crate) root: IndexMap<String, HoconValue>,
46    /// Whether the tree contains no substitution placeholders.
47    pub(crate) resolved: bool,
48    /// Base directory for resolving relative include paths on re-resolution.
49    pub(crate) parse_base_dir: Option<PathBuf>,
50    /// User-visible source name carried for error messages (from ParseOptions).
51    pub(crate) origin_description: Option<String>,
52    /// Pre-resolution ResObj with prior_values; used by resolve() / resolve_with().
53    /// None for fully-resolved Configs.
54    pub(crate) unresolved_tree: Option<crate::resolver::types::ResObj>,
55}
56
57impl PartialEq for Config {
58    fn eq(&self, other: &Self) -> bool {
59        self.root == other.root
60            && self.resolved == other.resolved
61            && self.parse_base_dir == other.parse_base_dir
62            && self.origin_description == other.origin_description
63    }
64}
65
66impl Config {
67    /// Create a `Config` from a pre-built ordered map of key-value pairs.
68    /// Marks the config as resolved (no substitution placeholders).
69    pub fn new(root: IndexMap<String, HoconValue>) -> Self {
70        Self {
71            root,
72            resolved: true,
73            parse_base_dir: None,
74            origin_description: None,
75            unresolved_tree: None,
76        }
77    }
78
79    /// Create a fully-resolved `Config` with optional metadata.
80    pub(crate) fn new_with_meta(
81        root: IndexMap<String, HoconValue>,
82        origin_description: Option<String>,
83    ) -> Self {
84        Self {
85            root,
86            resolved: true,
87            parse_base_dir: None,
88            origin_description,
89            unresolved_tree: None,
90        }
91    }
92
93    /// Create an unresolved `Config` from a `ResObj` tree.
94    /// `resolved` is derived from actual tree content so a deferred parse of a
95    /// substitution-free document still reports `is_resolved() = true`.
96    pub(crate) fn new_from_res_obj(
97        tree: crate::resolver::types::ResObj,
98        parse_base_dir: Option<PathBuf>,
99        origin_description: Option<String>,
100    ) -> Self {
101        let root = crate::resolver::res_obj_to_hocon_partial(&tree);
102        let resolved = !crate::resolver::contains_placeholders_in_hocon_map(&root);
103        // Keep the ResObj in unresolved_tree if either:
104        // - There are placeholders (resolved=false), OR
105        // - There are prior_values anywhere in the tree (composition barrier info
106        //   for future with_fallback calls). This ensures barriers survive when
107        //   the merged config is placeholder-free.
108        let has_priors = crate::resolver::res_obj_has_priors(&tree);
109        Self {
110            root,
111            resolved,
112            parse_base_dir,
113            origin_description,
114            unresolved_tree: if resolved && !has_priors {
115                None
116            } else {
117                Some(tree)
118            },
119        }
120    }
121
122    /// Returns `true` if the config's value tree contains no unresolved
123    /// substitution placeholders. Whole-config granularity per E12 decision 11.
124    pub fn is_resolved(&self) -> bool {
125        if self.resolved {
126            return true;
127        }
128        !crate::resolver::contains_placeholders_in_hocon_map(&self.root)
129    }
130
131    /// The user-visible source name associated with this config, if any.
132    pub fn origin_description(&self) -> Option<&str> {
133        self.origin_description.as_deref()
134    }
135
136    /// Perform substitution resolution, producing a fully resolved `Config`.
137    ///
138    /// Idempotent on already-resolved Configs. On unresolved Configs, runs
139    /// `resolver::resolve_tree` (phase 2) on the stored `unresolved_tree`
140    /// (priors preserved for S13a self-ref) or reconstructed ResObj.
141    pub fn resolve(
142        &self,
143        opts: crate::options::ResolveOptions,
144    ) -> Result<Config, crate::error::HoconError> {
145        use crate::error::{HoconError, ParseError};
146        if self.is_resolved() {
147            return Ok(Config {
148                root: self.root.clone(),
149                resolved: true,
150                parse_base_dir: self.parse_base_dir.clone(),
151                origin_description: self.origin_description.clone(),
152                unresolved_tree: None,
153            });
154        }
155
156        let tree = match &self.unresolved_tree {
157            Some(t) => t.clone(),
158            None => crate::resolver::hocon_map_to_res_obj(&self.root),
159        };
160
161        let env: std::collections::HashMap<String, String> = if opts.use_system_environment {
162            std::env::vars().collect()
163        } else {
164            std::collections::HashMap::new()
165        };
166        let internal_opts = crate::resolver::InternalResolveOptions::new(env)
167            .with_base_dir_opt(self.parse_base_dir.clone())
168            .with_allow_unresolved(opts.allow_unresolved)
169            .with_use_system_environment(opts.use_system_environment);
170
171        // T1+T2 fix: clone the pre-resolution tree before consuming it.
172        // If resolution produces a still-unresolved output (allow_unresolved=true
173        // with remaining placeholders), we store this pre-resolution tree as
174        // unresolved_tree instead of reconstructing it from the HoconValue output.
175        // Reconstruction via hocon_map_to_res_obj loses ConcatPlaceholder structure
176        // (concat becomes a sentinel Placeholder), so subsequent with_fallback() /
177        // resolve() cycles would fail to re-resolve concat values (T2 root cause).
178        // The pre-resolution tree retains all Concat/Subst structure for correct
179        // re-resolution once missing values become available.
180        let pre_resolution_tree = if opts.allow_unresolved {
181            Some(tree.clone())
182        } else {
183            None
184        };
185
186        let resolved_value = crate::resolver::resolve_tree(tree, &internal_opts)?;
187        match resolved_value {
188            HoconValue::Object(fields) => {
189                let resolved = !crate::resolver::contains_placeholders_in_hocon_map(&fields);
190                let unresolved_tree = if resolved {
191                    None
192                } else {
193                    // Use the pre-resolution tree (retains ConcatPlaceholder structure).
194                    pre_resolution_tree
195                };
196                Ok(Config {
197                    root: fields,
198                    resolved,
199                    parse_base_dir: self.parse_base_dir.clone(),
200                    origin_description: self.origin_description.clone(),
201                    unresolved_tree,
202                })
203            }
204            _ => Err(HoconError::Parse(ParseError {
205                message: "root must be an object".into(),
206                line: 1,
207                col: 1,
208            })),
209        }
210    }
211
212    /// Resolve substitutions using `source` for lookup; source keys NOT in result.
213    ///
214    /// Differs from `self.with_fallback(source).resolve(opts)` which DOES
215    /// include source keys in the result.
216    ///
217    /// Precondition: `source.is_resolved()` must be `true`. If not,
218    /// returns `Err(HoconError::NotResolved(...))` immediately (E12 decision 10).
219    ///
220    /// The filter is RECURSIVE: only paths in receiver's pre-merge shape are kept.
221    pub fn resolve_with(
222        &self,
223        source: &Config,
224        opts: crate::options::ResolveOptions,
225    ) -> Result<Config, crate::error::HoconError> {
226        use crate::error::{HoconError, ParseError};
227        if !source.is_resolved() {
228            return Err(HoconError::NotResolved(NotResolvedError {
229                path: "<source>".into(),
230            }));
231        }
232
233        if self.is_resolved() {
234            return Ok(Config {
235                root: self.root.clone(),
236                resolved: true,
237                parse_base_dir: self.parse_base_dir.clone(),
238                origin_description: self.origin_description.clone(),
239                unresolved_tree: None,
240            });
241        }
242
243        // Snapshot receiver key shape BEFORE merge.
244        let receiver_root_snapshot = self.root.clone();
245
246        let recv_obj = match &self.unresolved_tree {
247            Some(t) => t.clone(),
248            None => crate::resolver::hocon_map_to_res_obj(&self.root),
249        };
250        let src_obj = crate::resolver::hocon_map_to_res_obj(&source.root);
251        let merged = crate::resolver::merge_unresolved(recv_obj, src_obj);
252
253        let env: std::collections::HashMap<String, String> = if opts.use_system_environment {
254            std::env::vars().collect()
255        } else {
256            std::collections::HashMap::new()
257        };
258        let internal_opts = crate::resolver::InternalResolveOptions::new(env)
259            .with_base_dir_opt(self.parse_base_dir.clone())
260            .with_allow_unresolved(opts.allow_unresolved)
261            .with_use_system_environment(opts.use_system_environment);
262
263        // T1+T2 fix (resolve_with): same as resolve() — clone pre-resolution merged
264        // tree before consuming it, so that ConcatPlaceholder structure is preserved
265        // for re-resolution when allow_unresolved=true leaves placeholders.
266        let pre_resolution_tree = if opts.allow_unresolved {
267            Some(merged.clone())
268        } else {
269            None
270        };
271
272        let resolved_value = crate::resolver::resolve_tree(merged, &internal_opts)?;
273
274        let filtered = match resolved_value {
275            HoconValue::Object(mut fields) => {
276                // Recursive filter using receiver's pre-merge shape.
277                filter_hocon_object_by_receiver(&mut fields, &receiver_root_snapshot);
278                fields
279            }
280            _ => {
281                return Err(HoconError::Parse(ParseError {
282                    message: "root must be an object".into(),
283                    line: 1,
284                    col: 1,
285                }));
286            }
287        };
288
289        let resolved = !crate::resolver::contains_placeholders_in_hocon_map(&filtered);
290        let unresolved_tree = if resolved {
291            None
292        } else {
293            // Use the pre-resolution tree (retains ConcatPlaceholder structure).
294            pre_resolution_tree
295        };
296        Ok(Config {
297            root: filtered,
298            resolved,
299            parse_base_dir: self.parse_base_dir.clone(),
300            origin_description: self.origin_description.clone(),
301            unresolved_tree,
302        })
303    }
304
305    // Walk the dot-separated path through nested objects.
306    fn lookup_node(&self, path: &str) -> Option<&HoconValue> {
307        let segments = split_config_path(path);
308        lookup_in_map_by_segments(&self.root, &segments)
309    }
310
311    /// Return the raw [`HoconValue`] at the given dot-separated path,
312    /// or `None` if the path does not exist.
313    pub fn get(&self, path: &str) -> Option<&HoconValue> {
314        self.lookup_node(path)
315    }
316
317    /// Return the value at `path` as a `String`.
318    ///
319    /// Returns the raw string for any non-null scalar (string, number,
320    /// boolean). Returns [`ConfigError`] if the path is missing, the value
321    /// is an Object or Array, or the value is `null` (spec L1252: null →
322    /// any non-null type is an error).
323    pub fn get_string(&self, path: &str) -> Result<String, ConfigError> {
324        match self.lookup_node(path) {
325            None => Err(missing(path)),
326            Some(HoconValue::Placeholder(_)) => Err(not_resolved(path)),
327            Some(HoconValue::Scalar(sv)) => {
328                if sv.value_type == ScalarType::Null {
329                    return Err(type_mismatch(path, "String"));
330                }
331                Ok(sv.raw.clone())
332            }
333            _ => Err(type_mismatch(path, "String")),
334        }
335    }
336
337    /// Return the value at `path` as an `i64`.
338    ///
339    /// Whole-number floats and numeric strings are coerced automatically.
340    /// Returns [`ConfigError`] if the path is missing or the value cannot be
341    /// represented as `i64`.
342    pub fn get_i64(&self, path: &str) -> Result<i64, ConfigError> {
343        match self.lookup_node(path) {
344            None => Err(missing(path)),
345            Some(HoconValue::Placeholder(_)) => Err(not_resolved(path)),
346            Some(HoconValue::Scalar(sv)) => {
347                // Direct i64 parse, else exact whole-number float/exponent
348                // coercion from the raw text (xx.hocon#56: never via f64).
349                sv.raw
350                    .parse::<i64>()
351                    .ok()
352                    .or_else(|| crate::value::whole_float_to_i64(&sv.raw))
353                    .ok_or_else(|| type_mismatch(path, "i64"))
354            }
355            _ => Err(type_mismatch(path, "i64")),
356        }
357    }
358
359    /// Return the value at `path` as an `f64`.
360    ///
361    /// Integers and numeric strings are coerced automatically.
362    /// Returns [`ConfigError`] if the path is missing or the value cannot be
363    /// represented as `f64`.
364    pub fn get_f64(&self, path: &str) -> Result<f64, ConfigError> {
365        match self.lookup_node(path) {
366            None => Err(missing(path)),
367            Some(HoconValue::Placeholder(_)) => Err(not_resolved(path)),
368            Some(HoconValue::Scalar(sv)) => sv
369                .raw
370                .parse::<f64>()
371                .map_err(|_| type_mismatch(path, "f64")),
372            _ => Err(type_mismatch(path, "f64")),
373        }
374    }
375
376    /// Return the value at `path` as a `bool`.
377    ///
378    /// String values `"true"`, `"yes"`, `"on"` (case-insensitive) coerce to
379    /// `true`; `"false"`, `"no"`, `"off"` coerce to `false`.
380    /// Returns [`ConfigError`] if the path is missing or the value is not boolean-like.
381    pub fn get_bool(&self, path: &str) -> Result<bool, ConfigError> {
382        match self.lookup_node(path) {
383            None => Err(missing(path)),
384            Some(HoconValue::Placeholder(_)) => Err(not_resolved(path)),
385            Some(HoconValue::Scalar(sv)) => match sv.raw.to_lowercase().as_str() {
386                "true" | "yes" | "on" => Ok(true),
387                "false" | "no" | "off" => Ok(false),
388                _ => Err(type_mismatch(path, "bool")),
389            },
390            _ => Err(type_mismatch(path, "bool")),
391        }
392    }
393
394    /// Return the sub-object at `path` as a new [`Config`].
395    ///
396    /// Returns [`ConfigError`] if the path is missing or the value is not an object.
397    pub fn get_config(&self, path: &str) -> Result<Config, ConfigError> {
398        match self.lookup_node(path) {
399            None => Err(missing(path)),
400            Some(HoconValue::Placeholder(_)) => Err(not_resolved(path)),
401            Some(HoconValue::Object(map)) => Ok(Config::new(map.clone())),
402            _ => Err(type_mismatch(path, "Object")),
403        }
404    }
405
406    /// Return the array at `path` as a `Vec<HoconValue>`.
407    ///
408    /// Returns [`ConfigError`] if the path is missing or the value is not an array.
409    ///
410    /// Numerically-indexed objects (S15) are converted to an array on demand:
411    /// `{"0":"a","1":"b"}` returns `["a","b"]`. Empty objects and objects with
412    /// no integer keys are NOT converted — they return a type-mismatch error.
413    pub fn get_list(&self, path: &str) -> Result<Vec<HoconValue>, ConfigError> {
414        match self.lookup_node(path) {
415            None => Err(missing(path)),
416            Some(HoconValue::Placeholder(_)) => Err(not_resolved(path)),
417            Some(HoconValue::Array(items)) => Ok(items.clone()),
418            Some(v @ HoconValue::Object(_)) => {
419                // S15: attempt numeric-keyed object → array conversion.
420                // Returns None for empty objects (S15.4) and objects with no
421                // eligible integer keys (S15.12 / na12). In those cases fall
422                // through to the type-mismatch error.
423                numeric_object_to_array(v).ok_or_else(|| type_mismatch(path, "Array"))
424            }
425            _ => Err(type_mismatch(path, "Array")),
426        }
427    }
428
429    /// Like [`get_string`](Self::get_string) but returns `None` instead of an error.
430    pub fn get_string_option(&self, path: &str) -> Option<String> {
431        self.get_string(path).ok()
432    }
433
434    /// Like [`get_i64`](Self::get_i64) but returns `None` instead of an error.
435    pub fn get_i64_option(&self, path: &str) -> Option<i64> {
436        self.get_i64(path).ok()
437    }
438
439    /// Like [`get_f64`](Self::get_f64) but returns `None` instead of an error.
440    pub fn get_f64_option(&self, path: &str) -> Option<f64> {
441        self.get_f64(path).ok()
442    }
443
444    /// Like [`get_bool`](Self::get_bool) but returns `None` instead of an error.
445    pub fn get_bool_option(&self, path: &str) -> Option<bool> {
446        self.get_bool(path).ok()
447    }
448
449    /// Like [`get_config`](Self::get_config) but returns `None` instead of an error.
450    pub fn get_config_option(&self, path: &str) -> Option<Config> {
451        self.get_config(path).ok()
452    }
453
454    /// Like [`get_list`](Self::get_list) but returns `None` instead of an error.
455    pub fn get_list_option(&self, path: &str) -> Option<Vec<HoconValue>> {
456        self.get_list(path).ok()
457    }
458
459    /// Return the value at `path` as a [`Duration`](std::time::Duration).
460    ///
461    /// Accepts HOCON duration strings (e.g., `"30 seconds"`, `"100ms"`,
462    /// `"2 hours"`). Bare integers are interpreted as milliseconds.
463    ///
464    /// Supported units: `ns`/`nano`/`nanos`/`nanosecond`/`nanoseconds`,
465    /// `us`/`micro`/`micros`/`microsecond`/`microseconds`,
466    /// `ms`/`milli`/`millis`/`millisecond`/`milliseconds`,
467    /// `s`/`second`/`seconds`, `m`/`minute`/`minutes`,
468    /// `h`/`hour`/`hours`, `d`/`day`/`days`, `w`/`week`/`weeks`.
469    pub fn get_duration(&self, path: &str) -> Result<std::time::Duration, ConfigError> {
470        match self.lookup_node(path) {
471            None => Err(missing(path)),
472            Some(HoconValue::Scalar(sv)) => {
473                // Try as duration string first
474                if let Some(d) = parse_duration(&sv.raw) {
475                    return Ok(d);
476                }
477                // Number types: bare integer = milliseconds, bare float = milliseconds
478                if sv.value_type == ScalarType::Number {
479                    if let Ok(n) = sv.raw.parse::<i64>() {
480                        if n < 0 {
481                            return Err(ConfigError {
482                                message: format!("negative duration at {}: {}", path, sv.raw),
483                                path: path.to_string(),
484                            });
485                        }
486                        return Ok(std::time::Duration::from_millis(n as u64));
487                    }
488                    if let Ok(f) = sv.raw.parse::<f64>() {
489                        if f < 0.0 || !f.is_finite() {
490                            return Err(ConfigError {
491                                message: format!("invalid duration at {}: {}", path, sv.raw),
492                                path: path.to_string(),
493                            });
494                        }
495                        let secs = f / 1000.0;
496                        if secs > u64::MAX as f64 {
497                            return Err(ConfigError {
498                                message: format!("duration too large at {}: {}", path, sv.raw),
499                                path: path.to_string(),
500                            });
501                        }
502                        return Ok(std::time::Duration::from_secs_f64(secs));
503                    }
504                }
505                Err(ConfigError {
506                    message: format!("invalid duration at {}: {}", path, sv.raw),
507                    path: path.to_string(),
508                })
509            }
510            Some(HoconValue::Placeholder(_)) => Err(not_resolved(path)),
511            _ => Err(ConfigError {
512                message: format!("expected duration at {}", path),
513                path: path.to_string(),
514            }),
515        }
516    }
517
518    /// Like [`get_duration`](Self::get_duration) but returns `None` instead of an error.
519    pub fn get_duration_option(&self, path: &str) -> Option<std::time::Duration> {
520        self.get_duration(path).ok()
521    }
522
523    /// Return the value at `path` as a byte count (`i64`).
524    ///
525    /// Accepts HOCON byte-size strings (e.g., `"512 MB"`, `"1 GiB"`).
526    /// Bare integers are returned as-is (assumed bytes).
527    ///
528    /// Supported units: `B`/`byte`/`bytes`, `K`/`KB`/`kilobyte`/`kilobytes`,
529    /// `KiB`/`kibibyte`/`kibibytes`, `M`/`MB`/`megabyte`/`megabytes`,
530    /// `MiB`/`mebibyte`/`mebibytes`, `G`/`GB`/`gigabyte`/`gigabytes`,
531    /// `GiB`/`gibibyte`/`gibibytes`, `T`/`TB`/`terabyte`/`terabytes`,
532    /// `TiB`/`tebibyte`/`tebibytes`. Fractional numbers (e.g. `0.5M`) are supported.
533    pub fn get_bytes(&self, path: &str) -> Result<i64, ConfigError> {
534        let v = self.lookup_node(path).ok_or_else(|| ConfigError {
535            message: format!("path not found: {}", path),
536            path: path.to_string(),
537        })?;
538        match v {
539            HoconValue::Scalar(sv) => {
540                let n: i64 = if sv.value_type == ScalarType::Number {
541                    // Bare integer number: return as-is (assumed bytes).
542                    // Bare float without unit (e.g. "1.5") is not valid for bytes.
543                    sv.raw.parse::<i64>().map_err(|_| ConfigError {
544                        message: format!("expected byte size at {}", path),
545                        path: path.to_string(),
546                    })?
547                } else {
548                    // String type: try byte-size string (e.g. "512 MB", "1.5 KiB")
549                    parse_bytes(&sv.raw).ok_or_else(|| ConfigError {
550                        message: format!("invalid byte size at {}: {}", path, sv.raw),
551                        path: path.to_string(),
552                    })?
553                };
554                // ub04: Lightbend `getBytesBigInteger` rejects negative byte sizes.
555                // Bytes represent a resource size and must be non-negative.
556                // This guard applies to BOTH the bare-numeric and string paths.
557                if n < 0 {
558                    return Err(ConfigError {
559                        message: format!("negative byte size at {}: {}", path, sv.raw),
560                        path: path.to_string(),
561                    });
562                }
563                Ok(n)
564            }
565            HoconValue::Placeholder(_) => Err(not_resolved(path)),
566            _ => Err(ConfigError {
567                message: format!("expected byte size at {}", path),
568                path: path.to_string(),
569            }),
570        }
571    }
572
573    /// Like [`get_bytes`](Self::get_bytes) but returns `None` instead of an error.
574    pub fn get_bytes_option(&self, path: &str) -> Option<i64> {
575        self.get_bytes(path).ok()
576    }
577
578    /// Return the value at `path` as a calendar [`Period`].
579    ///
580    /// Accepts HOCON period strings (e.g. `"7d"`, `"2w"`, `"3m"`, `"1y"`) or a bare
581    /// integer string, which is taken as days per HOCON.md L1321.
582    ///
583    /// Supported units: `d`/`day`/`days` (default), `w`/`week`/`weeks` (× 7 days),
584    /// `m`/`mo`/`month`/`months`, `y`/`year`/`years`.
585    ///
586    /// Negative values are permitted (matches Lightbend behaviour).
587    pub fn get_period(&self, path: &str) -> Result<Period, ConfigError> {
588        match self.lookup_node(path) {
589            None => Err(missing(path)),
590            Some(HoconValue::Scalar(sv)) => {
591                if let Some((y, mo, d)) = parse_period(&sv.raw) {
592                    return Ok(Period::new(y, mo, d));
593                }
594                // Bare integer scalar (non-string): treat as days default (S18.1 parallel).
595                if sv.value_type == ScalarType::Number {
596                    if let Ok(n) = sv.raw.parse::<i32>() {
597                        return Ok(Period::new(0, 0, n));
598                    }
599                }
600                Err(ConfigError {
601                    message: format!("invalid period at {}: {}", path, sv.raw),
602                    path: path.to_string(),
603                })
604            }
605            Some(HoconValue::Placeholder(_)) => Err(not_resolved(path)),
606            _ => Err(ConfigError {
607                message: format!("expected period at {}", path),
608                path: path.to_string(),
609            }),
610        }
611    }
612
613    /// Like [`get_period`](Self::get_period) but returns `None` instead of an error.
614    pub fn get_period_option(&self, path: &str) -> Option<Period> {
615        self.get_period(path).ok()
616    }
617
618    /// Return `true` if a value exists at the given dot-separated path.
619    pub fn has(&self, path: &str) -> bool {
620        self.lookup_node(path).is_some()
621    }
622
623    /// Return the top-level keys in insertion order.
624    pub fn keys(&self) -> Vec<&str> {
625        self.root.keys().map(|s| s.as_str()).collect()
626    }
627
628    /// Merge this config with a fallback. Keys present in `self` win;
629    /// missing keys are filled from `fallback`. Nested objects are deep-merged.
630    ///
631    /// ```rust
632    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
633    /// let app = hocon::parse(r#"server.port = 9090"#)?;
634    /// let defaults = hocon::parse(r#"server { host = "0.0.0.0", port = 8080 }"#)?;
635    /// let merged = app.with_fallback(&defaults);
636    ///
637    /// assert_eq!(merged.get_i64("server.port")?, 9090);       // app wins
638    /// assert_eq!(merged.get_string("server.host")?, "0.0.0.0"); // filled from defaults
639    /// # Ok(())
640    /// # }
641    /// ```
642    /// Merge this config with a fallback. Receiver's keys win; missing keys
643    /// come from fallback. Nested objects are deep-merged.
644    ///
645    /// Accepts both resolved and unresolved operands (E12 decision 5).
646    /// Non-object collision captures fallback value as prior for S13a
647    /// cross-layer self-reference. Result is resolved iff merged tree
648    /// contains no placeholders.
649    pub fn with_fallback(&self, fallback: &Config) -> Config {
650        let recv_obj = match &self.unresolved_tree {
651            Some(t) => t.clone(),
652            None => crate::resolver::hocon_map_to_res_obj(&self.root),
653        };
654        let fb_obj = match &fallback.unresolved_tree {
655            Some(t) => t.clone(),
656            None => crate::resolver::hocon_map_to_res_obj(&fallback.root),
657        };
658        let merged = crate::resolver::merge_unresolved(recv_obj, fb_obj);
659        Config::new_from_res_obj(
660            merged,
661            self.parse_base_dir.clone(),
662            self.origin_description.clone(),
663        )
664    }
665}
666
667/// Split a HOCON config path into segments, respecting quoted keys.
668/// e.g. `server."web.api".port` → `["server", "web.api", "port"]`
669/// Empty segments are preserved: `a..b` → `["a", "", "b"]`.
670/// Quoted segments process escape sequences (e.g. `\"` → `"`).
671fn split_config_path(path: &str) -> Vec<String> {
672    let mut segments = Vec::new();
673    let chars: Vec<char> = path.chars().collect();
674    let mut i = 0;
675    while i < chars.len() {
676        if chars[i] == '"' {
677            // Quoted segment — collect until closing quote, processing escapes
678            i += 1; // skip opening quote
679            let mut seg = String::new();
680            let mut closed = false;
681            while i < chars.len() {
682                if chars[i] == '\\' && i + 1 < chars.len() {
683                    seg.push(chars[i + 1]);
684                    i += 2;
685                    continue;
686                }
687                if chars[i] == '"' {
688                    closed = true;
689                    i += 1;
690                    break;
691                }
692                seg.push(chars[i]);
693                i += 1;
694            }
695            if !closed {
696                return vec![path.to_string()]; // treat as literal if unterminated
697            }
698            segments.push(seg);
699            // skip optional '.' separator
700            if i < chars.len() && chars[i] == '.' {
701                i += 1;
702            }
703        } else {
704            // Unquoted segment — collect until '.' or '"'
705            // Always push the segment (even empty) to preserve consecutive-dot semantics.
706            let start = i;
707            while i < chars.len() && chars[i] != '.' && chars[i] != '"' {
708                i += 1;
709            }
710            segments.push(chars[start..i].iter().collect());
711            // skip optional '.' separator
712            if i < chars.len() && chars[i] == '.' {
713                i += 1;
714            }
715        }
716    }
717    // A trailing dot means there is a final empty segment
718    if path.ends_with('.') {
719        segments.push(String::new());
720    }
721    segments
722}
723
724fn lookup_in_map_by_segments<'a>(
725    map: &'a IndexMap<String, HoconValue>,
726    segments: &[String],
727) -> Option<&'a HoconValue> {
728    if segments.is_empty() {
729        return None;
730    }
731    let key = &segments[0];
732    let rest = &segments[1..];
733    let value = map.get(key)?;
734    if rest.is_empty() {
735        Some(value)
736    } else {
737        match value {
738            HoconValue::Object(inner) => lookup_in_map_by_segments(inner, rest),
739            _ => None,
740        }
741    }
742}
743
744#[cfg(feature = "serde")]
745impl Config {
746    /// Deserialize this config into any type implementing [`serde::Deserialize`].
747    ///
748    /// Requires the `serde` feature. HOCON-aware coercion (e.g., string-to-number)
749    /// is applied during deserialization.
750    pub fn deserialize<T: ::serde::de::DeserializeOwned>(
751        &self,
752    ) -> Result<T, crate::serde::DeserializeError> {
753        let value = HoconValue::Object(self.root.clone());
754        crate::serde::from_value(&value)
755    }
756
757    /// Deserialize the value at `path` into any type implementing
758    /// [`serde::Deserialize`].
759    ///
760    /// Unlike [`Config::get_config`] + [`Config::deserialize`] (object subtrees
761    /// only), this accepts **any** node at `path` — object, array, or scalar —
762    /// so e.g. `get_as::<Vec<T>>("servers")` deserializes a list directly.
763    ///
764    /// Requires the `serde` feature. HOCON-aware coercion is applied. Errors:
765    /// missing path, an unresolved substitution at/under `path`
766    /// (`ConfigError::is_not_resolved()` is then `true`), or a deserialization
767    /// failure (type mismatch etc.).
768    pub fn get_as<T: ::serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, ConfigError> {
769        let node = self.lookup_node(path).ok_or_else(|| missing(path))?;
770        if value_contains_placeholder(node) {
771            return Err(not_resolved(path));
772        }
773        crate::serde::from_value(node).map_err(|e| ConfigError {
774            message: e.message,
775            path: path.to_string(),
776        })
777    }
778}
779
780/// Trim HOCON whitespace (per `is_hocon_whitespace`) from both ends of `s`.
781///
782/// Unlike `str::trim()` (which is ASCII-only for whitespace), this respects the full
783/// HOCON_WS set (U+00A0 NBSP, U+FEFF BOM, various Unicode space separators, etc.).
784fn trim_hocon_ws(s: &str) -> &str {
785    s.trim_matches(is_hocon_whitespace)
786}
787
788/// Returns `true` if `s` matches `[+-]?[0-9]+` (integer pre-classification).
789///
790/// Mirrors Lightbend `SimpleConfig.isWholeNumber`. Used to choose the integer fast-path
791/// vs fractional fallback in `parse_duration` and `parse_bytes`.
792fn is_integer_str(s: &str) -> bool {
793    let s = s.strip_prefix(['+', '-']).unwrap_or(s);
794    !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
795}
796
797/// Parse a HOCON duration string into a [`std::time::Duration`].
798///
799/// Accepts `[ws] number [ws] [unit] [ws]` where `unit` is one of the HOCON duration
800/// units (HOCON.md L1304-1313). When no unit is present the default is **milliseconds**
801/// (HOCON.md L1301: "bare numbers are taken to be in milliseconds already").
802///
803/// # Fractional values (Lightbend-faithful per-family)
804///
805/// - Integer form `[+-]?[0-9]+`: parsed as `i64` milliseconds → scaled to nanos.
806/// - Fractional form: parsed as `f64`, multiplied by `nanos_per_unit`.
807///
808/// # Negative values (rs-specific limitation)
809///
810/// `std::time::Duration` is unsigned; this function returns `None` for negative inputs.
811/// Callers that need signed duration semantics should inspect the raw string first.
812/// `get_duration` documents this constraint; ud06 conformance test asserts `is_err()`.
813fn parse_duration(s: &str) -> Option<std::time::Duration> {
814    let s = trim_hocon_ws(s);
815    if s.is_empty() {
816        return None;
817    }
818
819    // Scan to end of the numeric prefix (digits, optional leading sign, optional decimal).
820    let num_end = s
821        .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+')
822        .unwrap_or(s.len());
823    let num_str = s[..num_end].trim();
824    // Trim HOCON whitespace between number and unit, then lowercase the unit.
825    let unit_str = trim_hocon_ws(&s[num_end..]).to_lowercase();
826
827    if num_str.is_empty() {
828        return None;
829    }
830
831    let nanos_per_unit: f64 = match unit_str.as_str() {
832        // Default: milliseconds (HOCON.md L1301).
833        "" | "ms" | "milli" | "millis" | "millisecond" | "milliseconds" => 1_000_000.0,
834        "ns" | "nano" | "nanos" | "nanosecond" | "nanoseconds" => 1.0,
835        "us" | "micro" | "micros" | "microsecond" | "microseconds" => 1_000.0,
836        "s" | "second" | "seconds" => 1_000_000_000.0,
837        "m" | "minute" | "minutes" => 60_000_000_000.0,
838        "h" | "hour" | "hours" => 3_600_000_000_000.0,
839        "d" | "day" | "days" => 86_400_000_000_000.0,
840        "w" | "week" | "weeks" => 604_800_000_000_000.0,
841        _ => return None,
842    };
843
844    // Integer fast-path (matches Lightbend `Long.parseLong`).
845    if is_integer_str(num_str) {
846        // Parse as i128 so we can range-check both negatives (rejected per rs's
847        // unsigned-Duration limitation) AND values in [i64::MAX, u64::MAX] before
848        // narrowing to u64. The prior i64 parse rejected the entire upper half of
849        // the representable nanos range as "parse error" rather than overflow.
850        let n_i128: i128 = num_str.parse().ok()?;
851        if n_i128 < 0 {
852            return None;
853        }
854        let n_u64: u64 = n_i128.try_into().ok()?;
855        // Overflow guard via checked_mul on u64. The table values are exact small
856        // integers (max = 604_800_000_000_000 for weeks), so the f64 → u64 cast
857        // is lossless. The prior `(n as f64 * nanos_per_unit) as u64` lost precision
858        // for large n (~2^53+) and silently saturated on overflow.
859        let unit_u64 = nanos_per_unit as u64;
860        let nanos = n_u64.checked_mul(unit_u64)?;
861        return Some(std::time::Duration::from_nanos(nanos));
862    }
863
864    // Fractional fallback (matches Lightbend `Double.parseDouble`).
865    let f: f64 = num_str.parse().ok()?;
866    if f < 0.0 || !f.is_finite() {
867        return None;
868    }
869    // Precision-safe upper bound: u64::MAX = 2^64 - 1 cannot be represented exactly
870    // in f64 (rounds up to 2^64). Compare against `2f64.powi(64)` — the exact float64
871    // value of 2^64 — so the boundary check fires for any value that would saturate
872    // the subsequent `as u64` cast. Same approach as the cluster #3h fractional byte
873    // overflow fix (rs `parse_bytes` 2^63, go `math.Exp2(63)`).
874    let product = f * nanos_per_unit;
875    if !product.is_finite() || product >= 2f64.powi(64) {
876        return None;
877    }
878    Some(std::time::Duration::from_nanos(product as u64))
879}
880
881/// Parse a HOCON period string into a `(years, months, days)` tuple.
882///
883/// Accepts `[ws] integer [ws] [unit] [ws]` where `unit` is one of the HOCON period
884/// units (HOCON.md L1324-1333). When no unit is present the default is **days**
885/// (HOCON.md L1321: "bare numbers are taken to be in days").
886///
887/// Period is integer-only (matches Lightbend `Integer.parseInt`). Fractional strings
888/// like `"7.5"` return `None`.
889///
890/// Returns `(years, months, days)` as `i32` to support negative periods (Lightbend allows
891/// negative). A `chrono` dependency is intentionally avoided; callers that need a typed
892/// `Period` can decompose the tuple.
893pub(crate) fn parse_period(s: &str) -> Option<(i32, i32, i32)> {
894    let s = trim_hocon_ws(s);
895    if s.is_empty() {
896        return None;
897    }
898
899    // Scan numeric prefix.
900    let num_end = s
901        .find(|c: char| !c.is_ascii_digit() && c != '-' && c != '+')
902        .unwrap_or(s.len());
903    let num_str = s[..num_end].trim();
904    let unit_str = trim_hocon_ws(&s[num_end..]);
905
906    if num_str.is_empty() {
907        return None;
908    }
909
910    // Period is integer-only (Lightbend `Integer.parseInt`). Reject fractional.
911    if !is_integer_str(num_str) {
912        return None;
913    }
914
915    let n: i32 = num_str.parse().ok()?;
916
917    // Unit match — case-sensitive per HOCON.md L1304 (period shares the same
918    // "unit names are case-sensitive" rule as duration; only lowercase accepted).
919    match unit_str {
920        // Default: days (HOCON.md L1321).
921        "" | "d" | "day" | "days" => Some((0, 0, n)),
922        "w" | "week" | "weeks" => Some((0, 0, n.checked_mul(7)?)),
923        "m" | "mo" | "month" | "months" => Some((0, n, 0)),
924        "y" | "year" | "years" => Some((n, 0, 0)),
925        _ => None,
926    }
927}
928
929/// Parse a HOCON byte-size string into a byte count.
930///
931/// Accepts `[ws] number [ws] [unit] [ws]`. When no unit is present the default is
932/// **bytes** (HOCON.md L1341: "bare numbers are taken to be in bytes").
933///
934/// Fractional values are accepted and **truncated** (not rounded) per Lightbend's
935/// `BigDecimal.toBigInteger()` semantics (e.g. `"1024.5"` → 1024 bytes).
936///
937/// Note: `get_bytes` rejects negative results at the accessor level (ub04 / Lightbend
938/// `getBytesBigInteger` positive-only invariant). `parse_bytes` itself allows negative
939/// integer inputs.
940fn parse_bytes(s: &str) -> Option<i64> {
941    let s = trim_hocon_ws(s);
942    let num_end = s
943        .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+')
944        .unwrap_or(s.len());
945    let num_str = s[..num_end].trim();
946    let unit_str = trim_hocon_ws(&s[num_end..]);
947
948    if num_str.is_empty() {
949        return None;
950    }
951
952    // Case-sensitive matching: KB vs KiB matters.
953    //
954    // Single-letter abbreviations (K, M, G, T, P, E) map to **powers of two**
955    // per HOCON.md L1385: "single-character abbreviations ('128K') should go
956    // with… powers of two" — aligned with Lightbend typesafe-config 1.4.3.
957    //
958    // BREAKING (since 1.3.0): K/M/G/T were previously treated as SI decimal
959    // (1_000, 1_000_000, …). They are now binary (1_024, 1_048_576, …).
960    // Multi-letter forms KB/MB/GB/TB remain SI decimal (separate match arms).
961    // See CHANGELOG.md — S21.4 BREAKING entry.
962    let multiplier: i64 = match unit_str {
963        "" | "B" | "byte" | "bytes" => 1,
964        // Single-letter → powers of two (HOCON.md L1385). BREAKING for K/M/G/T.
965        "K" | "k" => 1_024,
966        "M" | "m" => 1_048_576,
967        "G" | "g" => 1_073_741_824,
968        "T" | "t" => 1_099_511_627_776,
969        "P" | "p" => 1_125_899_906_842_624,
970        "E" | "e" => 1_152_921_504_606_846_976,
971        // Multi-letter SI decimal forms (unchanged).
972        "KB" | "kilobyte" | "kilobytes" => 1_000,
973        "KiB" | "Ki" | "kibibyte" | "kibibytes" => 1_024,
974        "MB" | "megabyte" | "megabytes" => 1_000_000,
975        "MiB" | "Mi" | "mebibyte" | "mebibytes" => 1_048_576,
976        "GB" | "gigabyte" | "gigabytes" => 1_000_000_000,
977        "GiB" | "Gi" | "gibibyte" | "gibibytes" => 1_073_741_824,
978        "TB" | "terabyte" | "terabytes" => 1_000_000_000_000,
979        "TiB" | "Ti" | "tebibyte" | "tebibytes" => 1_099_511_627_776,
980        _ => return None,
981    };
982
983    // Integer fast-path: lossless, avoids any floating-point rounding.
984    if is_integer_str(num_str) {
985        let n: i64 = num_str.parse().ok()?;
986        return n.checked_mul(multiplier);
987    }
988
989    // Fractional fallback: truncate toward zero per Lightbend `BigDecimal.toBigInteger()`.
990    let f: f64 = num_str.parse().ok()?;
991    // Overflow guard BEFORE the cast: `i64::MAX as f64` rounds up to exactly 2^63 in IEEE-754,
992    // so `> i64::MAX as f64` misses the boundary (8.0E == 2^63 passes the `>` test but saturates).
993    // Use `>= 2f64.powi(63)` to catch the exact boundary and all values above it.
994    if !f.is_finite() || f.abs() * multiplier as f64 >= 2f64.powi(63) {
995        return None;
996    }
997    Some((f * multiplier as f64) as i64)
998}
999
1000fn missing(path: &str) -> ConfigError {
1001    ConfigError {
1002        message: "key not found".to_string(),
1003        path: path.to_string(),
1004    }
1005}
1006
1007fn type_mismatch(path: &str, expected: &str) -> ConfigError {
1008    ConfigError {
1009        message: format!("expected {}", expected),
1010        path: path.to_string(),
1011    }
1012}
1013
1014fn not_resolved(path: &str) -> ConfigError {
1015    ConfigError {
1016        message: "value is not resolved (call Config::resolve() before accessing values)"
1017            .to_string(),
1018        path: path.to_string(),
1019    }
1020}
1021
1022/// Whether `value` is, or transitively contains, an unresolved substitution
1023/// placeholder. Used by `get_as` to map such cases to `not_resolved` (so
1024/// `ConfigError::is_not_resolved()` holds) before attempting deserialization.
1025#[cfg(feature = "serde")]
1026fn value_contains_placeholder(value: &HoconValue) -> bool {
1027    match value {
1028        HoconValue::Placeholder(_) => true,
1029        HoconValue::Object(map) => map.values().any(value_contains_placeholder),
1030        HoconValue::Array(items) => items.iter().any(value_contains_placeholder),
1031        HoconValue::Scalar(_) => false,
1032    }
1033}
1034
1035/// Recursively retain only keys present in `receiver_shape` (the receiver's
1036/// pre-merge key layout). For nested objects, recurse depth-first.
1037fn filter_hocon_object_by_receiver(
1038    resolved: &mut IndexMap<String, HoconValue>,
1039    receiver_shape: &IndexMap<String, HoconValue>,
1040) {
1041    resolved.retain(|k, v| {
1042        if !receiver_shape.contains_key(k) {
1043            return false;
1044        }
1045        if let (HoconValue::Object(inner_res), Some(HoconValue::Object(inner_recv))) =
1046            (v, receiver_shape.get(k))
1047        {
1048            filter_hocon_object_by_receiver(inner_res, inner_recv);
1049        }
1050        true
1051    });
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057    use crate::value::{HoconValue, ScalarValue};
1058    use indexmap::IndexMap;
1059
1060    fn make_config(entries: Vec<(&str, HoconValue)>) -> Config {
1061        let mut map = IndexMap::new();
1062        for (k, v) in entries {
1063            map.insert(k.to_string(), v);
1064        }
1065        Config::new(map)
1066    }
1067
1068    fn sv(s: &str) -> HoconValue {
1069        HoconValue::Scalar(ScalarValue::string(s.into()))
1070    }
1071    fn iv(n: i64) -> HoconValue {
1072        HoconValue::Scalar(ScalarValue::number(n.to_string()))
1073    }
1074    fn fv(n: f64) -> HoconValue {
1075        HoconValue::Scalar(ScalarValue::number(n.to_string()))
1076    }
1077    fn bv(b: bool) -> HoconValue {
1078        HoconValue::Scalar(ScalarValue::boolean(b))
1079    }
1080
1081    #[test]
1082    fn get_returns_value_at_path() {
1083        let c = make_config(vec![("host", sv("localhost"))]);
1084        assert!(c.get("host").is_some());
1085    }
1086
1087    #[test]
1088    fn get_returns_none_for_missing() {
1089        let c = make_config(vec![]);
1090        assert!(c.get("missing").is_none());
1091    }
1092
1093    #[test]
1094    fn get_string_returns_string() {
1095        let c = make_config(vec![("host", sv("localhost"))]);
1096        assert_eq!(c.get_string("host").unwrap(), "localhost");
1097    }
1098
1099    #[test]
1100    fn get_string_coerces_int() {
1101        let c = make_config(vec![("port", iv(8080))]);
1102        assert_eq!(c.get_string("port").unwrap(), "8080");
1103    }
1104
1105    #[test]
1106    fn get_string_coerces_float() {
1107        let c = make_config(vec![("ratio", fv(2.72))]);
1108        // f64::to_string may produce "2.72" or similar; just check it parses back
1109        let s = c.get_string("ratio").unwrap();
1110        let v: f64 = s.parse().unwrap();
1111        assert!((v - 2.72).abs() < 1e-10);
1112    }
1113
1114    #[test]
1115    fn get_string_coerces_bool() {
1116        let c = make_config(vec![("flag", bv(true))]);
1117        assert_eq!(c.get_string("flag").unwrap(), "true");
1118    }
1119
1120    #[test]
1121    fn get_string_error_on_null() {
1122        // Spec L1252: null → any non-null type is an error.
1123        let c = make_config(vec![("v", HoconValue::Scalar(ScalarValue::null()))]);
1124        assert!(c.get_string("v").is_err());
1125    }
1126
1127    #[test]
1128    fn get_string_error_on_object() {
1129        let mut inner = IndexMap::new();
1130        inner.insert("x".into(), iv(1));
1131        let c = make_config(vec![("obj", HoconValue::Object(inner))]);
1132        assert!(c.get_string("obj").is_err());
1133    }
1134
1135    #[test]
1136    fn get_i64_returns_number() {
1137        let c = make_config(vec![("port", iv(8080))]);
1138        assert_eq!(c.get_i64("port").unwrap(), 8080);
1139    }
1140
1141    #[test]
1142    fn get_i64_coerces_numeric_string() {
1143        let c = make_config(vec![("port", sv("9999"))]);
1144        assert_eq!(c.get_i64("port").unwrap(), 9999);
1145    }
1146
1147    #[test]
1148    fn get_i64_error_on_non_numeric() {
1149        let c = make_config(vec![("host", sv("localhost"))]);
1150        assert!(c.get_i64("host").is_err());
1151    }
1152
1153    #[test]
1154    fn get_i64_error_on_overflow() {
1155        // "1e20" parses as f64 but overflows i64 range
1156        let c = make_config(vec![("big", sv("1e20"))]);
1157        assert!(c.get_i64("big").is_err());
1158    }
1159
1160    #[test]
1161    fn get_i64_error_on_i64_max_plus_one() {
1162        // 9223372036854775808 == i64::MAX + 1, parses as f64 but must not saturate
1163        let c = make_config(vec![("big", sv("9223372036854775808"))]);
1164        assert!(c.get_i64("big").is_err());
1165    }
1166
1167    #[test]
1168    fn get_f64_returns_float() {
1169        let c = make_config(vec![("rate", fv(2.72))]);
1170        assert!((c.get_f64("rate").unwrap() - 2.72).abs() < f64::EPSILON);
1171    }
1172
1173    #[test]
1174    fn get_f64_coerces_numeric_string() {
1175        let c = make_config(vec![("rate", sv("2.72"))]);
1176        assert!((c.get_f64("rate").unwrap() - 2.72).abs() < f64::EPSILON);
1177    }
1178
1179    #[test]
1180    fn get_bool_returns_bool() {
1181        let c = make_config(vec![("debug", bv(true))]);
1182        assert!(c.get_bool("debug").unwrap());
1183    }
1184
1185    #[test]
1186    fn get_bool_coerces_string_true() {
1187        let c = make_config(vec![("debug", sv("true"))]);
1188        assert!(c.get_bool("debug").unwrap());
1189    }
1190
1191    #[test]
1192    fn get_bool_coerces_string_false() {
1193        let c = make_config(vec![("debug", sv("false"))]);
1194        assert!(!c.get_bool("debug").unwrap());
1195    }
1196
1197    #[test]
1198    fn get_bool_coerces_yes_no_on_off() {
1199        let c1 = make_config(vec![("v", sv("yes"))]);
1200        assert!(c1.get_bool("v").unwrap());
1201        let c2 = make_config(vec![("v", sv("no"))]);
1202        assert!(!c2.get_bool("v").unwrap());
1203        let c3 = make_config(vec![("v", sv("on"))]);
1204        assert!(c3.get_bool("v").unwrap());
1205        let c4 = make_config(vec![("v", sv("off"))]);
1206        assert!(!c4.get_bool("v").unwrap());
1207    }
1208
1209    #[test]
1210    fn get_bool_is_case_insensitive() {
1211        let c = make_config(vec![("v", sv("TRUE"))]);
1212        assert!(c.get_bool("v").unwrap());
1213        let c2 = make_config(vec![("v", sv("Off"))]);
1214        assert!(!c2.get_bool("v").unwrap());
1215    }
1216
1217    #[test]
1218    fn get_bool_error_on_non_boolean() {
1219        let c = make_config(vec![("v", sv("maybe"))]);
1220        assert!(c.get_bool("v").is_err());
1221    }
1222
1223    #[test]
1224    fn has_returns_true_for_existing() {
1225        let c = make_config(vec![("host", sv("localhost"))]);
1226        assert!(c.has("host"));
1227    }
1228
1229    #[test]
1230    fn has_returns_false_for_missing() {
1231        let c = make_config(vec![]);
1232        assert!(!c.has("missing"));
1233    }
1234
1235    #[test]
1236    fn keys_returns_in_order() {
1237        let c = make_config(vec![("b", iv(2)), ("a", iv(1))]);
1238        assert_eq!(c.keys(), vec!["b", "a"]);
1239    }
1240
1241    #[test]
1242    fn get_nested_dot_path() {
1243        let mut inner = IndexMap::new();
1244        inner.insert("host".into(), sv("localhost"));
1245        let c = make_config(vec![("server", HoconValue::Object(inner))]);
1246        assert_eq!(c.get_string("server.host").unwrap(), "localhost");
1247    }
1248
1249    #[test]
1250    fn get_config_returns_sub_config() {
1251        let mut inner = IndexMap::new();
1252        inner.insert("host".into(), sv("localhost"));
1253        let c = make_config(vec![("server", HoconValue::Object(inner))]);
1254        let sub = c.get_config("server").unwrap();
1255        assert_eq!(sub.get_string("host").unwrap(), "localhost");
1256    }
1257
1258    #[test]
1259    fn get_list_returns_array() {
1260        let items = vec![iv(1), iv(2), iv(3)];
1261        let c = make_config(vec![("list", HoconValue::Array(items))]);
1262        let list = c.get_list("list").unwrap();
1263        assert_eq!(list.len(), 3);
1264    }
1265
1266    #[test]
1267    fn with_fallback_receiver_wins() {
1268        let c1 = make_config(vec![("host", sv("prod"))]);
1269        let c2 = make_config(vec![("host", sv("dev")), ("port", iv(8080))]);
1270        let merged = c1.with_fallback(&c2);
1271        assert_eq!(merged.get_string("host").unwrap(), "prod");
1272        assert_eq!(merged.get_i64("port").unwrap(), 8080);
1273    }
1274
1275    #[test]
1276    fn option_variants_return_none_on_missing() {
1277        let c = make_config(vec![]);
1278        assert!(c.get_string_option("x").is_none());
1279        assert!(c.get_i64_option("x").is_none());
1280        assert!(c.get_f64_option("x").is_none());
1281        assert!(c.get_bool_option("x").is_none());
1282    }
1283
1284    #[test]
1285    fn get_duration_nanoseconds() {
1286        let c = make_config(vec![("t", sv("100 ns"))]);
1287        assert_eq!(
1288            c.get_duration("t").unwrap(),
1289            std::time::Duration::from_nanos(100)
1290        );
1291    }
1292
1293    #[test]
1294    fn get_duration_milliseconds() {
1295        let c = make_config(vec![("t", sv("500 ms"))]);
1296        assert_eq!(
1297            c.get_duration("t").unwrap(),
1298            std::time::Duration::from_millis(500)
1299        );
1300    }
1301
1302    #[test]
1303    fn get_duration_seconds() {
1304        let c = make_config(vec![("t", sv("30 seconds"))]);
1305        assert_eq!(
1306            c.get_duration("t").unwrap(),
1307            std::time::Duration::from_secs(30)
1308        );
1309    }
1310
1311    #[test]
1312    fn get_duration_minutes() {
1313        let c = make_config(vec![("t", sv("5 m"))]);
1314        assert_eq!(
1315            c.get_duration("t").unwrap(),
1316            std::time::Duration::from_secs(300)
1317        );
1318    }
1319
1320    #[test]
1321    fn get_duration_hours() {
1322        let c = make_config(vec![("t", sv("2 hours"))]);
1323        assert_eq!(
1324            c.get_duration("t").unwrap(),
1325            std::time::Duration::from_secs(7200)
1326        );
1327    }
1328
1329    #[test]
1330    fn get_duration_days() {
1331        let c = make_config(vec![("t", sv("1 d"))]);
1332        assert_eq!(
1333            c.get_duration("t").unwrap(),
1334            std::time::Duration::from_secs(86400)
1335        );
1336    }
1337
1338    #[test]
1339    fn get_duration_fractional() {
1340        let c = make_config(vec![("t", sv("1.5 hours"))]);
1341        assert_eq!(
1342            c.get_duration("t").unwrap(),
1343            std::time::Duration::from_secs(5400)
1344        );
1345    }
1346
1347    #[test]
1348    fn get_duration_no_space() {
1349        let c = make_config(vec![("t", sv("100ms"))]);
1350        assert_eq!(
1351            c.get_duration("t").unwrap(),
1352            std::time::Duration::from_millis(100)
1353        );
1354    }
1355
1356    #[test]
1357    fn get_duration_singular_unit() {
1358        let c = make_config(vec![("t", sv("1 second"))]);
1359        assert_eq!(
1360            c.get_duration("t").unwrap(),
1361            std::time::Duration::from_secs(1)
1362        );
1363    }
1364
1365    #[test]
1366    fn get_duration_error_invalid_unit() {
1367        let c = make_config(vec![("t", sv("100 foos"))]);
1368        assert!(c.get_duration("t").is_err());
1369    }
1370
1371    #[test]
1372    fn get_duration_option_missing() {
1373        let c = make_config(vec![]);
1374        assert!(c.get_duration_option("t").is_none());
1375    }
1376
1377    #[test]
1378    fn get_bytes_plain() {
1379        let c = make_config(vec![("s", sv("100 B"))]);
1380        assert_eq!(c.get_bytes("s").unwrap(), 100);
1381    }
1382
1383    #[test]
1384    fn get_bytes_kilobytes() {
1385        let c = make_config(vec![("s", sv("10 KB"))]);
1386        assert_eq!(c.get_bytes("s").unwrap(), 10_000);
1387    }
1388
1389    #[test]
1390    fn get_bytes_kibibytes() {
1391        let c = make_config(vec![("s", sv("1 KiB"))]);
1392        assert_eq!(c.get_bytes("s").unwrap(), 1_024);
1393    }
1394
1395    #[test]
1396    fn get_bytes_megabytes() {
1397        let c = make_config(vec![("s", sv("5 MB"))]);
1398        assert_eq!(c.get_bytes("s").unwrap(), 5_000_000);
1399    }
1400
1401    #[test]
1402    fn get_bytes_mebibytes() {
1403        let c = make_config(vec![("s", sv("1 MiB"))]);
1404        assert_eq!(c.get_bytes("s").unwrap(), 1_048_576);
1405    }
1406
1407    #[test]
1408    fn get_bytes_gigabytes() {
1409        let c = make_config(vec![("s", sv("2 GB"))]);
1410        assert_eq!(c.get_bytes("s").unwrap(), 2_000_000_000);
1411    }
1412
1413    #[test]
1414    fn get_bytes_gibibytes() {
1415        let c = make_config(vec![("s", sv("1 GiB"))]);
1416        assert_eq!(c.get_bytes("s").unwrap(), 1_073_741_824);
1417    }
1418
1419    #[test]
1420    fn get_bytes_terabytes() {
1421        let c = make_config(vec![("s", sv("1 TB"))]);
1422        assert_eq!(c.get_bytes("s").unwrap(), 1_000_000_000_000);
1423    }
1424
1425    #[test]
1426    fn get_bytes_tebibytes() {
1427        let c = make_config(vec![("s", sv("1 TiB"))]);
1428        assert_eq!(c.get_bytes("s").unwrap(), 1_099_511_627_776);
1429    }
1430
1431    #[test]
1432    fn get_bytes_no_space() {
1433        let c = make_config(vec![("s", sv("512MB"))]);
1434        assert_eq!(c.get_bytes("s").unwrap(), 512_000_000);
1435    }
1436
1437    #[test]
1438    fn get_bytes_long_unit() {
1439        let c = make_config(vec![("s", sv("2 megabytes"))]);
1440        assert_eq!(c.get_bytes("s").unwrap(), 2_000_000);
1441    }
1442
1443    #[test]
1444    fn get_bytes_error_invalid_unit() {
1445        let c = make_config(vec![("s", sv("100 XB"))]);
1446        assert!(c.get_bytes("s").is_err());
1447    }
1448
1449    #[test]
1450    fn get_bytes_option_missing() {
1451        let c = make_config(vec![]);
1452        assert!(c.get_bytes_option("s").is_none());
1453    }
1454
1455    #[test]
1456    fn get_bytes_fractional_rounds() {
1457        // 1.5 KiB = 1536 bytes exactly; rounding should not change it
1458        let c = make_config(vec![("s", sv("1.5 KiB"))]);
1459        assert_eq!(c.get_bytes("s").unwrap(), 1536);
1460    }
1461
1462    // ──────────────────────────────────────────────────────────────
1463    // Unit B — parse_duration bare/fractional/ws tests (RED phase)
1464    // ──────────────────────────────────────────────────────────────
1465
1466    #[test]
1467    fn parse_duration_bare_integer_uses_ms_default() {
1468        assert_eq!(
1469            parse_duration("500"),
1470            Some(std::time::Duration::from_millis(500))
1471        );
1472    }
1473    #[test]
1474    fn parse_duration_leading_ws_bare() {
1475        assert_eq!(
1476            parse_duration(" 500"),
1477            Some(std::time::Duration::from_millis(500))
1478        );
1479    }
1480    #[test]
1481    fn parse_duration_trailing_ws_bare() {
1482        assert_eq!(
1483            parse_duration("500 "),
1484            Some(std::time::Duration::from_millis(500))
1485        );
1486    }
1487    #[test]
1488    fn parse_duration_both_ws_bare() {
1489        assert_eq!(
1490            parse_duration(" 500 "),
1491            Some(std::time::Duration::from_millis(500))
1492        );
1493    }
1494    #[test]
1495    fn parse_duration_fractional_bare_uses_nanos() {
1496        let d = parse_duration("500.5").unwrap();
1497        assert_eq!(d.as_nanos(), 500_500_000);
1498    }
1499    #[test]
1500    fn parse_duration_empty_is_none() {
1501        assert!(parse_duration("").is_none());
1502    }
1503    #[test]
1504    fn parse_duration_ws_only_is_none() {
1505        assert!(parse_duration("   ").is_none());
1506    }
1507    #[test]
1508    fn parse_duration_unit_only_is_none() {
1509        assert!(parse_duration("ms").is_none());
1510    }
1511
1512    // ──────────────────────────────────────────────────────────────
1513    // Issue #95 — parse_duration overflow guards (integer + fractional)
1514    //
1515    // Pre-fix: `(n as f64 * nanos_per_unit) as u64` silently saturated for
1516    // large n; `(f * nanos_per_unit) as u64` silently saturated for fractional
1517    // overflow. Lightbend errors in both cases — we now match.
1518    // ──────────────────────────────────────────────────────────────
1519
1520    #[test]
1521    fn parse_duration_integer_overflow_weeks_is_none() {
1522        // i64::MAX weeks would require ~5.6e33 nanos, far past u64::MAX.
1523        assert!(parse_duration("9223372036854775807 weeks").is_none());
1524    }
1525
1526    #[test]
1527    fn parse_duration_integer_overflow_days_is_none() {
1528        // i64::MAX days × 86_400_000_000_000 ns/day overflows u64 (2^64 ≈ 1.8e19).
1529        assert!(parse_duration("9223372036854775807 days").is_none());
1530    }
1531
1532    #[test]
1533    fn parse_duration_integer_max_u64_nanos_succeeds() {
1534        // u64::MAX nanos should be representable (boundary success).
1535        let d = parse_duration("18446744073709551615ns").unwrap();
1536        assert_eq!(d.as_nanos(), u64::MAX as u128);
1537    }
1538
1539    #[test]
1540    fn parse_duration_fractional_overflow_is_none() {
1541        // 1e30 days is wildly past u64::MAX nanos.
1542        assert!(parse_duration("1e30 d").is_none());
1543    }
1544
1545    #[test]
1546    fn parse_duration_fractional_above_u64_max_is_none() {
1547        // 2^64 nanos exactly — boundary just past representable range.
1548        // f64 rounds u64::MAX up to 2^64, so the strict-< check at 2^64 catches both.
1549        assert!(parse_duration("18446744073709551616ns").is_none());
1550    }
1551
1552    #[test]
1553    fn parse_duration_fractional_succeeds_below_boundary() {
1554        // 1.5 weeks = ~907_200_000_000_000 ns, well within u64.
1555        let d = parse_duration("1.5w").unwrap();
1556        assert_eq!(d.as_nanos(), 907_200_000_000_000u128);
1557    }
1558
1559    // ──────────────────────────────────────────────────────────────
1560    // Unit C — parse_bytes ws / truncate / negative-accessor (RED)
1561    // ──────────────────────────────────────────────────────────────
1562
1563    #[test]
1564    fn parse_bytes_leading_trailing_ws_bare() {
1565        assert_eq!(parse_bytes(" 1024 "), Some(1024));
1566    }
1567    #[test]
1568    fn parse_bytes_fractional_truncated() {
1569        assert_eq!(parse_bytes("1024.5"), Some(1024));
1570    }
1571    #[test]
1572    fn get_bytes_negative_accessor_rejects() {
1573        use std::collections::HashMap;
1574        let cfg = crate::parse_with_env(r#"b = "-1""#, &HashMap::new()).unwrap();
1575        assert!(
1576            cfg.get_bytes("b").is_err(),
1577            "ub04: negative byte size must error at accessor (string path)"
1578        );
1579    }
1580    #[test]
1581    fn get_bytes_negative_bare_number_rejects() {
1582        use std::collections::HashMap;
1583        // b = -1 (unquoted number scalar) — previously bypassed the guard and returned Ok(-1).
1584        let cfg = crate::parse_with_env(r#"b = -1"#, &HashMap::new()).unwrap();
1585        assert!(
1586            cfg.get_bytes("b").is_err(),
1587            "ub04-bare: bare numeric -1 must error at accessor (both paths must hit guard)"
1588        );
1589    }
1590    #[test]
1591    fn get_bytes_option_negative_bare_number_is_none() {
1592        use std::collections::HashMap;
1593        let cfg = crate::parse_with_env(r#"b = -1"#, &HashMap::new()).unwrap();
1594        assert!(
1595            cfg.get_bytes_option("b").is_none(),
1596            "ub04-bare-option: get_bytes_option must return None for bare numeric -1"
1597        );
1598    }
1599
1600    // ──────────────────────────────────────────────────────────────
1601    // Unit D — parse_period (RED phase)
1602    // ──────────────────────────────────────────────────────────────
1603
1604    #[test]
1605    fn parse_period_bare_integer_uses_days_default() {
1606        assert_eq!(parse_period("7"), Some((0, 0, 7)));
1607    }
1608    #[test]
1609    fn parse_period_leading_trailing_ws() {
1610        assert_eq!(parse_period(" 7 "), Some((0, 0, 7)));
1611    }
1612    #[test]
1613    fn parse_period_fractional_rejected() {
1614        assert!(parse_period("7.5").is_none());
1615    }
1616    #[test]
1617    fn parse_period_negative_allowed() {
1618        assert_eq!(parse_period("-7"), Some((0, 0, -7)));
1619    }
1620    #[test]
1621    fn parse_period_weeks_unit() {
1622        assert_eq!(parse_period("7w"), Some((0, 0, 49)));
1623    }
1624    #[test]
1625    fn parse_period_months_unit() {
1626        assert_eq!(parse_period("3m"), Some((0, 3, 0)));
1627    }
1628    #[test]
1629    fn parse_period_years_unit() {
1630        assert_eq!(parse_period("2y"), Some((2, 0, 0)));
1631    }
1632    #[test]
1633    fn parse_period_days_explicit() {
1634        assert_eq!(parse_period("5d"), Some((0, 0, 5)));
1635    }
1636    #[test]
1637    fn parse_period_empty_is_none() {
1638        assert!(parse_period("").is_none());
1639    }
1640
1641    #[test]
1642    fn split_config_path_consecutive_dots_preserve_empty() {
1643        let segs = split_config_path("a..b");
1644        assert_eq!(segs, vec!["a", "", "b"]);
1645    }
1646
1647    #[test]
1648    fn split_config_path_trailing_dot_empty_segment() {
1649        let segs = split_config_path("a.b.");
1650        assert_eq!(segs, vec!["a", "b", ""]);
1651    }
1652
1653    #[test]
1654    fn split_config_path_quoted_escape() {
1655        // "a\"b" as a path key should produce the key: a"b
1656        let segs = split_config_path(r#""a\"b""#);
1657        assert_eq!(segs, vec!["a\"b"]);
1658    }
1659
1660    #[test]
1661    fn split_config_path_quoted_with_dot() {
1662        let segs = split_config_path(r#"server."web.api".port"#);
1663        assert_eq!(segs, vec!["server", "web.api", "port"]);
1664    }
1665}