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