Skip to main content

hocon/
config.rs

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