Skip to main content

tier/loader/
mod.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::ffi::OsStr;
4use std::fmt::{self, Display, Formatter};
5use std::net::IpAddr;
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, OnceLock};
8
9use regex::Regex;
10use serde::Serialize;
11use serde::de::{
12    DeserializeOwned, IntoDeserializer, MapAccess, SeqAccess, Visitor,
13    value::{Error as ValueDeError, MapAccessDeserializer},
14};
15use serde_json::{Map, Value};
16
17#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
18use crate::error::LineColumn;
19use crate::error::{ConfigError, UnknownField, ValidationErrors};
20#[cfg(feature = "schema")]
21use crate::export::{json_pretty, json_value};
22use crate::patch::DeferredPatchLayer;
23use crate::report::{
24    AppliedMigration, ConfigReport, ConfigWarning, DeprecatedField, ResolutionStep,
25    canonicalize_path_with_aliases, collect_diff_paths, collect_paths, get_value_at_path,
26    join_path, normalize_path, path_matches_pattern, path_overlaps_pattern,
27    path_starts_with_pattern, redact_value,
28};
29use crate::{ConfigMetadata, EnvDecoder, MergeStrategy, TierMetadata, TierPatch};
30
31mod canonical;
32mod de;
33mod env;
34mod load;
35mod merge;
36mod overrides;
37mod path;
38mod policy;
39mod unknown;
40mod validation;
41
42use self::canonical::*;
43use self::de::deserialize_with_path;
44use self::merge::*;
45use self::overrides::*;
46use self::path::*;
47use self::policy::enforce_source_policies;
48use self::unknown::*;
49use self::validation::{validate_declared_checks, validate_declared_rules};
50
51pub(crate) use self::de::insert_path;
52pub(crate) use self::load::is_secret_path;
53pub(crate) use self::path::record_direct_array_state;
54
55type Normalizer<T> = Box<dyn Fn(&mut T) -> Result<(), String> + Send + Sync>;
56type Validator<T> = Box<dyn Fn(&T) -> Result<(), ValidationErrors> + Send + Sync>;
57type CustomEnvDecoder = Arc<dyn Fn(&str) -> Result<Value, String> + Send + Sync>;
58
59#[derive(
60    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
61)]
62#[serde(rename_all = "snake_case")]
63/// Kind of source that contributed configuration values.
64pub enum SourceKind {
65    /// Values originating from in-code defaults.
66    Default,
67    /// Values originating from configuration files.
68    File,
69    /// Values originating from environment variables.
70    Environment,
71    /// Values originating from CLI overrides.
72    Arguments,
73    /// Values originating from normalization hooks.
74    Normalization,
75    /// Values originating from a custom layer.
76    Custom,
77}
78
79impl Display for SourceKind {
80    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::Default => write!(f, "default"),
83            Self::File => write!(f, "file"),
84            Self::Environment => write!(f, "env"),
85            Self::Arguments => write!(f, "cli"),
86            Self::Normalization => write!(f, "normalize"),
87            Self::Custom => write!(f, "custom"),
88        }
89    }
90}
91
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93/// Policy applied when unknown configuration paths are discovered.
94pub enum UnknownFieldPolicy {
95    /// Accept unknown fields silently.
96    Allow,
97    /// Accept unknown fields but emit warnings.
98    Warn,
99    #[default]
100    /// Reject unknown fields with an error.
101    Deny,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
105/// Migration action applied when upgrading older configuration payloads.
106pub enum ConfigMigrationKind {
107    /// Renames one configuration path to another.
108    Rename {
109        /// Original path used by older configs.
110        from: String,
111        /// Replacement path used by newer configs.
112        to: String,
113    },
114    /// Removes a configuration path that is no longer supported.
115    Remove {
116        /// Path removed from newer configs.
117        path: String,
118    },
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
122/// Declarative migration rule applied to loaded configuration values.
123pub struct ConfigMigration {
124    /// Version introduced by this migration rule.
125    pub since_version: u32,
126    /// Concrete migration action.
127    pub kind: ConfigMigrationKind,
128    /// Optional operator-facing migration note.
129    pub note: Option<String>,
130}
131
132impl ConfigMigration {
133    /// Creates a rename migration from `from` to `to`.
134    #[must_use]
135    pub fn rename(from: impl Into<String>, to: impl Into<String>, since_version: u32) -> Self {
136        Self {
137            since_version,
138            kind: ConfigMigrationKind::Rename {
139                from: from.into(),
140                to: to.into(),
141            },
142            note: None,
143        }
144    }
145
146    /// Creates a removal migration for `path`.
147    #[must_use]
148    pub fn remove(path: impl Into<String>, since_version: u32) -> Self {
149        Self {
150            since_version,
151            kind: ConfigMigrationKind::Remove { path: path.into() },
152            note: None,
153        }
154    }
155
156    /// Attaches an operator-facing migration note.
157    #[must_use]
158    pub fn with_note(mut self, note: impl Into<String>) -> Self {
159        self.note = Some(note.into());
160        self
161    }
162}
163
164impl Display for UnknownFieldPolicy {
165    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
166        match self {
167            Self::Allow => write!(f, "allow"),
168            Self::Warn => write!(f, "warn"),
169            Self::Deny => write!(f, "deny"),
170        }
171    }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
175/// Human-readable description of where a configuration value came from.
176pub struct SourceTrace {
177    /// High-level source category.
178    pub kind: SourceKind,
179    /// Source name, such as a file path or environment variable name.
180    pub name: String,
181    /// Optional location inside the source, when available.
182    pub location: Option<String>,
183}
184
185impl SourceTrace {
186    fn new(kind: SourceKind, name: impl Into<String>) -> Self {
187        Self {
188            kind,
189            name: name.into(),
190            location: None,
191        }
192    }
193}
194
195impl Display for SourceTrace {
196    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
197        match &self.location {
198            Some(location) if self.name.is_empty() => write!(f, "{}({location})", self.kind),
199            Some(location) => write!(f, "{}({}:{location})", self.kind, self.name),
200            None if self.name.is_empty() => write!(f, "{}", self.kind),
201            None => write!(f, "{}({})", self.kind, self.name),
202        }
203    }
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207/// Supported on-disk configuration file formats.
208pub enum FileFormat {
209    /// JSON source file.
210    Json,
211    /// TOML source file.
212    Toml,
213    /// YAML source file.
214    Yaml,
215}
216
217impl Display for FileFormat {
218    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
219        match self {
220            Self::Json => write!(f, "json"),
221            Self::Toml => write!(f, "toml"),
222            Self::Yaml => write!(f, "yaml"),
223        }
224    }
225}
226
227#[derive(Debug, Clone)]
228/// File-backed configuration source definition.
229///
230/// `FileSource` is useful when you need more control than
231/// [`ConfigLoader::file`] or [`ConfigLoader::optional_file`] provide, such as
232/// candidate-path search or explicit format selection.
233///
234/// # Examples
235///
236/// ```no_run
237/// use serde::{Deserialize, Serialize};
238/// use tier::{ConfigLoader, FileFormat, FileSource};
239///
240/// #[derive(Debug, Clone, Serialize, Deserialize)]
241/// struct AppConfig {
242///     port: u16,
243/// }
244///
245/// impl Default for AppConfig {
246///     fn default() -> Self {
247///         Self { port: 3000 }
248///     }
249/// }
250///
251/// let loaded = ConfigLoader::new(AppConfig::default())
252///     .with_file(
253///         FileSource::search(["config/local", "config/default.toml"]).format(FileFormat::Toml),
254///     )
255///     .load()?;
256///
257/// assert!(loaded.port >= 1);
258/// # Ok::<(), tier::ConfigError>(())
259/// ```
260pub struct FileSource {
261    candidates: Vec<PathBuf>,
262    required: bool,
263    format: Option<FileFormat>,
264}
265
266impl FileSource {
267    /// Creates a required file source for a single path.
268    #[must_use]
269    pub fn new(path: impl Into<PathBuf>) -> Self {
270        Self {
271            candidates: vec![path.into()],
272            required: true,
273            format: None,
274        }
275    }
276
277    /// Creates an optional file source for a single path.
278    #[must_use]
279    pub fn optional(path: impl Into<PathBuf>) -> Self {
280        Self {
281            candidates: vec![path.into()],
282            required: false,
283            format: None,
284        }
285    }
286
287    /// Creates a required file source that searches candidate paths in order.
288    #[must_use]
289    pub fn search<I, P>(paths: I) -> Self
290    where
291        I: IntoIterator<Item = P>,
292        P: Into<PathBuf>,
293    {
294        Self {
295            candidates: paths.into_iter().map(Into::into).collect(),
296            required: true,
297            format: None,
298        }
299    }
300
301    /// Creates an optional file source that searches candidate paths in order.
302    #[must_use]
303    pub fn optional_search<I, P>(paths: I) -> Self
304    where
305        I: IntoIterator<Item = P>,
306        P: Into<PathBuf>,
307    {
308        Self {
309            candidates: paths.into_iter().map(Into::into).collect(),
310            required: false,
311            format: None,
312        }
313    }
314
315    /// Returns configured candidate paths in priority order.
316    #[must_use]
317    pub fn candidates(&self) -> &[PathBuf] {
318        &self.candidates
319    }
320
321    /// Overrides format inference with an explicit file format.
322    #[must_use]
323    pub fn format(mut self, format: FileFormat) -> Self {
324        self.format = Some(format);
325        self
326    }
327}
328
329#[derive(Debug, Clone)]
330/// Environment variable source definition.
331///
332/// Use `EnvSource` when environment variables should participate in the same
333/// layered pipeline as defaults and files.
334///
335/// # Examples
336///
337/// ```
338/// use serde::{Deserialize, Serialize};
339/// use tier::{ConfigLoader, EnvSource};
340///
341/// #[derive(Debug, Clone, Serialize, Deserialize)]
342/// struct AppConfig {
343///     server: ServerConfig,
344/// }
345///
346/// #[derive(Debug, Clone, Serialize, Deserialize)]
347/// struct ServerConfig {
348///     port: u16,
349/// }
350///
351/// impl Default for AppConfig {
352///     fn default() -> Self {
353///         Self {
354///             server: ServerConfig { port: 3000 },
355///         }
356///     }
357/// }
358///
359/// let loaded = ConfigLoader::new(AppConfig::default())
360///     .env(EnvSource::from_pairs([("APP__SERVER__PORT", "7000")]).prefix("APP"))
361///     .load()?;
362///
363/// assert_eq!(loaded.server.port, 7000);
364/// # Ok::<(), tier::ConfigError>(())
365/// ```
366pub struct EnvSource {
367    vars: BTreeMap<String, String>,
368    prefix: Option<String>,
369    separator: String,
370    lowercase_segments: bool,
371    bindings: BTreeMap<String, EnvBinding>,
372    binding_conflicts: Vec<EnvBindingConflict>,
373}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
376struct EnvBinding {
377    path: String,
378    decoder: Option<EnvDecoder>,
379    fallback: bool,
380}
381
382#[derive(Debug, Clone)]
383struct EnvBindingConflict {
384    name: String,
385    first: EnvBinding,
386    second: EnvBinding,
387}
388
389impl EnvSource {
390    /// Captures the current process environment.
391    #[must_use]
392    pub fn from_env() -> Self {
393        Self::from_pairs(std::env::vars())
394    }
395
396    /// Captures the current process environment using a prefix filter.
397    #[must_use]
398    pub fn prefixed(prefix: impl Into<String>) -> Self {
399        Self::from_env().prefix(prefix)
400    }
401
402    /// Creates an environment source from explicit key/value pairs.
403    #[must_use]
404    pub fn from_pairs<I, K, V>(iter: I) -> Self
405    where
406        I: IntoIterator<Item = (K, V)>,
407        K: Into<String>,
408        V: Into<String>,
409    {
410        let vars = iter
411            .into_iter()
412            .map(|(key, value)| (key.into(), value.into()))
413            .collect();
414        Self {
415            vars,
416            prefix: None,
417            separator: "__".to_owned(),
418            lowercase_segments: true,
419            bindings: BTreeMap::new(),
420            binding_conflicts: Vec::new(),
421        }
422    }
423
424    /// Sets an environment variable prefix filter.
425    #[must_use]
426    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
427        self.prefix = Some(prefix.into());
428        self
429    }
430
431    /// Sets the segment separator used to map variables to paths.
432    #[must_use]
433    pub fn separator(mut self, separator: impl Into<String>) -> Self {
434        let separator = separator.into();
435        if !separator.is_empty() {
436            self.separator = separator;
437        }
438        self
439    }
440
441    /// Preserves segment case instead of lowercasing them.
442    #[must_use]
443    pub fn preserve_case(mut self) -> Self {
444        self.lowercase_segments = false;
445        self
446    }
447
448    /// Maps an explicit environment variable name to a configuration path.
449    ///
450    /// This is useful for compatibility with standard operational variables
451    /// such as `HTTP_PROXY` alongside application-scoped names.
452    #[must_use]
453    pub fn with_alias(mut self, name: impl Into<String>, path: impl Into<String>) -> Self {
454        self.insert_binding(
455            name.into(),
456            EnvBinding {
457                path: path.into(),
458                decoder: None,
459                fallback: false,
460            },
461        );
462        self
463    }
464
465    /// Maps an explicit environment variable name to a configuration path and
466    /// decodes it with a built-in env decoder.
467    #[must_use]
468    pub fn with_alias_decoder(
469        mut self,
470        name: impl Into<String>,
471        path: impl Into<String>,
472        decoder: EnvDecoder,
473    ) -> Self {
474        self.insert_binding(
475            name.into(),
476            EnvBinding {
477                path: path.into(),
478                decoder: Some(decoder),
479                fallback: false,
480            },
481        );
482        self
483    }
484
485    /// Registers a lower-priority compatibility env mapping for a path.
486    ///
487    /// Fallback env names only apply when the same configuration path was not
488    /// already written by a more specific env binding from this source.
489    #[must_use]
490    pub fn with_fallback(mut self, name: impl Into<String>, path: impl Into<String>) -> Self {
491        self.insert_binding(
492            name.into(),
493            EnvBinding {
494                path: path.into(),
495                decoder: None,
496                fallback: true,
497            },
498        );
499        self
500    }
501
502    /// Registers a lower-priority compatibility env mapping with a built-in
503    /// decoder for structured values such as `NO_PROXY`.
504    #[must_use]
505    pub fn with_fallback_decoder(
506        mut self,
507        name: impl Into<String>,
508        path: impl Into<String>,
509        decoder: EnvDecoder,
510    ) -> Self {
511        self.insert_binding(
512            name.into(),
513            EnvBinding {
514                path: path.into(),
515                decoder: Some(decoder),
516                fallback: true,
517            },
518        );
519        self
520    }
521
522    fn insert_binding(&mut self, name: String, binding: EnvBinding) {
523        if let Some(existing) = self.bindings.get(&name) {
524            if existing != &binding {
525                self.binding_conflicts.push(EnvBindingConflict {
526                    name: name.clone(),
527                    first: existing.clone(),
528                    second: binding,
529                });
530            }
531            return;
532        }
533
534        self.bindings.insert(name, binding);
535    }
536}
537
538#[derive(Debug, Clone)]
539/// CLI override source definition.
540///
541/// `ArgsSource` parses the same `--config`, `--profile`, and `--set key=value`
542/// flags that `tier` accepts through its reusable `clap` integration.
543///
544/// # Examples
545///
546/// ```
547/// use serde::{Deserialize, Serialize};
548/// use tier::{ArgsSource, ConfigLoader};
549///
550/// #[derive(Debug, Clone, Serialize, Deserialize)]
551/// struct AppConfig {
552///     port: u16,
553/// }
554///
555/// impl Default for AppConfig {
556///     fn default() -> Self {
557///         Self { port: 3000 }
558///     }
559/// }
560///
561/// let loaded = ConfigLoader::new(AppConfig::default())
562///     .args(ArgsSource::from_args(["app", "--set", "port=7000"]))
563///     .load()?;
564///
565/// assert_eq!(loaded.port, 7000);
566/// # Ok::<(), tier::ConfigError>(())
567/// ```
568pub struct ArgsSource {
569    args: Vec<String>,
570}
571
572impl ArgsSource {
573    /// Captures the current process arguments.
574    #[must_use]
575    pub fn from_env() -> Self {
576        Self::from_args(std::env::args())
577    }
578
579    /// Creates an argument source from explicit argv values.
580    #[must_use]
581    pub fn from_args<I, S>(iter: I) -> Self
582    where
583        I: IntoIterator<Item = S>,
584        S: Into<String>,
585    {
586        Self {
587            args: iter.into_iter().map(Into::into).collect(),
588        }
589    }
590}
591
592#[derive(Debug, Clone)]
593/// Custom serializable configuration layer.
594pub struct Layer {
595    trace: SourceTrace,
596    value: Value,
597    entries: BTreeMap<String, SourceTrace>,
598    coercible_string_paths: BTreeSet<String>,
599    indexed_array_paths: BTreeSet<String>,
600    indexed_array_base_lengths: BTreeMap<String, usize>,
601    direct_array_paths: BTreeSet<String>,
602}
603
604impl Layer {
605    /// Creates a custom layer from a serializable value.
606    pub fn custom<T>(name: impl Into<String>, value: T) -> Result<Self, ConfigError>
607    where
608        T: Serialize,
609    {
610        Self::from_serializable(SourceTrace::new(SourceKind::Custom, name), value)
611    }
612
613    fn from_serializable<T>(trace: SourceTrace, value: T) -> Result<Self, ConfigError>
614    where
615        T: Serialize,
616    {
617        let value = serde_json::to_value(value)?;
618        Self::from_value(trace, value)
619    }
620
621    fn from_value(trace: SourceTrace, value: Value) -> Result<Self, ConfigError> {
622        ensure_root_object(&value)?;
623        ensure_path_safe_keys(&value, "")?;
624
625        let mut paths = Vec::new();
626        collect_paths(&value, "", &mut paths);
627        let entries = paths
628            .into_iter()
629            .map(|path| (path, trace.clone()))
630            .collect::<BTreeMap<_, _>>();
631
632        Ok(Self {
633            trace,
634            value,
635            entries,
636            coercible_string_paths: BTreeSet::new(),
637            indexed_array_paths: BTreeSet::new(),
638            indexed_array_base_lengths: BTreeMap::new(),
639            direct_array_paths: BTreeSet::new(),
640        })
641    }
642
643    pub(crate) fn from_parts(
644        trace: SourceTrace,
645        value: Value,
646        entries: BTreeMap<String, SourceTrace>,
647        coercible_string_paths: BTreeSet<String>,
648        indexed_array_paths: BTreeSet<String>,
649        indexed_array_base_lengths: BTreeMap<String, usize>,
650        direct_array_paths: BTreeSet<String>,
651    ) -> Self {
652        Self {
653            trace,
654            value,
655            entries,
656            coercible_string_paths,
657            indexed_array_paths,
658            indexed_array_base_lengths,
659            direct_array_paths,
660        }
661    }
662
663    /// Creates a custom configuration layer from a typed sparse patch.
664    ///
665    /// This is the typed alternative to manually building a [`Layer`] from a
666    /// serializable shadow struct.
667    ///
668    /// # Examples
669    ///
670    /// ```no_run
671    /// # #[cfg(feature = "derive")] {
672    /// # fn main() -> Result<(), tier::ConfigError> {
673    /// use tier::{Layer, TierPatch};
674    ///
675    /// #[derive(Debug, TierPatch, Default)]
676    /// struct CliPatch {
677    ///     port: Option<u16>,
678    /// }
679    ///
680    /// let _layer = Layer::from_patch("typed-cli", &CliPatch { port: Some(7000) })?;
681    /// # Ok(())
682    /// # }
683    /// # }
684    /// ```
685    pub fn from_patch<P>(name: impl Into<String>, patch: &P) -> Result<Self, ConfigError>
686    where
687        P: TierPatch,
688    {
689        Self::from_patch_with_trace(
690            SourceTrace {
691                kind: SourceKind::Custom,
692                name: name.into(),
693                location: None,
694            },
695            patch,
696        )
697    }
698
699    pub(crate) fn from_patch_with_trace<P>(
700        trace: SourceTrace,
701        patch: &P,
702    ) -> Result<Self, ConfigError>
703    where
704        P: TierPatch,
705    {
706        let mut builder = crate::patch::PatchLayerBuilder::from_trace(trace);
707        patch.write_layer(&mut builder, "")?;
708        Ok(builder.finish())
709    }
710}
711
712struct NamedNormalizer<T> {
713    name: String,
714    run: Normalizer<T>,
715}
716
717struct NamedValidator<T> {
718    name: String,
719    run: Validator<T>,
720}
721
722enum PendingCustomLayer {
723    Immediate(Layer),
724    DeferredPatch(DeferredPatchLayer),
725}
726
727#[derive(Debug, Clone)]
728struct ParsedArgs {
729    profile: Option<String>,
730    files: Vec<FileSource>,
731    layer: Option<Layer>,
732}
733
734#[derive(Debug)]
735/// Loaded configuration plus its diagnostic report.
736pub struct LoadedConfig<T> {
737    config: T,
738    report: ConfigReport,
739}
740
741impl<T> LoadedConfig<T> {
742    /// Returns the loaded configuration value.
743    #[must_use]
744    pub fn config(&self) -> &T {
745        &self.config
746    }
747
748    /// Returns the diagnostic report associated with the load.
749    #[must_use]
750    pub fn report(&self) -> &ConfigReport {
751        &self.report
752    }
753
754    /// Splits the loaded configuration into its value and report.
755    pub fn into_parts(self) -> (T, ConfigReport) {
756        (self.config, self.report)
757    }
758
759    /// Returns the loaded configuration value, discarding the report.
760    pub fn into_inner(self) -> T {
761        self.config
762    }
763}
764
765impl<T> Clone for LoadedConfig<T>
766where
767    T: Clone,
768{
769    fn clone(&self) -> Self {
770        Self {
771            config: self.config.clone(),
772            report: self.report.clone(),
773        }
774    }
775}
776
777impl<T> std::ops::Deref for LoadedConfig<T> {
778    type Target = T;
779
780    fn deref(&self) -> &Self::Target {
781        &self.config
782    }
783}
784
785#[cfg(feature = "schema")]
786impl<T> LoadedConfig<T>
787where
788    T: Serialize + DeserializeOwned + crate::JsonSchema + crate::TierMetadata,
789{
790    /// Builds a versioned machine-readable export bundle for downstream tools.
791    #[must_use]
792    pub fn export_bundle(&self, options: &crate::EnvDocOptions) -> crate::ExportBundleReport {
793        crate::ExportBundleReport {
794            format_version: crate::EXPORT_BUNDLE_FORMAT_VERSION,
795            doctor: self.report.doctor_report(),
796            audit: self.report.audit_report(),
797            env_docs: crate::env_docs_report::<T>(options),
798            json_schema: crate::json_schema_report::<T>(),
799            annotated_json_schema: crate::annotated_json_schema_report::<T>(),
800            example: crate::config_example_report::<T>(),
801        }
802    }
803
804    /// Renders the versioned export bundle as JSON.
805    #[must_use]
806    pub fn export_bundle_json(&self, options: &crate::EnvDocOptions) -> Value {
807        json_value(
808            &self.export_bundle(options),
809            Value::Object(Default::default()),
810        )
811    }
812
813    /// Renders the versioned export bundle as pretty JSON.
814    #[must_use]
815    pub fn export_bundle_json_pretty(&self, options: &crate::EnvDocOptions) -> String {
816        json_pretty(
817            &self.export_bundle_json(options),
818            "{\"error\":\"failed to render export bundle\"}",
819        )
820    }
821}
822
823/// Builder for layered configuration loading.
824///
825/// `ConfigLoader<T>` is the main entry point for `tier`. It starts from
826/// in-code defaults and then applies additional layers in a deterministic
827/// order. The loader can also attach metadata, secret paths, normalizers, and
828/// validators before producing a typed [`LoadedConfig`].
829///
830/// # Examples
831///
832/// ```no_run
833/// use serde::{Deserialize, Serialize};
834/// use tier::{ConfigLoader, EnvSource};
835///
836/// #[derive(Debug, Clone, Serialize, Deserialize)]
837/// struct AppConfig {
838///     host: String,
839///     port: u16,
840/// }
841///
842/// impl Default for AppConfig {
843///     fn default() -> Self {
844///         Self {
845///             host: "127.0.0.1".to_owned(),
846///             port: 3000,
847///         }
848///     }
849/// }
850///
851/// let loaded = ConfigLoader::new(AppConfig::default())
852///     .file("config/app.toml")
853///     .env(EnvSource::prefixed("APP"))
854///     .load()?;
855///
856/// assert!(loaded.port >= 1);
857/// # Ok::<(), tier::ConfigError>(())
858/// ```
859pub struct ConfigLoader<T> {
860    defaults: T,
861    files: Vec<FileSource>,
862    env_sources: Vec<EnvSource>,
863    args_source: Option<ArgsSource>,
864    custom_layers: Vec<PendingCustomLayer>,
865    typed_arg_layers: Vec<DeferredPatchLayer>,
866    metadata: ConfigMetadata,
867    secret_paths: BTreeSet<String>,
868    normalizers: Vec<NamedNormalizer<T>>,
869    validators: Vec<NamedValidator<T>>,
870    profile: Option<String>,
871    unknown_field_policy: UnknownFieldPolicy,
872    env_decoders: BTreeMap<String, EnvDecoder>,
873    custom_env_decoders: BTreeMap<String, CustomEnvDecoder>,
874    config_version: Option<(String, u32)>,
875    migrations: Vec<ConfigMigration>,
876}
877
878impl<T> ConfigLoader<T>
879where
880    T: Serialize + DeserializeOwned,
881{
882    /// Creates a loader with the provided in-code defaults.
883    #[must_use]
884    pub fn new(defaults: T) -> Self {
885        Self {
886            defaults,
887            files: Vec::new(),
888            env_sources: Vec::new(),
889            args_source: None,
890            custom_layers: Vec::new(),
891            typed_arg_layers: Vec::new(),
892            metadata: ConfigMetadata::default(),
893            secret_paths: BTreeSet::new(),
894            normalizers: Vec::new(),
895            validators: Vec::new(),
896            profile: None,
897            unknown_field_policy: UnknownFieldPolicy::Deny,
898            env_decoders: BTreeMap::new(),
899            custom_env_decoders: BTreeMap::new(),
900            config_version: None,
901            migrations: Vec::new(),
902        }
903    }
904
905    /// Adds a required configuration file.
906    #[must_use]
907    pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
908        self.files.push(FileSource::new(path));
909        self
910    }
911
912    /// Adds an optional configuration file.
913    #[must_use]
914    pub fn optional_file(mut self, path: impl Into<PathBuf>) -> Self {
915        self.files.push(FileSource::optional(path));
916        self
917    }
918
919    /// Adds a custom file source definition.
920    #[must_use]
921    pub fn with_file(mut self, file: FileSource) -> Self {
922        self.files.push(file);
923        self
924    }
925
926    /// Adds an environment variable source.
927    #[must_use]
928    pub fn env(mut self, source: EnvSource) -> Self {
929        self.env_sources.push(source);
930        self
931    }
932
933    /// Registers a built-in environment decoder for a configuration path.
934    ///
935    /// This is useful for operational formats such as comma-separated lists or
936    /// `key=value` maps that are common in environment variables but awkward to
937    /// express as JSON.
938    ///
939    /// # Examples
940    ///
941    /// ```no_run
942    /// # fn main() -> Result<(), tier::ConfigError> {
943    /// use serde::{Deserialize, Serialize};
944    /// use tier::{ConfigLoader, EnvDecoder, EnvSource};
945    ///
946    /// #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
947    /// struct AppConfig {
948    ///     no_proxy: Vec<String>,
949    /// }
950    ///
951    /// let loaded = ConfigLoader::new(AppConfig { no_proxy: Vec::new() })
952    ///     .env_decoder("no_proxy", EnvDecoder::Csv)
953    ///     .env(EnvSource::from_pairs([(
954    ///         "APP__NO_PROXY",
955    ///         "localhost,127.0.0.1,.internal.example.com",
956    ///     )]).prefix("APP"))
957    ///     .load()?;
958    ///
959    /// assert_eq!(
960    ///     loaded.no_proxy,
961    ///     vec![
962    ///         "localhost".to_owned(),
963    ///         "127.0.0.1".to_owned(),
964    ///         ".internal.example.com".to_owned(),
965    ///     ]
966    /// );
967    /// # Ok(())
968    /// # }
969    /// ```
970    #[must_use]
971    pub fn env_decoder(mut self, path: impl Into<String>, decoder: EnvDecoder) -> Self {
972        let path = path.into();
973        self.env_decoders.insert(path, decoder);
974        self
975    }
976
977    /// Registers a custom environment decoder for a configuration path.
978    ///
979    /// This keeps application-specific env parsing inside `tier` without
980    /// requiring pre-normalization before building an [`EnvSource`].
981    ///
982    /// # Examples
983    ///
984    /// ```no_run
985    /// # fn main() -> Result<(), tier::ConfigError> {
986    /// use serde::{Deserialize, Serialize};
987    /// use serde_json::Value;
988    /// use tier::{ConfigLoader, EnvSource};
989    ///
990    /// #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
991    /// struct AppConfig {
992    ///     no_proxy: Vec<String>,
993    /// }
994    ///
995    /// let loaded = ConfigLoader::new(AppConfig { no_proxy: Vec::new() })
996    ///     .env_decoder_with("no_proxy", |raw| {
997    ///         Ok(Value::Array(
998    ///             raw.split(';')
999    ///                 .map(str::trim)
1000    ///                 .filter(|segment| !segment.is_empty())
1001    ///                 .map(|segment| Value::String(segment.to_owned()))
1002    ///                 .collect(),
1003    ///         ))
1004    ///     })
1005    ///     .env(EnvSource::from_pairs([("APP__NO_PROXY", "localhost;.internal")]).prefix("APP"))
1006    ///     .load()?;
1007    ///
1008    /// assert_eq!(loaded.no_proxy, vec!["localhost", ".internal"]);
1009    /// # Ok(())
1010    /// # }
1011    /// ```
1012    #[must_use]
1013    pub fn env_decoder_with<F>(mut self, path: impl Into<String>, decoder: F) -> Self
1014    where
1015        F: Fn(&str) -> Result<Value, String> + Send + Sync + 'static,
1016    {
1017        let path = path.into();
1018        self.custom_env_decoders.insert(path, Arc::new(decoder));
1019        self
1020    }
1021
1022    /// Declares the configuration version path and the newest version this
1023    /// loader understands.
1024    #[must_use]
1025    pub fn config_version(mut self, path: impl Into<String>, current_version: u32) -> Self {
1026        self.config_version = Some((path.into(), current_version));
1027        self
1028    }
1029
1030    /// Registers a migration rule applied before deserialization.
1031    #[must_use]
1032    pub fn migration(mut self, migration: ConfigMigration) -> Self {
1033        self.migrations.push(migration);
1034        self
1035    }
1036
1037    /// Adds CLI overrides from an [`ArgsSource`].
1038    #[must_use]
1039    pub fn args(mut self, source: ArgsSource) -> Self {
1040        self.args_source = Some(source);
1041        self
1042    }
1043
1044    /// Adds a custom serializable layer.
1045    pub fn layer(mut self, layer: Layer) -> Self {
1046        self.custom_layers
1047            .push(PendingCustomLayer::Immediate(layer));
1048        self
1049    }
1050
1051    /// Adds a typed sparse patch as a custom layer.
1052    ///
1053    /// This keeps sparse overrides typed and avoids maintaining a parallel
1054    /// serializable shadow hierarchy just to build a [`Layer`].
1055    ///
1056    /// # Examples
1057    ///
1058    /// ```no_run
1059    /// # #[cfg(feature = "derive")] {
1060    /// # fn main() -> Result<(), tier::ConfigError> {
1061    /// use serde::{Deserialize, Serialize};
1062    /// use tier::{ConfigLoader, TierPatch};
1063    ///
1064    /// #[derive(Debug, Clone, Serialize, Deserialize)]
1065    /// struct AppConfig {
1066    ///     port: u16,
1067    /// }
1068    ///
1069    /// #[derive(Debug, TierPatch, Default)]
1070    /// struct CliPatch {
1071    ///     port: Option<u16>,
1072    /// }
1073    ///
1074    /// let loaded = ConfigLoader::new(AppConfig { port: 3000 })
1075    ///     .patch("typed-cli", &CliPatch { port: Some(7000) })?
1076    ///     .load()?;
1077    ///
1078    /// assert_eq!(loaded.port, 7000);
1079    /// # Ok(())
1080    /// # }
1081    /// # }
1082    /// ```
1083    pub fn patch<P>(mut self, name: impl Into<String>, patch: &P) -> Result<Self, ConfigError>
1084    where
1085        P: TierPatch,
1086    {
1087        let mut builder = crate::patch::PatchLayerBuilder::from_trace_deferred(SourceTrace {
1088            kind: SourceKind::Custom,
1089            name: name.into(),
1090            location: None,
1091        });
1092        patch.write_layer(&mut builder, "")?;
1093        let layer = builder.finish_deferred();
1094        if !layer.is_empty() {
1095            self.custom_layers
1096                .push(PendingCustomLayer::DeferredPatch(layer));
1097        }
1098        Ok(self)
1099    }
1100
1101    #[cfg(feature = "clap")]
1102    /// Adds a typed `clap`-style sparse override struct as the last CLI layer.
1103    ///
1104    /// This is the ergonomic bridge for applications that already parse a
1105    /// typed `clap` CLI and want to feed the parsed values into `tier` without
1106    /// building a manual shadow patch struct.
1107    ///
1108    /// `clap` remains responsible for CLI grammar, subcommands, trailing args,
1109    /// and parse-time validation. `tier` only applies the already-parsed typed
1110    /// values as a last-layer configuration patch.
1111    ///
1112    /// # Examples
1113    ///
1114    /// ```no_run
1115    /// # #[cfg(all(feature = "derive", feature = "clap"))] {
1116    /// # fn main() -> Result<(), tier::ConfigError> {
1117    /// use clap::Parser;
1118    /// use serde::{Deserialize, Serialize};
1119    /// use tier::{ConfigLoader, TierPatch};
1120    ///
1121    /// #[derive(Debug, Clone, Serialize, Deserialize)]
1122    /// struct AppConfig {
1123    ///     port: u16,
1124    ///     token: Option<String>,
1125    /// }
1126    ///
1127    /// #[derive(Debug, Parser, TierPatch)]
1128    /// struct AppCli {
1129    ///     #[arg(long)]
1130    ///     port: Option<u16>,
1131    ///     #[arg(long = "db-token")]
1132    ///     #[tier(path_expr = tier::path!(AppConfig.token))]
1133    ///     token: Option<String>,
1134    /// }
1135    ///
1136    /// let cli = AppCli::parse_from(["app", "--port", "8080", "--db-token", "from-cli"]);
1137    /// let loaded = ConfigLoader::new(AppConfig {
1138    ///         port: 3000,
1139    ///         token: None,
1140    ///     })
1141    ///     .clap_overrides(&cli)?
1142    ///     .load()?;
1143    ///
1144    /// assert_eq!(loaded.port, 8080);
1145    /// assert_eq!(loaded.token.as_deref(), Some("from-cli"));
1146    /// # Ok(())
1147    /// # }
1148    /// # }
1149    /// ```
1150    pub fn clap_overrides<P>(mut self, patch: &P) -> Result<Self, ConfigError>
1151    where
1152        P: TierPatch,
1153    {
1154        let mut builder = crate::patch::PatchLayerBuilder::from_trace_deferred(SourceTrace {
1155            kind: SourceKind::Arguments,
1156            name: "typed-clap".to_owned(),
1157            location: None,
1158        });
1159        patch.write_layer(&mut builder, "")?;
1160        let layer = builder.finish_deferred();
1161        if !layer.is_empty() {
1162            self.typed_arg_layers.push(layer);
1163        }
1164        Ok(self)
1165    }
1166
1167    #[cfg(feature = "clap")]
1168    /// Projects a parsed CLI value onto the config-bearing patch portion and
1169    /// applies it as the last CLI layer.
1170    ///
1171    /// This is the CLI-first companion to [`ConfigLoader::clap_overrides`].
1172    /// It lets an application keep a full `clap` model with subcommands,
1173    /// positional arguments, or trailing args, while only the selected
1174    /// config-bearing sub-structure participates in `tier` overrides.
1175    ///
1176    /// # Examples
1177    ///
1178    /// ```no_run
1179    /// # #[cfg(all(feature = "derive", feature = "clap"))] {
1180    /// # fn main() -> Result<(), tier::ConfigError> {
1181    /// use clap::{Parser, Subcommand};
1182    /// use serde::{Deserialize, Serialize};
1183    /// use tier::{ConfigLoader, TierPatch};
1184    ///
1185    /// #[derive(Debug, Clone, Serialize, Deserialize)]
1186    /// struct AppConfig {
1187    ///     port: u16,
1188    /// }
1189    ///
1190    /// #[derive(Debug, Clone, clap::Args, TierPatch, Default)]
1191    /// struct ConfigArgs {
1192    ///     #[arg(long)]
1193    ///     port: Option<u16>,
1194    /// }
1195    ///
1196    /// #[derive(Debug, Clone, Subcommand)]
1197    /// enum Command {
1198    ///     Serve {
1199    ///         #[arg(last = true)]
1200    ///         trailing: Vec<String>,
1201    ///     },
1202    /// }
1203    ///
1204    /// #[derive(Debug, Clone, Parser)]
1205    /// struct AppCli {
1206    ///     #[command(flatten)]
1207    ///     config: ConfigArgs,
1208    ///     #[command(subcommand)]
1209    ///     command: Option<Command>,
1210    /// }
1211    ///
1212    /// let cli = AppCli::parse_from(["app", "--port", "8080", "serve", "--", "extra"]);
1213    /// let loaded = ConfigLoader::new(AppConfig { port: 3000 })
1214    ///     .clap_overrides_from(&cli, |cli| &cli.config)?
1215    ///     .load()?;
1216    ///
1217    /// assert_eq!(loaded.port, 8080);
1218    /// # Ok(())
1219    /// # }
1220    /// # }
1221    /// ```
1222    pub fn clap_overrides_from<C, P, F>(self, cli: &C, project: F) -> Result<Self, ConfigError>
1223    where
1224        P: TierPatch,
1225        F: FnOnce(&C) -> &P,
1226    {
1227        self.clap_overrides(project(cli))
1228    }
1229
1230    /// Marks a dot-delimited path as sensitive for report redaction.
1231    #[must_use]
1232    pub fn secret_path(mut self, path: impl Into<String>) -> Self {
1233        let path = path.into();
1234        if !path.is_empty() && path != "." {
1235            self.secret_paths.insert(path);
1236        }
1237        self
1238    }
1239
1240    /// Applies explicit field metadata to the loader.
1241    ///
1242    /// This is the manual alternative to [`ConfigLoader::derive_metadata`].
1243    /// Use it when you want env overrides, secrets, merge policies, or
1244    /// declared validations without enabling the `derive` feature.
1245    #[must_use]
1246    pub fn metadata(mut self, metadata: ConfigMetadata) -> Self {
1247        self.secret_paths.extend(metadata.secret_paths());
1248        self.metadata.extend(metadata);
1249        self
1250    }
1251
1252    /// Sets the active profile used by `{profile}` path templates.
1253    #[must_use]
1254    pub fn profile(mut self, profile: impl Into<String>) -> Self {
1255        self.profile = Some(profile.into());
1256        self
1257    }
1258
1259    /// Applies metadata-derived secret paths for the target configuration type.
1260    ///
1261    /// This is the most ergonomic way to connect `#[derive(TierConfig)]`
1262    /// metadata to the loader when the `derive` feature is enabled.
1263    #[must_use]
1264    pub fn derive_metadata(self) -> Self
1265    where
1266        T: TierMetadata,
1267    {
1268        self.metadata(T::metadata())
1269    }
1270
1271    /// Sets the unknown field policy.
1272    #[must_use]
1273    pub fn unknown_field_policy(mut self, policy: UnknownFieldPolicy) -> Self {
1274        self.unknown_field_policy = policy;
1275        self
1276    }
1277
1278    /// Allows unknown fields without warnings.
1279    #[must_use]
1280    pub fn allow_unknown_fields(self) -> Self {
1281        self.unknown_field_policy(UnknownFieldPolicy::Allow)
1282    }
1283
1284    /// Allows unknown fields and records warnings.
1285    #[must_use]
1286    pub fn warn_unknown_fields(self) -> Self {
1287        self.unknown_field_policy(UnknownFieldPolicy::Warn)
1288    }
1289
1290    /// Rejects unknown fields with an error.
1291    #[must_use]
1292    pub fn deny_unknown_fields(self) -> Self {
1293        self.unknown_field_policy(UnknownFieldPolicy::Deny)
1294    }
1295
1296    /// Registers a normalization hook applied after merge and before validation.
1297    #[must_use]
1298    pub fn normalizer<F, E>(mut self, name: impl Into<String>, normalizer: F) -> Self
1299    where
1300        F: Fn(&mut T) -> Result<(), E> + Send + Sync + 'static,
1301        E: Display,
1302    {
1303        self.normalizers.push(NamedNormalizer {
1304            name: name.into(),
1305            run: Box::new(move |config| normalizer(config).map_err(|error| error.to_string())),
1306        });
1307        self
1308    }
1309
1310    /// Registers a validation hook applied after normalization.
1311    #[must_use]
1312    pub fn validator<F>(mut self, name: impl Into<String>, validator: F) -> Self
1313    where
1314        F: Fn(&T) -> Result<(), ValidationErrors> + Send + Sync + 'static,
1315    {
1316        self.validators.push(NamedValidator {
1317            name: name.into(),
1318            run: Box::new(validator),
1319        });
1320        self
1321    }
1322}
1323
1324#[cfg(feature = "schema")]
1325impl<T> ConfigLoader<T>
1326where
1327    T: Serialize + DeserializeOwned + schemars::JsonSchema,
1328{
1329    /// Discovers secret paths from the target type's JSON Schema.
1330    #[must_use]
1331    pub fn discover_secret_paths_from_schema(mut self) -> Self {
1332        for path in schema_secret_paths::<T>() {
1333            self.secret_paths.insert(path);
1334        }
1335        self
1336    }
1337}
1338
1339fn is_valid_hostname(value: &str) -> bool {
1340    if value.is_empty() || value.len() > 253 {
1341        return false;
1342    }
1343
1344    value.split('.').all(|label| {
1345        !label.is_empty()
1346            && label.len() <= 63
1347            && !label.starts_with('-')
1348            && !label.ends_with('-')
1349            && label
1350                .chars()
1351                .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
1352    })
1353}
1354
1355fn is_valid_url(value: &str) -> bool {
1356    if value.is_empty()
1357        || value
1358            .chars()
1359            .any(|ch| ch.is_whitespace() || ch.is_control())
1360    {
1361        return false;
1362    }
1363
1364    let Some((scheme, rest)) = value.split_once(':') else {
1365        return false;
1366    };
1367    if !is_valid_url_scheme(scheme) || rest.is_empty() {
1368        return false;
1369    }
1370
1371    if scheme == "mailto" {
1372        return !rest.starts_with('/')
1373            && has_valid_percent_escapes(rest)
1374            && rest
1375                .chars()
1376                .all(|ch| !ch.is_whitespace() && !ch.is_control());
1377    }
1378
1379    if let Some(authority_and_tail) = rest.strip_prefix("//") {
1380        return is_valid_hierarchical_url(scheme, authority_and_tail);
1381    }
1382
1383    if rest.starts_with('/') {
1384        return matches!(scheme, "file" | "unix")
1385            && has_valid_percent_escapes(rest)
1386            && rest
1387                .chars()
1388                .all(|ch| !ch.is_whitespace() && !ch.is_control());
1389    }
1390
1391    false
1392}
1393
1394fn is_valid_url_scheme(scheme: &str) -> bool {
1395    let mut chars = scheme.chars();
1396    matches!(chars.next(), Some(ch) if ch.is_ascii_alphabetic())
1397        && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
1398}
1399
1400fn is_valid_hierarchical_url(scheme: &str, authority_and_tail: &str) -> bool {
1401    let split_at = authority_and_tail
1402        .find(['/', '?', '#'])
1403        .unwrap_or(authority_and_tail.len());
1404    let (authority, tail) = authority_and_tail.split_at(split_at);
1405
1406    if authority.is_empty() {
1407        return matches!(scheme, "file" | "unix")
1408            && !tail.is_empty()
1409            && has_valid_percent_escapes(tail)
1410            && tail
1411                .chars()
1412                .all(|ch| !ch.is_whitespace() && !ch.is_control());
1413    }
1414
1415    if !has_valid_percent_escapes(authority) || !has_valid_percent_escapes(tail) {
1416        return false;
1417    }
1418
1419    let host_port = authority
1420        .rsplit_once('@')
1421        .map_or(authority, |(userinfo, host_port)| {
1422            if userinfo.is_empty() || userinfo.contains('@') || !is_valid_url_userinfo(userinfo) {
1423                ""
1424            } else {
1425                host_port
1426            }
1427        });
1428    if !is_valid_url_host_port(host_port) {
1429        return false;
1430    }
1431
1432    tail.chars()
1433        .all(|ch| !ch.is_whitespace() && !ch.is_control())
1434}
1435
1436fn is_valid_url_host_port(host_port: &str) -> bool {
1437    if host_port.is_empty() {
1438        return false;
1439    }
1440
1441    if let Some(ipv6) = host_port.strip_prefix('[') {
1442        let Some((host, suffix)) = ipv6.split_once(']') else {
1443            return false;
1444        };
1445        if host.parse::<std::net::Ipv6Addr>().is_err() {
1446            return false;
1447        }
1448        return suffix.is_empty() || parse_url_port(suffix.strip_prefix(':')).is_some();
1449    }
1450
1451    let (host, port) = match host_port.rsplit_once(':') {
1452        Some((host, port)) if !host.contains(':') => (host, Some(port)),
1453        Some(_) => return false,
1454        None => (host_port, None),
1455    };
1456
1457    if host.is_empty() || !(host.parse::<IpAddr>().is_ok() || is_valid_hostname(host)) {
1458        return false;
1459    }
1460
1461    port.is_none_or(|port| parse_url_port(Some(port)).is_some())
1462}
1463
1464fn parse_url_port(port: Option<&str>) -> Option<u16> {
1465    let port = port?;
1466    if port.is_empty() || !port.chars().all(|ch| ch.is_ascii_digit()) {
1467        return None;
1468    }
1469    port.parse::<u16>().ok()
1470}
1471
1472fn is_valid_url_userinfo(value: &str) -> bool {
1473    value.chars().all(|ch| {
1474        ch.is_ascii_alphanumeric()
1475            || matches!(
1476                ch,
1477                '-' | '.'
1478                    | '_'
1479                    | '~'
1480                    | '!'
1481                    | '$'
1482                    | '&'
1483                    | '\''
1484                    | '('
1485                    | ')'
1486                    | '*'
1487                    | '+'
1488                    | ','
1489                    | ';'
1490                    | '='
1491                    | ':'
1492                    | '%'
1493            )
1494    })
1495}
1496
1497fn has_valid_percent_escapes(value: &str) -> bool {
1498    let bytes = value.as_bytes();
1499    let mut index = 0usize;
1500    while index < bytes.len() {
1501        if bytes[index] == b'%' {
1502            let Some(first) = bytes.get(index + 1) else {
1503                return false;
1504            };
1505            let Some(second) = bytes.get(index + 2) else {
1506                return false;
1507            };
1508            if !first.is_ascii_hexdigit() || !second.is_ascii_hexdigit() {
1509                return false;
1510            }
1511            index += 3;
1512            continue;
1513        }
1514        index += 1;
1515    }
1516    true
1517}
1518
1519fn is_valid_email(value: &str) -> bool {
1520    if value.is_empty() || value.contains(char::is_whitespace) || value.matches('@').count() != 1 {
1521        return false;
1522    }
1523
1524    let Some((local, domain)) = value.split_once('@') else {
1525        return false;
1526    };
1527
1528    if local.is_empty()
1529        || domain.is_empty()
1530        || local.starts_with('.')
1531        || local.ends_with('.')
1532        || local.contains("..")
1533    {
1534        return false;
1535    }
1536
1537    static LOCAL_PART_RE: OnceLock<Regex> = OnceLock::new();
1538    let local_part_re = LOCAL_PART_RE.get_or_init(|| {
1539        Regex::new(r"^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$")
1540            .expect("email local-part regex must compile")
1541    });
1542    if !local_part_re.is_match(local) {
1543        return false;
1544    }
1545
1546    if let Some(ip_literal) = domain
1547        .strip_prefix('[')
1548        .and_then(|domain| domain.strip_suffix(']'))
1549    {
1550        return ip_literal.parse::<IpAddr>().is_ok();
1551    }
1552
1553    domain.parse::<std::net::Ipv4Addr>().is_err() && is_valid_hostname(domain)
1554}
1555
1556fn parse_args(source: ArgsSource) -> Result<ParsedArgs, ConfigError> {
1557    let mut args = source.args.into_iter();
1558    let mut files = Vec::new();
1559    let mut profile = None;
1560    let mut root = Value::Object(Map::new());
1561    let mut entries = BTreeMap::new();
1562    let mut coercible_string_paths = BTreeSet::new();
1563    let mut indexed_array_paths = BTreeSet::new();
1564    let mut indexed_array_base_lengths = BTreeMap::new();
1565    let mut current_array_lengths = BTreeMap::new();
1566    let mut direct_array_paths = BTreeSet::new();
1567    let mut claimed_paths = BTreeMap::<String, String>::new();
1568
1569    while let Some(arg) = args.next() {
1570        if let Some(value) = arg.strip_prefix("--config=") {
1571            files.push(FileSource::new(value));
1572            continue;
1573        }
1574
1575        if arg == "--config" {
1576            let value = args.next().ok_or_else(|| ConfigError::MissingArgValue {
1577                flag: "--config".to_owned(),
1578            })?;
1579            files.push(FileSource::new(value));
1580            continue;
1581        }
1582
1583        if let Some(value) = arg.strip_prefix("--profile=") {
1584            profile = Some(value.to_owned());
1585            continue;
1586        }
1587
1588        if arg == "--profile" {
1589            profile = Some(args.next().ok_or_else(|| ConfigError::MissingArgValue {
1590                flag: "--profile".to_owned(),
1591            })?);
1592            continue;
1593        }
1594
1595        let set_value = if let Some(value) = arg.strip_prefix("--set=") {
1596            Some(value.to_owned())
1597        } else if arg == "--set" {
1598            Some(args.next().ok_or_else(|| ConfigError::MissingArgValue {
1599                flag: "--set".to_owned(),
1600            })?)
1601        } else {
1602            None
1603        };
1604
1605        let Some(set_value) = set_value else {
1606            continue;
1607        };
1608
1609        let (raw_path, raw_value) =
1610            set_value
1611                .split_once('=')
1612                .ok_or_else(|| ConfigError::InvalidArg {
1613                    arg: set_value.clone(),
1614                    message: "expected key=value".to_owned(),
1615                })?;
1616        let path =
1617            try_normalize_external_path(raw_path).map_err(|message| ConfigError::InvalidArg {
1618                arg: format!("--set {raw_path}={raw_value}"),
1619                message,
1620            })?;
1621        if path.is_empty() {
1622            return Err(ConfigError::InvalidArg {
1623                arg: set_value,
1624                message: "configuration path cannot be empty".to_owned(),
1625            });
1626        }
1627
1628        let segments = path.split('.').collect::<Vec<_>>();
1629        let parsed =
1630            parse_override_value(raw_value).map_err(|message| ConfigError::InvalidArg {
1631                arg: format!("--set {path}={raw_value}"),
1632                message,
1633            })?;
1634        let arg_trace_name = format!("--set {raw_path}={raw_value}");
1635        let is_direct_array = parsed.value.is_array();
1636        claim_arg_path(
1637            &arg_trace_name,
1638            &path,
1639            is_direct_array,
1640            &direct_array_paths,
1641            &mut claimed_paths,
1642        )?;
1643        record_indexed_array_state(
1644            &mut current_array_lengths,
1645            &mut indexed_array_base_lengths,
1646            &path,
1647            &segments,
1648        );
1649        if is_direct_array {
1650            record_direct_array_state(
1651                &mut current_array_lengths,
1652                &mut indexed_array_base_lengths,
1653                &path,
1654                &parsed.value,
1655            );
1656        }
1657        insert_path(&mut root, &segments, parsed.value).map_err(|message| {
1658            ConfigError::InvalidArg {
1659                arg: format!("--set {path}={raw_value}"),
1660                message,
1661            }
1662        })?;
1663        for suffix in parsed.string_coercion_suffixes {
1664            coercible_string_paths.insert(if suffix.is_empty() {
1665                path.clone()
1666            } else {
1667                join_path(&path, &suffix)
1668            });
1669        }
1670        indexed_array_paths.extend(indexed_array_container_paths(&segments));
1671        if is_direct_array {
1672            direct_array_paths.insert(path.clone());
1673        }
1674
1675        entries.insert(
1676            path.clone(),
1677            SourceTrace::new(SourceKind::Arguments, arg_trace_name.clone()),
1678        );
1679
1680        let mut prefix = String::new();
1681        for segment in segments {
1682            if !prefix.is_empty() {
1683                prefix.push('.');
1684            }
1685            prefix.push_str(segment);
1686            let entry = entries
1687                .entry(prefix.clone())
1688                .or_insert_with(|| SourceTrace::new(SourceKind::Arguments, arg_trace_name.clone()));
1689            if prefix != path && entry.name != arg_trace_name {
1690                *entry = SourceTrace::new(SourceKind::Arguments, "arguments");
1691            }
1692        }
1693    }
1694
1695    let layer = if entries.is_empty() {
1696        None
1697    } else {
1698        Some(Layer {
1699            trace: SourceTrace::new(SourceKind::Arguments, "arguments"),
1700            value: root,
1701            entries,
1702            coercible_string_paths,
1703            indexed_array_paths,
1704            indexed_array_base_lengths,
1705            direct_array_paths,
1706        })
1707    };
1708
1709    Ok(ParsedArgs {
1710        profile,
1711        files,
1712        layer,
1713    })
1714}
1715
1716fn claim_arg_path(
1717    arg: &str,
1718    path: &str,
1719    is_direct_array: bool,
1720    direct_array_paths: &BTreeSet<String>,
1721    claimed_paths: &mut BTreeMap<String, String>,
1722) -> Result<(), ConfigError> {
1723    for (existing_path, existing_arg) in claimed_paths.iter() {
1724        if existing_path == path {
1725            return Err(ConfigError::InvalidArg {
1726                arg: arg.to_owned(),
1727                message: format!(
1728                    "conflicting CLI overrides `{existing_arg}` and `{arg}` both target `{path}`"
1729                ),
1730            });
1731        }
1732
1733        if existing_path
1734            .strip_prefix(path)
1735            .is_some_and(|suffix| suffix.starts_with('.'))
1736            || path
1737                .strip_prefix(existing_path)
1738                .is_some_and(|suffix| suffix.starts_with('.'))
1739        {
1740            if direct_array_overlap_allowed(
1741                existing_path,
1742                path,
1743                is_direct_array,
1744                direct_array_paths,
1745            ) {
1746                continue;
1747            }
1748            return Err(ConfigError::InvalidArg {
1749                arg: arg.to_owned(),
1750                message: format!(
1751                    "conflicting CLI overrides `{existing_arg}` and `{arg}` target overlapping configuration paths `{existing_path}` and `{path}`"
1752                ),
1753            });
1754        }
1755    }
1756
1757    claimed_paths.insert(path.to_owned(), arg.to_owned());
1758    Ok(())
1759}
1760
1761fn direct_array_overlap_allowed(
1762    existing_path: &str,
1763    new_path: &str,
1764    new_is_direct_array: bool,
1765    direct_array_paths: &BTreeSet<String>,
1766) -> bool {
1767    direct_array_prefix_allows(
1768        existing_path,
1769        new_path,
1770        direct_array_paths.contains(existing_path),
1771    ) || direct_array_prefix_allows(new_path, existing_path, new_is_direct_array)
1772}
1773
1774fn direct_array_prefix_allows(prefix: &str, other: &str, is_direct_array: bool) -> bool {
1775    if !is_direct_array {
1776        return false;
1777    }
1778    let remainder = if prefix.is_empty() {
1779        other
1780    } else {
1781        let Some(remainder) = other.strip_prefix(prefix) else {
1782            return false;
1783        };
1784        let Some(remainder) = remainder.strip_prefix('.') else {
1785            return false;
1786        };
1787        remainder
1788    };
1789    remainder
1790        .split('.')
1791        .next()
1792        .is_some_and(|segment| segment.parse::<usize>().is_ok())
1793}
1794
1795fn load_file_layer(file: FileSource, profile: Option<&str>) -> Result<Option<Layer>, ConfigError> {
1796    let resolved_paths = file
1797        .candidates
1798        .iter()
1799        .map(|path| resolve_profile_path(path, profile))
1800        .collect::<Result<Vec<_>, _>>()?;
1801    let path = resolved_paths.iter().find(|path| path.exists()).cloned();
1802    let Some(path) = path else {
1803        return if file.required {
1804            match resolved_paths.as_slice() {
1805                [] => Err(ConfigError::InvalidArg {
1806                    arg: "file source".to_owned(),
1807                    message: "at least one candidate path must be provided".to_owned(),
1808                }),
1809                [single] => Err(ConfigError::MissingFile {
1810                    path: single.clone(),
1811                }),
1812                _ => Err(ConfigError::MissingFiles {
1813                    paths: resolved_paths,
1814                }),
1815            }
1816        } else {
1817            Ok(None)
1818        };
1819    };
1820
1821    let content = std::fs::read_to_string(&path).map_err(|source| ConfigError::ReadFile {
1822        path: path.clone(),
1823        source,
1824    })?;
1825    let format = match file.format {
1826        Some(format) => format,
1827        None => infer_format(&path)?,
1828    };
1829    let value = parse_file_value(&path, &content, format)?;
1830
1831    let layer = Layer::from_value(
1832        SourceTrace::new(SourceKind::File, path.display().to_string()),
1833        value,
1834    )?;
1835
1836    Ok(Some(layer))
1837}
1838
1839fn resolve_profile_path(path: &Path, profile: Option<&str>) -> Result<PathBuf, ConfigError> {
1840    let raw = path.to_string_lossy();
1841    if raw.contains("{profile}") {
1842        let profile = profile.ok_or_else(|| ConfigError::MissingProfile {
1843            path: path.to_path_buf(),
1844        })?;
1845        Ok(PathBuf::from(raw.replace("{profile}", profile)))
1846    } else {
1847        Ok(path.to_path_buf())
1848    }
1849}
1850
1851#[cfg(feature = "schema")]
1852fn schema_secret_paths<T>() -> BTreeSet<String>
1853where
1854    T: schemars::JsonSchema,
1855{
1856    let schema = crate::schema::json_schema_for::<T>();
1857    let mut paths = BTreeSet::new();
1858    collect_secret_paths_from_schema(&schema, &schema, "", &mut paths, &mut BTreeSet::new());
1859    paths
1860}
1861
1862#[cfg(feature = "schema")]
1863fn collect_secret_paths_from_schema(
1864    schema: &Value,
1865    root: &Value,
1866    current: &str,
1867    paths: &mut BTreeSet<String>,
1868    visited_refs: &mut BTreeSet<String>,
1869) {
1870    let Some(object) = schema.as_object() else {
1871        return;
1872    };
1873
1874    let is_secret = object
1875        .get("x-tier-secret")
1876        .and_then(Value::as_bool)
1877        .unwrap_or(false)
1878        || object
1879            .get("writeOnly")
1880            .and_then(Value::as_bool)
1881            .unwrap_or(false);
1882
1883    if is_secret && !current.is_empty() {
1884        paths.insert(current.to_owned());
1885    }
1886
1887    if let Some(reference) = object.get("$ref").and_then(Value::as_str)
1888        && visited_refs.insert(reference.to_owned())
1889        && let Some(target) = resolve_schema_ref(root, reference)
1890    {
1891        collect_secret_paths_from_schema(target, root, current, paths, visited_refs);
1892        visited_refs.remove(reference);
1893    }
1894
1895    if let Some(properties) = object.get("properties").and_then(Value::as_object) {
1896        for (key, child) in properties {
1897            let next = crate::report::join_path(current, key);
1898            collect_secret_paths_from_schema(child, root, &next, paths, visited_refs);
1899        }
1900    }
1901
1902    if let Some(items) = object.get("prefixItems").and_then(Value::as_array) {
1903        for (index, child) in items.iter().enumerate() {
1904            let next = crate::report::join_path(current, &index.to_string());
1905            collect_secret_paths_from_schema(child, root, &next, paths, visited_refs);
1906        }
1907    }
1908
1909    if let Some(items) = object.get("items").and_then(Value::as_array) {
1910        for (index, child) in items.iter().enumerate() {
1911            let next = crate::report::join_path(current, &index.to_string());
1912            collect_secret_paths_from_schema(child, root, &next, paths, visited_refs);
1913        }
1914    }
1915
1916    if let Some(items) = object.get("items") {
1917        let next = crate::report::join_path(current, "*");
1918        collect_secret_paths_from_schema(items, root, &next, paths, visited_refs);
1919    }
1920
1921    if let Some(additional) = object
1922        .get("additionalProperties")
1923        .filter(|value| value.is_object())
1924    {
1925        let next = crate::report::join_path(current, "*");
1926        collect_secret_paths_from_schema(additional, root, &next, paths, visited_refs);
1927    }
1928
1929    for keyword in ["allOf", "anyOf", "oneOf"] {
1930        if let Some(array) = object.get(keyword).and_then(Value::as_array) {
1931            for child in array {
1932                collect_secret_paths_from_schema(child, root, current, paths, visited_refs);
1933            }
1934        }
1935    }
1936}
1937
1938#[cfg(feature = "schema")]
1939fn resolve_schema_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
1940    let pointer = reference.strip_prefix('#')?;
1941    root.pointer(pointer)
1942}
1943
1944fn infer_format(path: &Path) -> Result<FileFormat, ConfigError> {
1945    let extension = path
1946        .extension()
1947        .and_then(|extension| extension.to_str())
1948        .map(str::to_ascii_lowercase)
1949        .ok_or_else(|| ConfigError::InvalidArg {
1950            arg: path.display().to_string(),
1951            message: "cannot infer file format without an extension".to_owned(),
1952        })?;
1953
1954    match extension.as_str() {
1955        "json" => Ok(FileFormat::Json),
1956        "toml" => Ok(FileFormat::Toml),
1957        "yaml" | "yml" => Ok(FileFormat::Yaml),
1958        other => Err(ConfigError::InvalidArg {
1959            arg: path.display().to_string(),
1960            message: format!("unsupported file format extension: {other}"),
1961        }),
1962    }
1963}
1964
1965fn parse_file_value(path: &Path, content: &str, format: FileFormat) -> Result<Value, ConfigError> {
1966    match format {
1967        FileFormat::Json => {
1968            #[cfg(feature = "json")]
1969            {
1970                let value =
1971                    serde_json::from_str(content).map_err(|error| ConfigError::ParseFile {
1972                        path: path.to_path_buf(),
1973                        format,
1974                        location: Some(LineColumn {
1975                            line: error.line(),
1976                            column: error.column(),
1977                        }),
1978                        message: error.to_string(),
1979                    })?;
1980                Ok(value)
1981            }
1982
1983            #[cfg(not(feature = "json"))]
1984            {
1985                let _ = (path, content);
1986                Err(ConfigError::InvalidArg {
1987                    arg: "json".to_owned(),
1988                    message: "json support is disabled for this build".to_owned(),
1989                })
1990            }
1991        }
1992        FileFormat::Toml => {
1993            #[cfg(feature = "toml")]
1994            {
1995                let value = toml::from_str::<toml::Value>(content).map_err(|error| {
1996                    ConfigError::ParseFile {
1997                        path: path.to_path_buf(),
1998                        format,
1999                        location: error
2000                            .span()
2001                            .map(|span| offset_to_line_column(content, span.start)),
2002                        message: error.to_string(),
2003                    }
2004                })?;
2005                serde_json::to_value(value).map_err(ConfigError::from)
2006            }
2007
2008            #[cfg(not(feature = "toml"))]
2009            {
2010                let _ = (path, content);
2011                Err(ConfigError::InvalidArg {
2012                    arg: "toml".to_owned(),
2013                    message: "toml support is disabled for this build".to_owned(),
2014                })
2015            }
2016        }
2017        FileFormat::Yaml => {
2018            #[cfg(feature = "yaml")]
2019            {
2020                let value = serde_yaml::from_str::<Value>(content).map_err(|error| {
2021                    ConfigError::ParseFile {
2022                        path: path.to_path_buf(),
2023                        format,
2024                        location: error.location().map(|location| LineColumn {
2025                            line: location.line(),
2026                            column: location.column(),
2027                        }),
2028                        message: error.to_string(),
2029                    }
2030                })?;
2031                Ok(value)
2032            }
2033
2034            #[cfg(not(feature = "yaml"))]
2035            {
2036                let _ = (path, content);
2037                Err(ConfigError::InvalidArg {
2038                    arg: "yaml".to_owned(),
2039                    message: "yaml support is disabled for this build".to_owned(),
2040                })
2041            }
2042        }
2043    }
2044}
2045
2046#[cfg(feature = "toml")]
2047fn offset_to_line_column(input: &str, offset: usize) -> LineColumn {
2048    let mut line = 1;
2049    let mut column = 1;
2050    for (index, byte) in input.bytes().enumerate() {
2051        if index == offset {
2052            break;
2053        }
2054        if byte == b'\n' {
2055            line += 1;
2056            column = 1;
2057        } else {
2058            column += 1;
2059        }
2060    }
2061    LineColumn { line, column }
2062}