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