Skip to main content

rust_config_tree/
config.rs

1//! High-level `confique` integration and config-template rendering.
2//!
3//! This module loads `.env` values, builds a Figment runtime source graph,
4//! extracts it into a `confique` schema for defaults and validation, renders
5//! example templates that mirror the same include tree, and writes JSON Schema
6//! files that editors can use for completion and validation. YAML templates can
7//! also be split across nested schema sections.
8
9use std::{
10    collections::HashMap,
11    ffi::OsStr,
12    fs,
13    path::Component,
14    path::{Path, PathBuf},
15    sync::Arc,
16};
17
18use confique::{
19    Config, FileFormat, Layer,
20    meta::{Expr, FieldKind, LeafKind, MapKey, Meta},
21};
22use figment::{
23    Figment, Metadata, Profile, Provider, Source,
24    providers::{Env, Format, Json, Toml, Yaml},
25    value::{Dict, Map, Uncased},
26};
27use schemars::{JsonSchema, generate::SchemaSettings};
28use tracing::trace;
29
30use crate::{
31    ConfigError, ConfigSource, ConfigTree, ConfigTreeOptions, IncludeOrder, absolutize_lexical,
32    collect_template_targets, normalize_lexical, select_template_source,
33};
34
35/// Result type used by the high-level configuration API.
36///
37/// The error type is [`ConfigError`].
38pub type ConfigResult<T> = std::result::Result<T, ConfigError>;
39
40/// A `confique` schema that can expose recursive include paths and template
41/// section layout.
42///
43/// Implement this trait for the same type that derives `confique::Config`.
44/// `include_paths` receives a partially loaded layer so the crate can discover
45/// child config files before the final schema is merged.
46pub trait ConfigSchema: Config + Sized {
47    /// Returns include paths declared by a loaded config layer.
48    ///
49    /// Relative paths are resolved from the file that declared them. Empty paths
50    /// are rejected before traversal continues.
51    ///
52    /// # Arguments
53    ///
54    /// - `layer`: Partially loaded `confique` layer for one config file.
55    ///
56    /// # Returns
57    ///
58    /// Returns include paths declared by `layer`.
59    fn include_paths(layer: &<Self as Config>::Layer) -> Vec<PathBuf>;
60
61    /// Overrides the generated template file path for a nested section.
62    ///
63    /// By default, top-level sections are generated as `config/<field>.yaml`
64    /// and nested sections as children of their parent section file stem, e.g.
65    /// `config/trading/risk.yaml`.
66    ///
67    /// # Arguments
68    ///
69    /// - `section_path`: Path of nested schema field names from the root schema
70    ///   to the section being rendered.
71    ///
72    /// # Returns
73    ///
74    /// Returns `Some(path)` to override the generated file path, or `None` to
75    /// use the default section path.
76    fn template_path_for_section(section_path: &[&str]) -> Option<PathBuf> {
77        let _ = section_path;
78        None
79    }
80}
81
82/// File format used when loading config files or rendering templates.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum ConfigFormat {
85    /// YAML format, selected for `.yaml`, `.yml`, unknown extensions, and paths
86    /// without an extension.
87    Yaml,
88    /// TOML format, selected for `.toml`.
89    Toml,
90    /// JSON5-compatible format, selected for `.json` and `.json5`.
91    Json,
92}
93
94impl ConfigFormat {
95    /// Infers the config format from a path extension.
96    ///
97    /// Unknown extensions intentionally fall back to YAML.
98    ///
99    /// # Arguments
100    ///
101    /// - `path`: Config or template path whose extension should be inspected.
102    ///
103    /// # Returns
104    ///
105    /// Returns the inferred [`ConfigFormat`].
106    pub fn from_path(path: impl AsRef<Path>) -> Self {
107        match path.as_ref().extension().and_then(OsStr::to_str) {
108            Some("toml") => Self::Toml,
109            Some("json" | "json5") => Self::Json,
110            Some("yaml" | "yml") | Some(_) | None => Self::Yaml,
111        }
112    }
113
114    /// Converts this format into the `confique` file format used for loading.
115    ///
116    /// # Returns
117    ///
118    /// Returns the matching [`FileFormat`] value.
119    pub fn as_file_format(self) -> FileFormat {
120        match self {
121            Self::Yaml => FileFormat::Yaml,
122            Self::Toml => FileFormat::Toml,
123            Self::Json => FileFormat::Json5,
124        }
125    }
126}
127
128/// Generated template content for one output path.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ConfigTemplateTarget {
131    /// Path that should receive the generated content.
132    pub path: PathBuf,
133    /// Complete template content to write to `path`.
134    pub content: String,
135}
136
137/// Figment provider that maps environment variables declared in `confique`
138/// schema metadata onto their exact field paths.
139///
140/// This provider reads `#[config(env = "...")]` from [`Config::META`] and
141/// avoids Figment's delimiter-based environment key splitting. Environment
142/// variables such as `APP_DATABASE_POOL_SIZE` can therefore map to a Rust field
143/// named `database.pool_size` without treating the single underscores as nested
144/// separators.
145#[derive(Clone)]
146pub struct ConfiqueEnvProvider {
147    env: Env,
148    path_to_env: Arc<HashMap<String, String>>,
149}
150
151impl ConfiqueEnvProvider {
152    /// Creates an environment provider for a `confique` schema.
153    ///
154    /// # Type Parameters
155    ///
156    /// - `S`: Config schema whose metadata declares environment variable names.
157    ///
158    /// # Returns
159    ///
160    /// Returns a provider that emits only environment variables declared by `S`.
161    pub fn new<S>() -> Self
162    where
163        S: Config,
164    {
165        let mut env_to_path = HashMap::<String, String>::new();
166        let mut path_to_env = HashMap::<String, String>::new();
167
168        collect_env_mapping(&S::META, "", &mut env_to_path, &mut path_to_env);
169
170        let env_to_path = Arc::new(env_to_path);
171        let path_to_env = Arc::new(path_to_env);
172        let map_for_filter = Arc::clone(&env_to_path);
173
174        let env = Env::raw().filter_map(move |env_key| {
175            let lookup_key = env_key.as_str().to_ascii_uppercase();
176
177            map_for_filter
178                .get(&lookup_key)
179                .cloned()
180                .map(Uncased::from_owned)
181        });
182
183        Self { env, path_to_env }
184    }
185}
186
187impl Provider for ConfiqueEnvProvider {
188    fn metadata(&self) -> Metadata {
189        let path_to_env = Arc::clone(&self.path_to_env);
190
191        Metadata::named("environment variable").interpolater(move |_profile, keys| {
192            let path = keys.join(".");
193
194            path_to_env.get(&path).cloned().unwrap_or(path)
195        })
196    }
197
198    fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
199        self.env.data()
200    }
201}
202
203/// Loads a complete `confique` schema from a root config path.
204///
205/// The loader follows recursive include paths exposed by [`ConfigSchema`],
206/// resolves relative include paths from the declaring file, detects include
207/// cycles, loads the first `.env` file found from the root config directory
208/// upward, builds a [`Figment`] from config files and schema-declared
209/// environment variables, and then asks `confique` to apply defaults and
210/// validation. Existing process environment variables take precedence over
211/// values loaded from `.env`.
212///
213/// # Type Parameters
214///
215/// - `S`: Config schema type that derives [`Config`] and implements
216///   [`ConfigSchema`].
217///
218/// # Arguments
219///
220/// - `path`: Root config file path.
221///
222/// # Returns
223///
224/// Returns the merged config schema after loading the root file, recursive
225/// includes, `.env` values, and environment values.
226pub fn load_config<S>(path: impl AsRef<Path>) -> ConfigResult<S>
227where
228    S: ConfigSchema,
229{
230    let (config, _) = load_config_with_figment::<S>(path)?;
231    Ok(config)
232}
233
234/// Loads a config schema and returns the Figment graph used for runtime loading.
235///
236/// The returned [`Figment`] can be inspected with [`Figment::find_metadata`] to
237/// determine which provider supplied a runtime value.
238///
239/// # Type Parameters
240///
241/// - `S`: Config schema type that derives [`Config`] and implements
242///   [`ConfigSchema`].
243///
244/// # Arguments
245///
246/// - `path`: Root config file path.
247///
248/// # Returns
249///
250/// Returns the merged config schema and its runtime Figment source graph.
251pub fn load_config_with_figment<S>(path: impl AsRef<Path>) -> ConfigResult<(S, Figment)>
252where
253    S: ConfigSchema,
254{
255    let figment = build_config_figment::<S>(path)?;
256    let config = load_config_from_figment::<S>(&figment)?;
257
258    Ok((config, figment))
259}
260
261/// Builds the Figment runtime source graph for a config tree.
262///
263/// Config files are merged in include order, then environment variables
264/// declared by [`ConfiqueEnvProvider`] are merged with higher priority.
265///
266/// # Type Parameters
267///
268/// - `S`: Config schema type used to discover includes and environment names.
269///
270/// # Arguments
271///
272/// - `path`: Root config file path.
273///
274/// # Returns
275///
276/// Returns a Figment source graph with file and environment providers.
277pub fn build_config_figment<S>(path: impl AsRef<Path>) -> ConfigResult<Figment>
278where
279    S: ConfigSchema,
280{
281    let path = path.as_ref();
282    load_dotenv_for_path(path)?;
283
284    let tree = load_layer_tree::<S>(path)?;
285    let mut figment = Figment::new();
286
287    for node in tree.nodes().iter().rev() {
288        figment = merge_file_provider(figment, node.path());
289    }
290
291    Ok(figment.merge(ConfiqueEnvProvider::new::<S>()))
292}
293
294/// Extracts and validates a config schema from a Figment source graph.
295///
296/// Figment supplies runtime values. `confique` supplies code defaults and final
297/// validation.
298///
299/// # Type Parameters
300///
301/// - `S`: Config schema type to extract and validate.
302///
303/// # Arguments
304///
305/// - `figment`: Runtime source graph.
306///
307/// # Returns
308///
309/// Returns the final config schema.
310pub fn load_config_from_figment<S>(figment: &Figment) -> ConfigResult<S>
311where
312    S: ConfigSchema,
313{
314    let runtime_layer: <S as Config>::Layer = figment.extract()?;
315    let config = S::from_layer(runtime_layer.with_fallback(S::Layer::default_values()))?;
316
317    trace_config_sources::<S>(figment);
318
319    Ok(config)
320}
321
322/// Loads one config layer from disk using the format inferred from the path.
323///
324/// # Type Parameters
325///
326/// - `S`: Config schema type whose intermediate `confique` layer should be
327///   loaded.
328///
329/// # Arguments
330///
331/// - `path`: Config file path to load.
332///
333/// # Returns
334///
335/// Returns the loaded `confique` layer for `S`.
336pub fn load_layer<S>(path: &Path) -> ConfigResult<<S as Config>::Layer>
337where
338    S: ConfigSchema,
339{
340    Ok(figment_for_file(path).extract()?)
341}
342
343fn load_layer_tree<S>(path: &Path) -> ConfigResult<ConfigTree<<S as Config>::Layer>>
344where
345    S: ConfigSchema,
346{
347    Ok(ConfigTreeOptions::default()
348        .include_order(IncludeOrder::Reverse)
349        .load(
350            path,
351            |path| -> ConfigResult<ConfigSource<<S as Config>::Layer>> {
352                let layer = load_layer::<S>(path)?;
353                let include_paths = S::include_paths(&layer);
354                Ok(ConfigSource::new(layer, include_paths))
355            },
356        )?)
357}
358
359fn merge_file_provider(figment: Figment, path: &Path) -> Figment {
360    match ConfigFormat::from_path(path) {
361        ConfigFormat::Yaml => figment.merge(Yaml::file_exact(path)),
362        ConfigFormat::Toml => figment.merge(Toml::file_exact(path)),
363        ConfigFormat::Json => figment.merge(Json::file_exact(path)),
364    }
365}
366
367fn figment_for_file(path: &Path) -> Figment {
368    merge_file_provider(Figment::new(), path)
369}
370
371/// Writes a Draft 7 JSON Schema for a config type.
372///
373/// The same generated schema can be referenced from TOML, YAML, and JSON
374/// configuration files. TOML and YAML templates can bind it with editor
375/// directives. JSON files should usually be bound through editor settings
376/// rather than a runtime `$schema` field.
377///
378/// # Type Parameters
379///
380/// - `S`: Config schema type that derives [`JsonSchema`].
381///
382/// # Arguments
383///
384/// - `output_path`: Destination path for the generated JSON Schema.
385///
386/// # Returns
387///
388/// Returns `Ok(())` after the schema file has been written.
389pub fn write_config_schema<S>(output_path: impl AsRef<Path>) -> ConfigResult<()>
390where
391    S: JsonSchema,
392{
393    let generator = SchemaSettings::draft07().into_generator();
394    let schema = generator.into_root_schema_for::<S>();
395    let mut json = serde_json::to_string_pretty(&schema)?;
396    ensure_single_trailing_newline(&mut json);
397
398    write_template(output_path.as_ref(), &json)
399}
400
401/// Renders the default template for one path.
402///
403/// The template format is inferred from the path extension.
404///
405/// # Type Parameters
406///
407/// - `S`: Config schema type used to render the template.
408///
409/// # Arguments
410///
411/// - `path`: Output path whose extension selects the template format.
412///
413/// # Returns
414///
415/// Returns the generated template content.
416pub fn template_for_path<S>(path: impl AsRef<Path>) -> ConfigResult<String>
417where
418    S: ConfigSchema,
419{
420    let template = match ConfigFormat::from_path(path.as_ref()) {
421        ConfigFormat::Yaml => confique::yaml::template::<S>(yaml_options()),
422        ConfigFormat::Toml => confique::toml::template::<S>(toml_options()),
423        ConfigFormat::Json => confique::json5::template::<S>(json5_options()),
424    };
425
426    Ok(template)
427}
428
429/// Collects all template targets that should be generated for a config tree.
430///
431/// The root template source is selected with [`select_template_source`]. Include
432/// paths found in the source tree are mirrored under `output_path` for relative
433/// includes. When a source node has no includes, nested `confique` sections are
434/// used to derive child template files with paths from
435/// [`ConfigSchema::template_path_for_section`].
436///
437/// # Type Parameters
438///
439/// - `S`: Config schema type used to discover includes and render templates.
440///
441/// # Arguments
442///
443/// - `config_path`: Root config path preferred as the template source when it
444///   exists.
445/// - `output_path`: Root output path for generated templates.
446///
447/// # Returns
448///
449/// Returns all generated template targets in traversal order.
450pub fn template_targets_for_paths<S>(
451    config_path: impl AsRef<Path>,
452    output_path: impl AsRef<Path>,
453) -> ConfigResult<Vec<ConfigTemplateTarget>>
454where
455    S: ConfigSchema,
456{
457    let output_path = output_path.as_ref();
458    let source_path = select_template_source(config_path, output_path);
459    let root_source_path = absolutize_lexical(source_path)?;
460    let output_base_dir = output_path.parent().unwrap_or_else(|| Path::new("."));
461
462    let template_targets = collect_template_targets(
463        &root_source_path,
464        output_path,
465        |node_source_path| -> ConfigResult<Vec<PathBuf>> {
466            let mut include_paths = template_source_include_paths::<S>(node_source_path)?;
467
468            if include_paths.is_empty() {
469                include_paths =
470                    default_child_include_paths::<S>(&root_source_path, node_source_path);
471            }
472
473            Ok(include_paths)
474        },
475    )?;
476
477    let split_paths = template_targets
478        .iter()
479        .filter_map(|target| {
480            section_path_for_target::<S>(output_base_dir, target.target_path())
481                .filter(|section_path| !section_path.is_empty())
482        })
483        .collect::<Vec<_>>();
484
485    template_targets
486        .into_iter()
487        .map(|target| {
488            let (_, target_path, include_paths) = target.into_parts();
489            let section_path =
490                section_path_for_target::<S>(output_base_dir, &target_path).unwrap_or_default();
491            Ok(ConfigTemplateTarget {
492                content: template_for_target::<S>(
493                    &target_path,
494                    &include_paths,
495                    &section_path,
496                    &split_paths,
497                )?,
498                path: target_path,
499            })
500        })
501        .collect()
502}
503
504/// Collects template targets and binds TOML/YAML templates to a JSON Schema.
505///
506/// TOML targets receive a `#:schema` directive. YAML targets receive a YAML
507/// Language Server modeline. JSON and JSON5 targets are left unchanged so the
508/// runtime configuration is not polluted with a `$schema` field.
509///
510/// # Type Parameters
511///
512/// - `S`: Config schema type used to discover includes and render templates.
513///
514/// # Arguments
515///
516/// - `config_path`: Root config path preferred as the template source when it
517///   exists.
518/// - `output_path`: Root output path for generated templates.
519/// - `schema_path`: JSON Schema path to reference from TOML/YAML templates.
520///
521/// # Returns
522///
523/// Returns all generated template targets in traversal order.
524pub fn template_targets_for_paths_with_schema<S>(
525    config_path: impl AsRef<Path>,
526    output_path: impl AsRef<Path>,
527    schema_path: impl AsRef<Path>,
528) -> ConfigResult<Vec<ConfigTemplateTarget>>
529where
530    S: ConfigSchema,
531{
532    template_targets_for_paths::<S>(config_path, output_path)?
533        .into_iter()
534        .map(|mut target| {
535            target.content = template_with_schema_directive(
536                &target.path,
537                schema_path.as_ref(),
538                &target.content,
539            )?;
540            Ok(target)
541        })
542        .collect()
543}
544
545/// Writes all generated config templates for a config tree.
546///
547/// Parent directories are created before each target is written.
548///
549/// # Type Parameters
550///
551/// - `S`: Config schema type used to discover includes and render templates.
552///
553/// # Arguments
554///
555/// - `config_path`: Root config path preferred as the template source when it
556///   exists.
557/// - `output_path`: Root output path for generated templates.
558///
559/// # Returns
560///
561/// Returns `Ok(())` after all template files have been written.
562pub fn write_config_templates<S>(
563    config_path: impl AsRef<Path>,
564    output_path: impl AsRef<Path>,
565) -> ConfigResult<()>
566where
567    S: ConfigSchema,
568{
569    for target in template_targets_for_paths::<S>(config_path, output_path)? {
570        write_template(&target.path, &target.content)?;
571    }
572
573    Ok(())
574}
575
576/// Writes all generated config templates with editor schema bindings.
577///
578/// TOML targets receive `#:schema <path>`, YAML targets receive
579/// `# yaml-language-server: $schema=<path>`, and JSON targets are left
580/// unchanged. The schema path is rendered relative to each template file.
581///
582/// # Type Parameters
583///
584/// - `S`: Config schema type used to discover includes and render templates.
585///
586/// # Arguments
587///
588/// - `config_path`: Root config path preferred as the template source when it
589///   exists.
590/// - `output_path`: Root output path for generated templates.
591/// - `schema_path`: JSON Schema path to reference from TOML/YAML templates.
592///
593/// # Returns
594///
595/// Returns `Ok(())` after all template files have been written.
596pub fn write_config_templates_with_schema<S>(
597    config_path: impl AsRef<Path>,
598    output_path: impl AsRef<Path>,
599    schema_path: impl AsRef<Path>,
600) -> ConfigResult<()>
601where
602    S: ConfigSchema,
603{
604    for target in
605        template_targets_for_paths_with_schema::<S>(config_path, output_path, schema_path)?
606    {
607        write_template(&target.path, &target.content)?;
608    }
609
610    Ok(())
611}
612
613/// Writes one generated template file, creating parent directories first.
614///
615/// # Arguments
616///
617/// - `path`: Destination file path.
618/// - `content`: Complete template content to write.
619///
620/// # Returns
621///
622/// Returns `Ok(())` after the file has been written.
623pub(crate) fn write_template(path: &Path, content: &str) -> ConfigResult<()> {
624    if let Some(parent) = path
625        .parent()
626        .filter(|parent| !parent.as_os_str().is_empty())
627    {
628        fs::create_dir_all(parent)?;
629    }
630
631    fs::write(path, content)?;
632    Ok(())
633}
634
635/// Resolves the CLI template output path to a normalized absolute path.
636///
637/// # Arguments
638///
639/// - `output`: Optional user-provided output path. When omitted,
640///   `config.example.yaml` is used.
641///
642/// # Returns
643///
644/// Returns a normalized absolute output path.
645pub(crate) fn resolve_config_template_output(output: Option<PathBuf>) -> ConfigResult<PathBuf> {
646    let current_dir = std::env::current_dir()?;
647    let output = output.unwrap_or_else(|| PathBuf::from("config.example.yaml"));
648    let output = if output.is_absolute() {
649        output
650    } else {
651        current_dir.join(output)
652    };
653
654    Ok(normalize_lexical(output))
655}
656
657fn template_source_include_paths<S>(path: &Path) -> ConfigResult<Vec<PathBuf>>
658where
659    S: ConfigSchema,
660{
661    if !path.exists() {
662        return Ok(Vec::new());
663    }
664
665    match load_layer::<S>(path) {
666        Ok(layer) => Ok(S::include_paths(&layer)),
667        Err(_) => load_include_paths_only(path),
668    }
669}
670
671fn load_include_paths_only(path: &Path) -> ConfigResult<Vec<PathBuf>> {
672    match figment_for_file(path).extract_inner::<Vec<PathBuf>>("include") {
673        Ok(paths) => Ok(paths),
674        Err(error) if error.missing() => Ok(Vec::new()),
675        Err(error) => Err(error.into()),
676    }
677}
678
679fn template_with_schema_directive(
680    template_path: &Path,
681    schema_path: &Path,
682    content: &str,
683) -> ConfigResult<String> {
684    let schema_ref = schema_reference_for_path(template_path, schema_path)?;
685    let directive = match ConfigFormat::from_path(template_path) {
686        ConfigFormat::Yaml => Some(format!("# yaml-language-server: $schema={schema_ref}")),
687        ConfigFormat::Toml => Some(format!("#:schema {schema_ref}")),
688        ConfigFormat::Json => None,
689    };
690
691    let Some(directive) = directive else {
692        return Ok(content.to_owned());
693    };
694
695    Ok(format!("{directive}\n\n{content}"))
696}
697
698fn schema_reference_for_path(template_path: &Path, schema_path: &Path) -> ConfigResult<String> {
699    let template_path = absolutize_lexical(template_path)?;
700    let schema_path = absolutize_lexical(schema_path)?;
701    let template_dir = template_path.parent().unwrap_or_else(|| Path::new("."));
702    let relative_path = relative_path_from(&schema_path, template_dir);
703    Ok(render_schema_reference(&relative_path))
704}
705
706fn relative_path_from(path: &Path, base: &Path) -> PathBuf {
707    let path_components = path.components().collect::<Vec<_>>();
708    let base_components = base.components().collect::<Vec<_>>();
709
710    let mut common_len = 0;
711    while common_len < path_components.len()
712        && common_len < base_components.len()
713        && path_components[common_len] == base_components[common_len]
714    {
715        common_len += 1;
716    }
717
718    if common_len == 0 {
719        return path.to_path_buf();
720    }
721
722    let mut relative = PathBuf::new();
723    for component in &base_components[common_len..] {
724        if matches!(component, Component::Normal(_)) {
725            relative.push("..");
726        }
727    }
728
729    for component in &path_components[common_len..] {
730        relative.push(component.as_os_str());
731    }
732
733    if relative.as_os_str().is_empty() {
734        PathBuf::from(".")
735    } else {
736        relative
737    }
738}
739
740fn render_schema_reference(path: &Path) -> String {
741    let value = path.to_string_lossy().replace('\\', "/");
742    if path.is_absolute() || value.starts_with("../") || value.starts_with("./") {
743        value
744    } else {
745        format!("./{value}")
746    }
747}
748
749fn template_for_target<S>(
750    path: &Path,
751    include_paths: &[PathBuf],
752    section_path: &[&'static str],
753    split_paths: &[Vec<&'static str>],
754) -> ConfigResult<String>
755where
756    S: ConfigSchema,
757{
758    if ConfigFormat::from_path(path) != ConfigFormat::Yaml || split_paths.is_empty() {
759        return template_for_path_with_includes::<S>(path, include_paths);
760    }
761
762    Ok(render_yaml_template(
763        &S::META,
764        include_paths,
765        section_path,
766        split_paths,
767    ))
768}
769
770fn default_child_include_paths<S>(root_source_path: &Path, node_source_path: &Path) -> Vec<PathBuf>
771where
772    S: ConfigSchema,
773{
774    let root_base_dir = root_source_path.parent().unwrap_or_else(|| Path::new("."));
775    let section_path =
776        section_path_for_target::<S>(root_base_dir, node_source_path).unwrap_or_default();
777    let source_base_dir = node_source_path.parent().unwrap_or_else(|| Path::new("."));
778
779    immediate_child_section_paths(&S::META, &section_path)
780        .into_iter()
781        .map(|child_section_path| {
782            let child_path =
783                root_base_dir.join(template_path_for_section::<S>(&child_section_path));
784            path_relative_to(&child_path, source_base_dir)
785        })
786        .collect()
787}
788
789fn collect_env_mapping(
790    meta: &'static Meta,
791    prefix: &str,
792    env_to_path: &mut HashMap<String, String>,
793    path_to_env: &mut HashMap<String, String>,
794) {
795    for field in meta.fields {
796        let path = if prefix.is_empty() {
797            field.name.to_owned()
798        } else {
799            format!("{prefix}.{}", field.name)
800        };
801
802        match field.kind {
803            FieldKind::Leaf { env: Some(env), .. } => {
804                env_to_path.insert(env.to_ascii_uppercase(), path.clone());
805                path_to_env.insert(path, env.to_owned());
806            }
807            FieldKind::Leaf { env: None, .. } => {}
808            FieldKind::Nested { meta } => {
809                collect_env_mapping(meta, &path, env_to_path, path_to_env);
810            }
811        }
812    }
813}
814
815fn load_dotenv_for_path(path: &Path) -> ConfigResult<()> {
816    let path = absolutize_lexical(path)?;
817    let mut current_dir = path.parent();
818
819    while let Some(dir) = current_dir {
820        let dotenv_path = dir.join(".env");
821        if dotenv_path.try_exists()? {
822            dotenvy::from_path(&dotenv_path)?;
823            break;
824        }
825        current_dir = dir.parent();
826    }
827
828    Ok(())
829}
830
831fn section_path_for_target<S>(root_base_dir: &Path, target_path: &Path) -> Option<Vec<&'static str>>
832where
833    S: ConfigSchema,
834{
835    let normalized_target = normalize_lexical(target_path);
836
837    for section_path in nested_section_paths(&S::META) {
838        let section_target =
839            normalize_lexical(root_base_dir.join(template_path_for_section::<S>(&section_path)));
840        if section_target == normalized_target {
841            return Some(section_path);
842        }
843    }
844
845    infer_section_path_from_path::<S>(target_path)
846}
847
848fn template_path_for_section<S>(section_path: &[&str]) -> PathBuf
849where
850    S: ConfigSchema,
851{
852    if let Some(path) = S::template_path_for_section(section_path) {
853        return path;
854    }
855
856    let Some((last, parent_path)) = section_path.split_last() else {
857        return PathBuf::new();
858    };
859
860    if parent_path.is_empty() {
861        return PathBuf::from("config").join(format!("{last}.yaml"));
862    }
863
864    let parent_template_path = template_path_for_section::<S>(parent_path);
865    parent_template_path
866        .with_extension("")
867        .join(format!("{last}.yaml"))
868}
869
870fn path_relative_to(path: &Path, base: &Path) -> PathBuf {
871    match path.strip_prefix(base) {
872        Ok(relative) if !relative.as_os_str().is_empty() => relative.to_path_buf(),
873        _ => path.to_path_buf(),
874    }
875}
876
877fn nested_section_paths(meta: &'static Meta) -> Vec<Vec<&'static str>> {
878    let mut paths = Vec::new();
879    collect_nested_section_paths(meta, &mut Vec::new(), &mut paths);
880    paths
881}
882
883fn collect_nested_section_paths(
884    meta: &'static Meta,
885    prefix: &mut Vec<&'static str>,
886    paths: &mut Vec<Vec<&'static str>>,
887) {
888    for field in meta.fields {
889        if let FieldKind::Nested { meta } = field.kind {
890            prefix.push(field.name);
891            paths.push(prefix.clone());
892            collect_nested_section_paths(meta, prefix, paths);
893            prefix.pop();
894        }
895    }
896}
897
898fn immediate_child_section_paths(
899    meta: &'static Meta,
900    section_path: &[&'static str],
901) -> Vec<Vec<&'static str>> {
902    let Some(section_meta) = meta_at_path(meta, section_path) else {
903        return Vec::new();
904    };
905
906    section_meta
907        .fields
908        .iter()
909        .filter_map(|field| match field.kind {
910            FieldKind::Nested { .. } => {
911                let mut path = section_path.to_vec();
912                path.push(field.name);
913                Some(path)
914            }
915            FieldKind::Leaf { .. } => None,
916        })
917        .collect()
918}
919
920/// Emits Figment source metadata for every leaf field at TRACE level.
921///
922/// This function returns immediately unless `tracing` has TRACE enabled. Callers
923/// can invoke it after initializing their tracing subscriber from the loaded log
924/// configuration.
925///
926/// # Type Parameters
927///
928/// - `S`: Config schema whose metadata declares the field paths to trace.
929///
930/// # Arguments
931///
932/// - `figment`: Runtime source graph used to load the config.
933///
934/// # Returns
935///
936/// This function only emits tracing events and returns no value.
937pub fn trace_config_sources<S>(figment: &Figment)
938where
939    S: ConfigSchema,
940{
941    if !tracing::enabled!(tracing::Level::TRACE) {
942        return;
943    }
944
945    for path in leaf_config_paths(&S::META) {
946        let source = config_source_for_path(figment, &path);
947        trace!(target: "rust_config_tree::config", config_key = %path, source = %source, "config source");
948    }
949}
950
951fn config_source_for_path(figment: &Figment, path: &str) -> String {
952    match figment.find_metadata(path) {
953        Some(metadata) => render_metadata(metadata, path),
954        None => "confique default or unset optional field".to_owned(),
955    }
956}
957
958fn render_metadata(metadata: &Metadata, path: &str) -> String {
959    match &metadata.source {
960        Some(Source::File(path)) => format!("{} `{}`", metadata.name, path.display()),
961        Some(Source::Custom(value)) => format!("{} `{value}`", metadata.name),
962        Some(Source::Code(location)) => {
963            format!("{} {}:{}", metadata.name, location.file(), location.line())
964        }
965        Some(_) => metadata.name.to_string(),
966        None => {
967            let parts = path.split('.').collect::<Vec<_>>();
968            let native = metadata.interpolate(&Profile::Default, &parts);
969
970            format!("{} `{native}`", metadata.name)
971        }
972    }
973}
974
975fn leaf_config_paths(meta: &'static Meta) -> Vec<String> {
976    let mut paths = Vec::new();
977    collect_leaf_config_paths(meta, "", &mut paths);
978    paths
979}
980
981fn collect_leaf_config_paths(meta: &'static Meta, prefix: &str, paths: &mut Vec<String>) {
982    for field in meta.fields {
983        let path = if prefix.is_empty() {
984            field.name.to_owned()
985        } else {
986            format!("{prefix}.{}", field.name)
987        };
988
989        match field.kind {
990            FieldKind::Leaf { .. } => paths.push(path),
991            FieldKind::Nested { meta } => collect_leaf_config_paths(meta, &path, paths),
992        }
993    }
994}
995
996fn infer_section_path_from_path<S>(path: &Path) -> Option<Vec<&'static str>>
997where
998    S: ConfigSchema,
999{
1000    let path_tokens = normalized_path_tokens(path);
1001    let file_token = path
1002        .file_stem()
1003        .and_then(OsStr::to_str)
1004        .map(normalize_token)
1005        .unwrap_or_default();
1006
1007    nested_section_paths(&S::META)
1008        .into_iter()
1009        .filter_map(|section_path| {
1010            let score = section_path_score(&section_path, &path_tokens, &file_token);
1011            (score > 0).then_some((score, section_path))
1012        })
1013        .max_by_key(|(score, section_path)| (*score, section_path.len()))
1014        .map(|(_, section_path)| section_path)
1015}
1016
1017fn normalized_path_tokens(path: &Path) -> Vec<String> {
1018    path.components()
1019        .filter_map(|component| component.as_os_str().to_str())
1020        .map(|component| {
1021            Path::new(component)
1022                .file_stem()
1023                .and_then(OsStr::to_str)
1024                .unwrap_or(component)
1025        })
1026        .map(normalize_token)
1027        .filter(|component| !component.is_empty())
1028        .collect()
1029}
1030
1031fn normalize_token(token: &str) -> String {
1032    token
1033        .chars()
1034        .filter_map(|character| match character {
1035            '-' | ' ' => Some('_'),
1036            '_' => Some('_'),
1037            character if character.is_ascii_alphanumeric() => Some(character.to_ascii_lowercase()),
1038            _ => None,
1039        })
1040        .collect()
1041}
1042
1043fn section_path_score(section_path: &[&str], path_tokens: &[String], file_token: &str) -> usize {
1044    let section_tokens = section_path
1045        .iter()
1046        .map(|segment| normalize_token(segment))
1047        .collect::<Vec<_>>();
1048
1049    if path_tokens.ends_with(&section_tokens) {
1050        return 1_000 + section_tokens.len();
1051    }
1052
1053    let Some(last_section_token) = section_tokens.last() else {
1054        return 0;
1055    };
1056
1057    if file_token == last_section_token {
1058        return 500 + section_tokens.len();
1059    }
1060
1061    if file_token.starts_with(last_section_token) || last_section_token.starts_with(file_token) {
1062        return 100 + last_section_token.len().min(file_token.len());
1063    }
1064
1065    0
1066}
1067
1068fn meta_at_path(meta: &'static Meta, section_path: &[&str]) -> Option<&'static Meta> {
1069    let mut current_meta = meta;
1070    for section in section_path {
1071        current_meta = current_meta.fields.iter().find_map(|field| {
1072            if field.name != *section {
1073                return None;
1074            }
1075
1076            match field.kind {
1077                FieldKind::Nested { meta } => Some(meta),
1078                FieldKind::Leaf { .. } => None,
1079            }
1080        })?;
1081    }
1082
1083    Some(current_meta)
1084}
1085
1086fn render_yaml_template(
1087    meta: &'static Meta,
1088    include_paths: &[PathBuf],
1089    section_path: &[&'static str],
1090    split_paths: &[Vec<&'static str>],
1091) -> String {
1092    let mut output = String::new();
1093    if !include_paths.is_empty() {
1094        output.push_str(&render_yaml_include(include_paths));
1095        output.push('\n');
1096    }
1097
1098    if section_path.is_empty() {
1099        render_yaml_fields(
1100            meta,
1101            &mut Vec::new(),
1102            split_paths,
1103            0,
1104            !include_paths.is_empty(),
1105            &mut output,
1106        );
1107    } else {
1108        render_yaml_section(meta, section_path, split_paths, &mut output);
1109    }
1110
1111    ensure_single_trailing_newline(&mut output);
1112    output
1113}
1114
1115fn render_yaml_section(
1116    meta: &'static Meta,
1117    section_path: &[&'static str],
1118    split_paths: &[Vec<&'static str>],
1119    output: &mut String,
1120) {
1121    let mut current_meta = meta;
1122    let mut current_path = Vec::new();
1123
1124    for (depth, section) in section_path.iter().enumerate() {
1125        write_yaml_indent(output, depth);
1126        output.push('#');
1127        output.push_str(section);
1128        output.push_str(":\n");
1129        current_path.push(*section);
1130
1131        let Some(next_meta) = meta_at_path(current_meta, &[*section]) else {
1132            return;
1133        };
1134        current_meta = next_meta;
1135    }
1136
1137    render_yaml_fields(
1138        current_meta,
1139        &mut current_path,
1140        split_paths,
1141        section_path.len(),
1142        false,
1143        output,
1144    );
1145}
1146
1147fn render_yaml_fields(
1148    meta: &'static Meta,
1149    current_path: &mut Vec<&'static str>,
1150    split_paths: &[Vec<&'static str>],
1151    depth: usize,
1152    skip_include_field: bool,
1153    output: &mut String,
1154) {
1155    let mut emitted_anything = false;
1156
1157    for field in meta.fields {
1158        let FieldKind::Leaf { env, kind } = field.kind else {
1159            continue;
1160        };
1161
1162        if skip_include_field && current_path.is_empty() && field.name == "include" {
1163            continue;
1164        }
1165
1166        if emitted_anything {
1167            output.push('\n');
1168        }
1169        emitted_anything = true;
1170        render_yaml_leaf(field.name, field.doc, env, kind, depth, output);
1171    }
1172
1173    for field in meta.fields {
1174        let FieldKind::Nested { meta } = field.kind else {
1175            continue;
1176        };
1177
1178        current_path.push(field.name);
1179        let split_exact = split_paths.iter().any(|path| path == current_path);
1180        let split_descendant = split_paths
1181            .iter()
1182            .any(|path| path.starts_with(current_path) && path.len() > current_path.len());
1183
1184        if split_exact {
1185            current_path.pop();
1186            continue;
1187        }
1188
1189        if emitted_anything {
1190            output.push('\n');
1191        }
1192        emitted_anything = true;
1193
1194        for doc in field.doc {
1195            write_yaml_indent(output, depth);
1196            output.push('#');
1197            output.push_str(doc);
1198            output.push('\n');
1199        }
1200        write_yaml_indent(output, depth);
1201        output.push_str(field.name);
1202        output.push_str(":\n");
1203
1204        let child_split_paths = if split_descendant { split_paths } else { &[] };
1205        render_yaml_fields(
1206            meta,
1207            current_path,
1208            child_split_paths,
1209            depth + 1,
1210            false,
1211            output,
1212        );
1213        current_path.pop();
1214    }
1215}
1216
1217fn render_yaml_leaf(
1218    name: &str,
1219    doc: &[&str],
1220    env: Option<&str>,
1221    kind: LeafKind,
1222    depth: usize,
1223    output: &mut String,
1224) {
1225    let mut emitted_doc_comment = false;
1226    for doc in doc {
1227        write_yaml_indent(output, depth);
1228        output.push('#');
1229        output.push_str(doc);
1230        output.push('\n');
1231        emitted_doc_comment = true;
1232    }
1233
1234    if let Some(env) = env {
1235        if emitted_doc_comment {
1236            write_yaml_indent(output, depth);
1237            output.push_str("#\n");
1238        }
1239        write_yaml_indent(output, depth);
1240        output.push_str("# Can also be specified via environment variable `");
1241        output.push_str(env);
1242        output.push_str("`.\n");
1243    }
1244
1245    match kind {
1246        LeafKind::Optional => {
1247            write_yaml_indent(output, depth);
1248            output.push('#');
1249            output.push_str(name);
1250            output.push_str(":\n");
1251        }
1252        LeafKind::Required { default } => {
1253            write_yaml_indent(output, depth);
1254            match default {
1255                Some(default) => {
1256                    output.push_str("# Default value: ");
1257                    output.push_str(&render_yaml_expr(&default));
1258                    output.push('\n');
1259                    write_yaml_indent(output, depth);
1260                    output.push('#');
1261                    output.push_str(name);
1262                    output.push_str(": ");
1263                    output.push_str(&render_yaml_expr(&default));
1264                    output.push('\n');
1265                }
1266                None => {
1267                    output.push_str("# Required! This value must be specified.\n");
1268                    write_yaml_indent(output, depth);
1269                    output.push('#');
1270                    output.push_str(name);
1271                    output.push_str(":\n");
1272                }
1273            }
1274        }
1275    }
1276}
1277
1278fn render_yaml_expr(expr: &Expr) -> String {
1279    match expr {
1280        Expr::Str(value) => render_plain_or_quoted_string(value),
1281        Expr::Float(value) => value.to_string(),
1282        Expr::Integer(value) => value.to_string(),
1283        Expr::Bool(value) => value.to_string(),
1284        Expr::Array(items) => {
1285            let items = items
1286                .iter()
1287                .map(render_yaml_expr)
1288                .collect::<Vec<_>>()
1289                .join(", ");
1290            format!("[{items}]")
1291        }
1292        Expr::Map(entries) => {
1293            let entries = entries
1294                .iter()
1295                .map(|entry| {
1296                    format!(
1297                        "{}: {}",
1298                        render_yaml_map_key(&entry.key),
1299                        render_yaml_expr(&entry.value)
1300                    )
1301                })
1302                .collect::<Vec<_>>()
1303                .join(", ");
1304            format!("{{ {entries} }}")
1305        }
1306        _ => String::new(),
1307    }
1308}
1309
1310fn render_yaml_map_key(key: &MapKey) -> String {
1311    match key {
1312        MapKey::Str(value) => render_plain_or_quoted_string(value),
1313        MapKey::Float(value) => value.to_string(),
1314        MapKey::Integer(value) => value.to_string(),
1315        MapKey::Bool(value) => value.to_string(),
1316        _ => String::new(),
1317    }
1318}
1319
1320fn render_plain_or_quoted_string(value: &str) -> String {
1321    let needs_quotes = value.is_empty()
1322        || value.starts_with([
1323            ' ', '#', '{', '}', '[', ']', ',', '&', '*', '!', '|', '>', '\'', '"',
1324        ])
1325        || value.contains([':', '\n', '\r', '\t']);
1326
1327    if needs_quotes {
1328        quote_path(Path::new(value))
1329    } else {
1330        value.to_owned()
1331    }
1332}
1333
1334fn write_yaml_indent(output: &mut String, depth: usize) {
1335    for _ in 0..depth {
1336        output.push_str("  ");
1337    }
1338}
1339
1340fn ensure_single_trailing_newline(output: &mut String) {
1341    if output.ends_with('\n') {
1342        while output.ends_with("\n\n") {
1343            output.pop();
1344        }
1345    } else {
1346        output.push('\n');
1347    }
1348}
1349
1350fn template_for_path_with_includes<S>(
1351    path: &Path,
1352    include_paths: &[PathBuf],
1353) -> ConfigResult<String>
1354where
1355    S: ConfigSchema,
1356{
1357    let template = template_for_path::<S>(path)?;
1358    if include_paths.is_empty() {
1359        return Ok(template);
1360    }
1361
1362    let template = match ConfigFormat::from_path(path) {
1363        ConfigFormat::Yaml => {
1364            let template = strip_prefix_once(&template, "# Default value: []\n#include: []\n\n");
1365            format!("{}\n{template}", render_yaml_include(include_paths))
1366        }
1367        ConfigFormat::Toml => {
1368            let template = strip_prefix_once(&template, "# Default value: []\n#include = []\n\n");
1369            format!("{}\n{template}", render_toml_include(include_paths))
1370        }
1371        ConfigFormat::Json => {
1372            let body = template.strip_prefix("{\n").unwrap_or(&template);
1373            let body = strip_prefix_once(body, "  // Default value: []\n  //include: [],\n\n");
1374            format!("{{\n{}\n{body}", render_json5_include(include_paths))
1375        }
1376    };
1377
1378    Ok(template)
1379}
1380
1381fn render_yaml_include(paths: &[PathBuf]) -> String {
1382    let mut out = String::from("include:\n");
1383    for path in paths {
1384        out.push_str("  - ");
1385        out.push_str(&quote_path(path));
1386        out.push('\n');
1387    }
1388    out
1389}
1390
1391fn render_toml_include(paths: &[PathBuf]) -> String {
1392    let entries = paths
1393        .iter()
1394        .map(|path| quote_path(path))
1395        .collect::<Vec<_>>()
1396        .join(", ");
1397    format!("include = [{entries}]\n")
1398}
1399
1400fn render_json5_include(paths: &[PathBuf]) -> String {
1401    let mut out = String::from("  include: [\n");
1402    for path in paths {
1403        out.push_str("    ");
1404        out.push_str(&quote_path(path));
1405        out.push_str(",\n");
1406    }
1407    out.push_str("  ],\n");
1408    out
1409}
1410
1411fn quote_path(path: &Path) -> String {
1412    serde_json::to_string(&path.to_string_lossy()).expect("path string serialization cannot fail")
1413}
1414
1415fn strip_prefix_once<'a>(value: &'a str, prefix: &str) -> &'a str {
1416    value.strip_prefix(prefix).unwrap_or(value)
1417}
1418
1419fn yaml_options() -> confique::yaml::FormatOptions {
1420    let mut options = confique::yaml::FormatOptions::default();
1421    options.indent = 2;
1422    options.general.comments = true;
1423    options.general.env_keys = true;
1424    options.general.nested_field_gap = 1;
1425    options
1426}
1427
1428fn toml_options() -> confique::toml::FormatOptions {
1429    let mut options = confique::toml::FormatOptions::default();
1430    options.general.comments = true;
1431    options.general.env_keys = true;
1432    options.general.nested_field_gap = 1;
1433    options
1434}
1435
1436fn json5_options() -> confique::json5::FormatOptions {
1437    let mut options = confique::json5::FormatOptions::default();
1438    options.indent = 2;
1439    options.general.comments = true;
1440    options.general.env_keys = true;
1441    options.general.nested_field_gap = 1;
1442    options
1443}
1444
1445#[cfg(test)]
1446#[path = "unit_tests/config.rs"]
1447mod unit_tests;