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::{BTreeSet, 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 serde_json::Value;
29use tracing::trace;
30
31use crate::{
32    ConfigError, ConfigSource, ConfigTree, ConfigTreeOptions, IncludeOrder, absolutize_lexical,
33    collect_template_targets, normalize_lexical, select_template_source,
34};
35
36/// Result type used by the high-level configuration API.
37///
38/// The error type is [`ConfigError`].
39pub type ConfigResult<T> = std::result::Result<T, ConfigError>;
40
41/// A `confique` schema that can expose recursive include paths and template
42/// section layout.
43///
44/// Implement this trait for the same type that derives `confique::Config`.
45/// `include_paths` receives a partially loaded layer so the crate can discover
46/// child config files before the final schema is merged.
47pub trait ConfigSchema: Config + Sized {
48    /// Returns include paths declared by a loaded config layer.
49    ///
50    /// Relative paths are resolved from the file that declared them. Empty paths
51    /// are rejected before traversal continues.
52    ///
53    /// # Arguments
54    ///
55    /// - `layer`: Partially loaded `confique` layer for one config file.
56    ///
57    /// # Returns
58    ///
59    /// Returns include paths declared by `layer`.
60    fn include_paths(layer: &<Self as Config>::Layer) -> Vec<PathBuf>;
61
62    /// Overrides the generated template file path for a nested section.
63    ///
64    /// By default, top-level sections are generated as `config/<field>.yaml`
65    /// and nested sections as children of their parent section file stem, e.g.
66    /// `config/trading/risk.yaml`.
67    ///
68    /// # Arguments
69    ///
70    /// - `section_path`: Path of nested schema field names from the root schema
71    ///   to the section being rendered.
72    ///
73    /// # Returns
74    ///
75    /// Returns `Some(path)` to override the generated file path, or `None` to
76    /// use the default section path.
77    fn template_path_for_section(section_path: &[&str]) -> Option<PathBuf> {
78        let _ = section_path;
79        None
80    }
81}
82
83/// File format used when loading config files or rendering templates.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ConfigFormat {
86    /// YAML format, selected for `.yaml`, `.yml`, unknown extensions, and paths
87    /// without an extension.
88    Yaml,
89    /// TOML format, selected for `.toml`.
90    Toml,
91    /// JSON5-compatible format, selected for `.json` and `.json5`.
92    Json,
93}
94
95impl ConfigFormat {
96    /// Infers the config format from a path extension.
97    ///
98    /// Unknown extensions intentionally fall back to YAML.
99    ///
100    /// # Arguments
101    ///
102    /// - `path`: Config or template path whose extension should be inspected.
103    ///
104    /// # Returns
105    ///
106    /// Returns the inferred [`ConfigFormat`].
107    pub fn from_path(path: impl AsRef<Path>) -> Self {
108        match path.as_ref().extension().and_then(OsStr::to_str) {
109            Some("toml") => Self::Toml,
110            Some("json" | "json5") => Self::Json,
111            Some("yaml" | "yml") | Some(_) | None => Self::Yaml,
112        }
113    }
114
115    /// Converts this format into the `confique` file format used for loading.
116    ///
117    /// # Returns
118    ///
119    /// Returns the matching [`FileFormat`] value.
120    pub fn as_file_format(self) -> FileFormat {
121        match self {
122            Self::Yaml => FileFormat::Yaml,
123            Self::Toml => FileFormat::Toml,
124            Self::Json => FileFormat::Json5,
125        }
126    }
127}
128
129/// Generated template content for one output path.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ConfigTemplateTarget {
132    /// Path that should receive the generated content.
133    pub path: PathBuf,
134    /// Complete template content to write to `path`.
135    pub content: String,
136}
137
138/// Generated JSON Schema content for one output path.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct ConfigSchemaTarget {
141    /// Path that should receive the generated schema.
142    pub path: PathBuf,
143    /// Complete JSON Schema content to write to `path`.
144    pub content: String,
145}
146
147/// Figment provider that maps environment variables declared in `confique`
148/// schema metadata onto their exact field paths.
149///
150/// This provider reads `#[config(env = "...")]` from [`Config::META`] and
151/// avoids Figment's delimiter-based environment key splitting. Environment
152/// variables such as `APP_DATABASE_POOL_SIZE` can therefore map to a Rust field
153/// named `database.pool_size` without treating the single underscores as nested
154/// separators.
155#[derive(Clone)]
156pub struct ConfiqueEnvProvider {
157    env: Env,
158    path_to_env: Arc<HashMap<String, String>>,
159}
160
161impl ConfiqueEnvProvider {
162    /// Creates an environment provider for a `confique` schema.
163    ///
164    /// # Type Parameters
165    ///
166    /// - `S`: Config schema whose metadata declares environment variable names.
167    ///
168    /// # Returns
169    ///
170    /// Returns a provider that emits only environment variables declared by `S`.
171    pub fn new<S>() -> Self
172    where
173        S: Config,
174    {
175        let mut env_to_path = HashMap::<String, String>::new();
176        let mut path_to_env = HashMap::<String, String>::new();
177
178        collect_env_mapping(&S::META, "", &mut env_to_path, &mut path_to_env);
179
180        let env_to_path = Arc::new(env_to_path);
181        let path_to_env = Arc::new(path_to_env);
182        let map_for_filter = Arc::clone(&env_to_path);
183
184        let env = Env::raw().filter_map(move |env_key| {
185            let lookup_key = env_key.as_str().to_ascii_uppercase();
186
187            map_for_filter
188                .get(&lookup_key)
189                .cloned()
190                .map(Uncased::from_owned)
191        });
192
193        Self { env, path_to_env }
194    }
195}
196
197impl Provider for ConfiqueEnvProvider {
198    fn metadata(&self) -> Metadata {
199        let path_to_env = Arc::clone(&self.path_to_env);
200
201        Metadata::named("environment variable").interpolater(move |_profile, keys| {
202            let path = keys.join(".");
203
204            path_to_env.get(&path).cloned().unwrap_or(path)
205        })
206    }
207
208    fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
209        self.env.data()
210    }
211}
212
213/// Loads a complete `confique` schema from a root config path.
214///
215/// The loader follows recursive include paths exposed by [`ConfigSchema`],
216/// resolves relative include paths from the declaring file, detects include
217/// cycles, loads the first `.env` file found from the root config directory
218/// upward, builds a [`Figment`] from config files and schema-declared
219/// environment variables, and then asks `confique` to apply defaults and
220/// validation. Existing process environment variables take precedence over
221/// values loaded from `.env`.
222///
223/// # Type Parameters
224///
225/// - `S`: Config schema type that derives [`Config`] and implements
226///   [`ConfigSchema`].
227///
228/// # Arguments
229///
230/// - `path`: Root config file path.
231///
232/// # Returns
233///
234/// Returns the merged config schema after loading the root file, recursive
235/// includes, `.env` values, and environment values.
236pub fn load_config<S>(path: impl AsRef<Path>) -> ConfigResult<S>
237where
238    S: ConfigSchema,
239{
240    let (config, _) = load_config_with_figment::<S>(path)?;
241    Ok(config)
242}
243
244/// Loads a config schema and returns the Figment graph used for runtime loading.
245///
246/// The returned [`Figment`] can be inspected with [`Figment::find_metadata`] to
247/// determine which provider supplied a runtime value.
248///
249/// # Type Parameters
250///
251/// - `S`: Config schema type that derives [`Config`] and implements
252///   [`ConfigSchema`].
253///
254/// # Arguments
255///
256/// - `path`: Root config file path.
257///
258/// # Returns
259///
260/// Returns the merged config schema and its runtime Figment source graph.
261pub fn load_config_with_figment<S>(path: impl AsRef<Path>) -> ConfigResult<(S, Figment)>
262where
263    S: ConfigSchema,
264{
265    let figment = build_config_figment::<S>(path)?;
266    let config = load_config_from_figment::<S>(&figment)?;
267
268    Ok((config, figment))
269}
270
271/// Builds the Figment runtime source graph for a config tree.
272///
273/// Config files are merged in include order, then environment variables
274/// declared by [`ConfiqueEnvProvider`] are merged with higher priority.
275///
276/// # Type Parameters
277///
278/// - `S`: Config schema type used to discover includes and environment names.
279///
280/// # Arguments
281///
282/// - `path`: Root config file path.
283///
284/// # Returns
285///
286/// Returns a Figment source graph with file and environment providers.
287pub fn build_config_figment<S>(path: impl AsRef<Path>) -> ConfigResult<Figment>
288where
289    S: ConfigSchema,
290{
291    let path = path.as_ref();
292    load_dotenv_for_path(path)?;
293
294    let tree = load_layer_tree::<S>(path)?;
295    let mut figment = Figment::new();
296
297    for node in tree.nodes().iter().rev() {
298        figment = merge_file_provider(figment, node.path());
299    }
300
301    Ok(figment.merge(ConfiqueEnvProvider::new::<S>()))
302}
303
304/// Extracts and validates a config schema from a Figment source graph.
305///
306/// Figment supplies runtime values. `confique` supplies code defaults and final
307/// validation.
308///
309/// # Type Parameters
310///
311/// - `S`: Config schema type to extract and validate.
312///
313/// # Arguments
314///
315/// - `figment`: Runtime source graph.
316///
317/// # Returns
318///
319/// Returns the final config schema.
320pub fn load_config_from_figment<S>(figment: &Figment) -> ConfigResult<S>
321where
322    S: ConfigSchema,
323{
324    let runtime_layer: <S as Config>::Layer = figment.extract()?;
325    let config = S::from_layer(runtime_layer.with_fallback(S::Layer::default_values()))?;
326
327    trace_config_sources::<S>(figment);
328
329    Ok(config)
330}
331
332/// Loads one config layer from disk using the format inferred from the path.
333///
334/// # Type Parameters
335///
336/// - `S`: Config schema type whose intermediate `confique` layer should be
337///   loaded.
338///
339/// # Arguments
340///
341/// - `path`: Config file path to load.
342///
343/// # Returns
344///
345/// Returns the loaded `confique` layer for `S`.
346pub fn load_layer<S>(path: &Path) -> ConfigResult<<S as Config>::Layer>
347where
348    S: ConfigSchema,
349{
350    Ok(figment_for_file(path).extract()?)
351}
352
353fn load_layer_tree<S>(path: &Path) -> ConfigResult<ConfigTree<<S as Config>::Layer>>
354where
355    S: ConfigSchema,
356{
357    Ok(ConfigTreeOptions::default()
358        .include_order(IncludeOrder::Reverse)
359        .load(
360            path,
361            |path| -> ConfigResult<ConfigSource<<S as Config>::Layer>> {
362                let layer = load_layer::<S>(path)?;
363                let include_paths = S::include_paths(&layer);
364                Ok(ConfigSource::new(layer, include_paths))
365            },
366        )?)
367}
368
369fn merge_file_provider(figment: Figment, path: &Path) -> Figment {
370    match ConfigFormat::from_path(path) {
371        ConfigFormat::Yaml => figment.merge(Yaml::file_exact(path)),
372        ConfigFormat::Toml => figment.merge(Toml::file_exact(path)),
373        ConfigFormat::Json => figment.merge(Json::file_exact(path)),
374    }
375}
376
377fn figment_for_file(path: &Path) -> Figment {
378    merge_file_provider(Figment::new(), path)
379}
380
381fn root_config_schema<S>() -> ConfigResult<Value>
382where
383    S: JsonSchema,
384{
385    let generator = SchemaSettings::draft07().into_generator();
386    let schema = generator.into_root_schema_for::<S>();
387    let mut schema = serde_json::to_value(schema)?;
388    remove_required_recursively(&mut schema);
389
390    Ok(schema)
391}
392
393fn schema_json(schema: &Value) -> ConfigResult<String> {
394    let mut json = serde_json::to_string_pretty(schema)?;
395    ensure_single_trailing_newline(&mut json);
396    Ok(json)
397}
398
399fn remove_required_recursively(value: &mut Value) {
400    match value {
401        Value::Object(object) => {
402            object.remove("required");
403
404            for (key, child) in object.iter_mut() {
405                if is_schema_map_key(key) {
406                    remove_required_from_schema_map(child);
407                } else {
408                    remove_required_recursively(child);
409                }
410            }
411        }
412        Value::Array(items) => {
413            for item in items {
414                remove_required_recursively(item);
415            }
416        }
417        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
418    }
419}
420
421fn is_schema_map_key(key: &str) -> bool {
422    matches!(
423        key,
424        "$defs" | "definitions" | "properties" | "patternProperties"
425    )
426}
427
428fn remove_required_from_schema_map(value: &mut Value) {
429    match value {
430        Value::Object(object) => {
431            for schema in object.values_mut() {
432                remove_required_recursively(schema);
433            }
434        }
435        _ => remove_required_recursively(value),
436    }
437}
438
439fn section_schema_for_path(root_schema: &Value, section_path: &[&str]) -> Option<Value> {
440    let mut current = root_schema;
441
442    for section in section_path {
443        current = current.get("properties")?.get(*section)?;
444        current = resolve_schema_reference(root_schema, current).unwrap_or(current);
445    }
446
447    Some(standalone_section_schema(root_schema, current))
448}
449
450fn resolve_schema_reference<'a>(root_schema: &'a Value, schema: &'a Value) -> Option<&'a Value> {
451    if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
452        return resolve_json_pointer_ref(root_schema, reference);
453    }
454
455    schema
456        .get("allOf")
457        .and_then(Value::as_array)
458        .and_then(|schemas| schemas.first())
459        .and_then(|schema| schema.get("$ref"))
460        .and_then(Value::as_str)
461        .and_then(|reference| resolve_json_pointer_ref(root_schema, reference))
462}
463
464fn resolve_json_pointer_ref<'a>(root_schema: &'a Value, reference: &str) -> Option<&'a Value> {
465    let pointer = reference.strip_prefix('#')?;
466    root_schema.pointer(pointer)
467}
468
469fn standalone_section_schema(root_schema: &Value, section_schema: &Value) -> Value {
470    let mut section_schema = section_schema.clone();
471    let Some(object) = section_schema.as_object_mut() else {
472        return section_schema;
473    };
474
475    if let Some(schema_uri) = root_schema.get("$schema") {
476        object
477            .entry("$schema".to_owned())
478            .or_insert_with(|| schema_uri.clone());
479    }
480
481    if let Some(definitions) = root_schema.get("definitions") {
482        object
483            .entry("definitions".to_owned())
484            .or_insert_with(|| definitions.clone());
485    }
486
487    if let Some(defs) = root_schema.get("$defs") {
488        object
489            .entry("$defs".to_owned())
490            .or_insert_with(|| defs.clone());
491    }
492
493    section_schema
494}
495
496fn schema_path_for_section(root_schema_path: &Path, section_path: &[&str]) -> PathBuf {
497    let Some((last, parents)) = section_path.split_last() else {
498        return root_schema_path.to_path_buf();
499    };
500
501    let mut path = root_schema_path
502        .parent()
503        .unwrap_or_else(|| Path::new("."))
504        .to_path_buf();
505
506    for parent in parents {
507        path.push(*parent);
508    }
509
510    path.push(format!("{}.schema.json", *last));
511    path
512}
513
514fn schema_for_output_path<S>(
515    full_schema: &Value,
516    section_path: &[&'static str],
517) -> ConfigResult<Value>
518where
519    S: ConfigSchema,
520{
521    let mut schema = if section_path.is_empty() {
522        full_schema.clone()
523    } else {
524        section_schema_for_path(full_schema, section_path).ok_or_else(|| {
525            std::io::Error::new(
526                std::io::ErrorKind::InvalidData,
527                format!(
528                    "failed to extract JSON Schema for config section {}",
529                    section_path.join(".")
530                ),
531            )
532        })?
533    };
534
535    remove_child_section_properties::<S>(&mut schema, section_path);
536    prune_unused_schema_maps(&mut schema);
537
538    Ok(schema)
539}
540
541fn remove_child_section_properties<S>(schema: &mut Value, section_path: &[&'static str])
542where
543    S: ConfigSchema,
544{
545    let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
546        return;
547    };
548
549    for child_section_path in immediate_child_section_paths(&S::META, section_path) {
550        if let Some(child_name) = child_section_path.last() {
551            properties.remove(*child_name);
552        }
553    }
554}
555
556fn prune_unused_schema_maps(schema: &mut Value) {
557    let mut definitions = BTreeSet::new();
558    let mut defs = BTreeSet::new();
559
560    collect_schema_refs(schema, false, &mut definitions, &mut defs);
561
562    loop {
563        let previous_len = definitions.len() + defs.len();
564        collect_transitive_schema_refs(schema, &mut definitions, &mut defs);
565
566        if definitions.len() + defs.len() == previous_len {
567            break;
568        }
569    }
570
571    retain_schema_map(schema, "definitions", &definitions);
572    retain_schema_map(schema, "$defs", &defs);
573}
574
575fn collect_transitive_schema_refs(
576    schema: &Value,
577    definitions: &mut BTreeSet<String>,
578    defs: &mut BTreeSet<String>,
579) {
580    let current_definitions = definitions.clone();
581    let current_defs = defs.clone();
582    let mut referenced_definitions = BTreeSet::new();
583    let mut referenced_defs = BTreeSet::new();
584
585    if let Some(schema_map) = schema.get("definitions").and_then(Value::as_object) {
586        for name in &current_definitions {
587            if let Some(schema) = schema_map.get(name) {
588                collect_schema_refs(
589                    schema,
590                    true,
591                    &mut referenced_definitions,
592                    &mut referenced_defs,
593                );
594            }
595        }
596    }
597
598    if let Some(schema_map) = schema.get("$defs").and_then(Value::as_object) {
599        for name in &current_defs {
600            if let Some(schema) = schema_map.get(name) {
601                collect_schema_refs(
602                    schema,
603                    true,
604                    &mut referenced_definitions,
605                    &mut referenced_defs,
606                );
607            }
608        }
609    }
610
611    definitions.extend(referenced_definitions);
612    defs.extend(referenced_defs);
613}
614
615fn collect_schema_refs(
616    value: &Value,
617    include_schema_maps: bool,
618    definitions: &mut BTreeSet<String>,
619    defs: &mut BTreeSet<String>,
620) {
621    match value {
622        Value::Object(object) => {
623            if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
624                collect_schema_ref(reference, definitions, defs);
625            }
626
627            for (key, child) in object {
628                if !include_schema_maps && matches!(key.as_str(), "definitions" | "$defs") {
629                    continue;
630                }
631
632                collect_schema_refs(child, include_schema_maps, definitions, defs);
633            }
634        }
635        Value::Array(items) => {
636            for item in items {
637                collect_schema_refs(item, include_schema_maps, definitions, defs);
638            }
639        }
640        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
641    }
642}
643
644fn collect_schema_ref(
645    reference: &str,
646    definitions: &mut BTreeSet<String>,
647    defs: &mut BTreeSet<String>,
648) {
649    if let Some(name) = schema_ref_name(reference, "#/definitions/") {
650        definitions.insert(name);
651    } else if let Some(name) = schema_ref_name(reference, "#/$defs/") {
652        defs.insert(name);
653    }
654}
655
656fn schema_ref_name(reference: &str, prefix: &str) -> Option<String> {
657    let name = reference.strip_prefix(prefix)?.split('/').next()?;
658    Some(decode_json_pointer_token(name))
659}
660
661fn decode_json_pointer_token(token: &str) -> String {
662    token.replace("~1", "/").replace("~0", "~")
663}
664
665fn retain_schema_map(schema: &mut Value, key: &str, used_names: &BTreeSet<String>) {
666    let Some(object) = schema.as_object_mut() else {
667        return;
668    };
669
670    let Some(schema_map) = object.get_mut(key).and_then(Value::as_object_mut) else {
671        return;
672    };
673
674    schema_map.retain(|name, _| used_names.contains(name));
675
676    if schema_map.is_empty() {
677        object.remove(key);
678    }
679}
680
681/// Writes a Draft 7 JSON Schema for the root config type.
682///
683/// The same generated schema can be referenced from TOML, YAML, and JSON
684/// configuration files. TOML and YAML templates can bind it with editor
685/// directives. JSON files should usually be bound through editor settings
686/// rather than a runtime `$schema` field. Generated schemas omit JSON Schema
687/// `required` constraints so editors provide completion without requiring every
688/// config field to exist in each partial config file.
689///
690/// # Type Parameters
691///
692/// - `S`: Config schema type that derives [`JsonSchema`].
693///
694/// # Arguments
695///
696/// - `output_path`: Destination path for the generated JSON Schema.
697///
698/// # Returns
699///
700/// Returns `Ok(())` after the schema file has been written.
701pub fn write_config_schema<S>(output_path: impl AsRef<Path>) -> ConfigResult<()>
702where
703    S: JsonSchema,
704{
705    let schema = root_config_schema::<S>()?;
706    let json = schema_json(&schema)?;
707
708    write_template(output_path.as_ref(), &json)
709}
710
711/// Collects the root schema and section schemas for a config type.
712///
713/// The root schema is written to `output_path`. Nested `confique` sections are
714/// written next to it as `<section>.schema.json`; deeper sections are nested in
715/// matching directories, for example `schemas/outer/inner.schema.json`. Each
716/// generated schema contains only the fields for its own template file; child
717/// section fields are omitted and completed by their own section schemas.
718///
719/// # Type Parameters
720///
721/// - `S`: Config schema type that derives [`JsonSchema`] and exposes section
722///   metadata through [`ConfigSchema`].
723///
724/// # Arguments
725///
726/// - `output_path`: Destination path for the root JSON Schema.
727///
728/// # Returns
729///
730/// Returns all generated schema targets in traversal order.
731pub fn config_schema_targets_for_path<S>(
732    output_path: impl AsRef<Path>,
733) -> ConfigResult<Vec<ConfigSchemaTarget>>
734where
735    S: ConfigSchema + JsonSchema,
736{
737    let output_path = output_path.as_ref();
738    let full_schema = root_config_schema::<S>()?;
739    let root_schema = schema_for_output_path::<S>(&full_schema, &[])?;
740    let mut targets = vec![ConfigSchemaTarget {
741        path: output_path.to_path_buf(),
742        content: schema_json(&root_schema)?,
743    }];
744
745    for section_path in nested_section_paths(&S::META) {
746        let schema_path = schema_path_for_section(output_path, &section_path);
747        let section_schema = schema_for_output_path::<S>(&full_schema, &section_path)?;
748
749        targets.push(ConfigSchemaTarget {
750            path: schema_path,
751            content: schema_json(&section_schema)?,
752        });
753    }
754
755    Ok(targets)
756}
757
758/// Writes the root schema and section schemas for a config type.
759///
760/// Parent directories are created before each schema is written. Generated
761/// schemas omit JSON Schema `required` constraints so they can be used for IDE
762/// completion against partial config files. The root schema does not complete
763/// nested section fields.
764///
765/// # Type Parameters
766///
767/// - `S`: Config schema type that derives [`JsonSchema`] and exposes section
768///   metadata through [`ConfigSchema`].
769///
770/// # Arguments
771///
772/// - `output_path`: Destination path for the root JSON Schema.
773///
774/// # Returns
775///
776/// Returns `Ok(())` after all schema files have been written.
777pub fn write_config_schemas<S>(output_path: impl AsRef<Path>) -> ConfigResult<()>
778where
779    S: ConfigSchema + JsonSchema,
780{
781    for target in config_schema_targets_for_path::<S>(output_path)? {
782        write_template(&target.path, &target.content)?;
783    }
784
785    Ok(())
786}
787
788/// Renders the default template for one path.
789///
790/// The template format is inferred from the path extension.
791///
792/// # Type Parameters
793///
794/// - `S`: Config schema type used to render the template.
795///
796/// # Arguments
797///
798/// - `path`: Output path whose extension selects the template format.
799///
800/// # Returns
801///
802/// Returns the generated template content.
803pub fn template_for_path<S>(path: impl AsRef<Path>) -> ConfigResult<String>
804where
805    S: ConfigSchema,
806{
807    let template = match ConfigFormat::from_path(path.as_ref()) {
808        ConfigFormat::Yaml => confique::yaml::template::<S>(yaml_options()),
809        ConfigFormat::Toml => confique::toml::template::<S>(toml_options()),
810        ConfigFormat::Json => confique::json5::template::<S>(json5_options()),
811    };
812
813    Ok(template)
814}
815
816/// Collects all template targets that should be generated for a config tree.
817///
818/// The root template source is selected with [`select_template_source`]. Include
819/// paths found in the source tree are mirrored under `output_path` for relative
820/// includes. When a source node has no includes, nested `confique` sections are
821/// used to derive child template files with paths from
822/// [`ConfigSchema::template_path_for_section`].
823///
824/// # Type Parameters
825///
826/// - `S`: Config schema type used to discover includes and render templates.
827///
828/// # Arguments
829///
830/// - `config_path`: Root config path preferred as the template source when it
831///   exists.
832/// - `output_path`: Root output path for generated templates.
833///
834/// # Returns
835///
836/// Returns all generated template targets in traversal order.
837pub fn template_targets_for_paths<S>(
838    config_path: impl AsRef<Path>,
839    output_path: impl AsRef<Path>,
840) -> ConfigResult<Vec<ConfigTemplateTarget>>
841where
842    S: ConfigSchema,
843{
844    let output_path = output_path.as_ref();
845    let source_path = select_template_source(config_path, output_path);
846    let root_source_path = absolutize_lexical(source_path)?;
847    let output_base_dir = output_path.parent().unwrap_or_else(|| Path::new("."));
848
849    let template_targets = collect_template_targets(
850        &root_source_path,
851        output_path,
852        |node_source_path| -> ConfigResult<Vec<PathBuf>> {
853            let mut include_paths = template_source_include_paths::<S>(node_source_path)?;
854
855            if include_paths.is_empty() {
856                include_paths =
857                    default_child_include_paths::<S>(&root_source_path, node_source_path);
858            }
859
860            Ok(include_paths)
861        },
862    )?;
863
864    let split_paths = template_targets
865        .iter()
866        .filter_map(|target| {
867            section_path_for_target::<S>(output_base_dir, target.target_path())
868                .filter(|section_path| !section_path.is_empty())
869        })
870        .collect::<Vec<_>>();
871
872    template_targets
873        .into_iter()
874        .map(|target| {
875            let (_, target_path, include_paths) = target.into_parts();
876            let section_path =
877                section_path_for_target::<S>(output_base_dir, &target_path).unwrap_or_default();
878            Ok(ConfigTemplateTarget {
879                content: template_for_target::<S>(
880                    &target_path,
881                    &include_paths,
882                    &section_path,
883                    &split_paths,
884                )?,
885                path: target_path,
886            })
887        })
888        .collect()
889}
890
891/// Collects template targets and binds TOML/YAML templates to JSON Schemas.
892///
893/// TOML targets receive a `#:schema` directive. YAML targets receive a YAML
894/// Language Server modeline. JSON and JSON5 targets are left unchanged so the
895/// runtime configuration is not polluted with a `$schema` field. Root targets
896/// bind `schema_path`; nested section targets bind their generated section
897/// schema path.
898///
899/// # Type Parameters
900///
901/// - `S`: Config schema type used to discover includes and render templates.
902///
903/// # Arguments
904///
905/// - `config_path`: Root config path preferred as the template source when it
906///   exists.
907/// - `output_path`: Root output path for generated templates.
908/// - `schema_path`: Root JSON Schema path to reference from root TOML/YAML
909///   templates.
910///
911/// # Returns
912///
913/// Returns all generated template targets in traversal order.
914pub fn template_targets_for_paths_with_schema<S>(
915    config_path: impl AsRef<Path>,
916    output_path: impl AsRef<Path>,
917    schema_path: impl AsRef<Path>,
918) -> ConfigResult<Vec<ConfigTemplateTarget>>
919where
920    S: ConfigSchema,
921{
922    let output_path = output_path.as_ref();
923    let output_base_dir = output_path.parent().unwrap_or_else(|| Path::new("."));
924    let schema_path = schema_path.as_ref();
925
926    template_targets_for_paths::<S>(config_path, output_path)?
927        .into_iter()
928        .map(|mut target| {
929            let schema_path =
930                schema_path_for_template_target::<S>(output_base_dir, &target.path, schema_path);
931            target.content =
932                template_with_schema_directive(&target.path, &schema_path, &target.content)?;
933            Ok(target)
934        })
935        .collect()
936}
937
938/// Writes all generated config templates for a config tree.
939///
940/// Parent directories are created before each target is written.
941///
942/// # Type Parameters
943///
944/// - `S`: Config schema type used to discover includes and render templates.
945///
946/// # Arguments
947///
948/// - `config_path`: Root config path preferred as the template source when it
949///   exists.
950/// - `output_path`: Root output path for generated templates.
951///
952/// # Returns
953///
954/// Returns `Ok(())` after all template files have been written.
955pub fn write_config_templates<S>(
956    config_path: impl AsRef<Path>,
957    output_path: impl AsRef<Path>,
958) -> ConfigResult<()>
959where
960    S: ConfigSchema,
961{
962    for target in template_targets_for_paths::<S>(config_path, output_path)? {
963        write_template(&target.path, &target.content)?;
964    }
965
966    Ok(())
967}
968
969/// Writes all generated config templates with editor schema bindings.
970///
971/// TOML targets receive `#:schema <path>`, YAML targets receive
972/// `# yaml-language-server: $schema=<path>`, and JSON targets are left
973/// unchanged. The schema path is rendered relative to each template file. Root
974/// targets bind `schema_path`; nested section targets bind their generated
975/// section schema path.
976///
977/// # Type Parameters
978///
979/// - `S`: Config schema type used to discover includes and render templates.
980///
981/// # Arguments
982///
983/// - `config_path`: Root config path preferred as the template source when it
984///   exists.
985/// - `output_path`: Root output path for generated templates.
986/// - `schema_path`: Root JSON Schema path to reference from root TOML/YAML
987///   templates.
988///
989/// # Returns
990///
991/// Returns `Ok(())` after all template files have been written.
992pub fn write_config_templates_with_schema<S>(
993    config_path: impl AsRef<Path>,
994    output_path: impl AsRef<Path>,
995    schema_path: impl AsRef<Path>,
996) -> ConfigResult<()>
997where
998    S: ConfigSchema,
999{
1000    for target in
1001        template_targets_for_paths_with_schema::<S>(config_path, output_path, schema_path)?
1002    {
1003        write_template(&target.path, &target.content)?;
1004    }
1005
1006    Ok(())
1007}
1008
1009/// Writes one generated template file, creating parent directories first.
1010///
1011/// # Arguments
1012///
1013/// - `path`: Destination file path.
1014/// - `content`: Complete template content to write.
1015///
1016/// # Returns
1017///
1018/// Returns `Ok(())` after the file has been written.
1019pub(crate) fn write_template(path: &Path, content: &str) -> ConfigResult<()> {
1020    if let Some(parent) = path
1021        .parent()
1022        .filter(|parent| !parent.as_os_str().is_empty())
1023    {
1024        fs::create_dir_all(parent)?;
1025    }
1026
1027    fs::write(path, content)?;
1028    Ok(())
1029}
1030
1031/// Resolves the CLI template output path to a normalized absolute path.
1032///
1033/// # Arguments
1034///
1035/// - `output`: Optional user-provided output path. When omitted,
1036///   `config.example.yaml` is used.
1037///
1038/// # Returns
1039///
1040/// Returns a normalized absolute output path.
1041pub(crate) fn resolve_config_template_output(output: Option<PathBuf>) -> ConfigResult<PathBuf> {
1042    let current_dir = std::env::current_dir()?;
1043    let output = output.unwrap_or_else(|| PathBuf::from("config.example.yaml"));
1044    let output = if output.is_absolute() {
1045        output
1046    } else {
1047        current_dir.join(output)
1048    };
1049
1050    Ok(normalize_lexical(output))
1051}
1052
1053fn template_source_include_paths<S>(path: &Path) -> ConfigResult<Vec<PathBuf>>
1054where
1055    S: ConfigSchema,
1056{
1057    if !path.exists() {
1058        return Ok(Vec::new());
1059    }
1060
1061    match load_layer::<S>(path) {
1062        Ok(layer) => Ok(S::include_paths(&layer)),
1063        Err(_) => load_include_paths_only(path),
1064    }
1065}
1066
1067fn load_include_paths_only(path: &Path) -> ConfigResult<Vec<PathBuf>> {
1068    match figment_for_file(path).extract_inner::<Vec<PathBuf>>("include") {
1069        Ok(paths) => Ok(paths),
1070        Err(error) if error.missing() => Ok(Vec::new()),
1071        Err(error) => Err(error.into()),
1072    }
1073}
1074
1075fn schema_path_for_template_target<S>(
1076    root_base_dir: &Path,
1077    target_path: &Path,
1078    root_schema_path: &Path,
1079) -> PathBuf
1080where
1081    S: ConfigSchema,
1082{
1083    section_path_for_target::<S>(root_base_dir, target_path)
1084        .filter(|section_path| !section_path.is_empty())
1085        .map(|section_path| schema_path_for_section(root_schema_path, &section_path))
1086        .unwrap_or_else(|| root_schema_path.to_path_buf())
1087}
1088
1089fn template_with_schema_directive(
1090    template_path: &Path,
1091    schema_path: &Path,
1092    content: &str,
1093) -> ConfigResult<String> {
1094    let schema_ref = schema_reference_for_path(template_path, schema_path)?;
1095    let directive = match ConfigFormat::from_path(template_path) {
1096        ConfigFormat::Yaml => Some(format!("# yaml-language-server: $schema={schema_ref}")),
1097        ConfigFormat::Toml => Some(format!("#:schema {schema_ref}")),
1098        ConfigFormat::Json => None,
1099    };
1100
1101    let Some(directive) = directive else {
1102        return Ok(content.to_owned());
1103    };
1104
1105    Ok(format!("{directive}\n\n{content}"))
1106}
1107
1108fn schema_reference_for_path(template_path: &Path, schema_path: &Path) -> ConfigResult<String> {
1109    let template_path = absolutize_lexical(template_path)?;
1110    let schema_path = absolutize_lexical(schema_path)?;
1111    let template_dir = template_path.parent().unwrap_or_else(|| Path::new("."));
1112    let relative_path = relative_path_from(&schema_path, template_dir);
1113    Ok(render_schema_reference(&relative_path))
1114}
1115
1116fn relative_path_from(path: &Path, base: &Path) -> PathBuf {
1117    let path_components = path.components().collect::<Vec<_>>();
1118    let base_components = base.components().collect::<Vec<_>>();
1119
1120    let mut common_len = 0;
1121    while common_len < path_components.len()
1122        && common_len < base_components.len()
1123        && path_components[common_len] == base_components[common_len]
1124    {
1125        common_len += 1;
1126    }
1127
1128    if common_len == 0 {
1129        return path.to_path_buf();
1130    }
1131
1132    let mut relative = PathBuf::new();
1133    for component in &base_components[common_len..] {
1134        if matches!(component, Component::Normal(_)) {
1135            relative.push("..");
1136        }
1137    }
1138
1139    for component in &path_components[common_len..] {
1140        relative.push(component.as_os_str());
1141    }
1142
1143    if relative.as_os_str().is_empty() {
1144        PathBuf::from(".")
1145    } else {
1146        relative
1147    }
1148}
1149
1150fn render_schema_reference(path: &Path) -> String {
1151    let value = path.to_string_lossy().replace('\\', "/");
1152    if path.is_absolute() || value.starts_with("../") || value.starts_with("./") {
1153        value
1154    } else {
1155        format!("./{value}")
1156    }
1157}
1158
1159fn template_for_target<S>(
1160    path: &Path,
1161    include_paths: &[PathBuf],
1162    section_path: &[&'static str],
1163    split_paths: &[Vec<&'static str>],
1164) -> ConfigResult<String>
1165where
1166    S: ConfigSchema,
1167{
1168    if ConfigFormat::from_path(path) != ConfigFormat::Yaml || split_paths.is_empty() {
1169        return template_for_path_with_includes::<S>(path, include_paths);
1170    }
1171
1172    Ok(render_yaml_template(
1173        &S::META,
1174        include_paths,
1175        section_path,
1176        split_paths,
1177    ))
1178}
1179
1180fn default_child_include_paths<S>(root_source_path: &Path, node_source_path: &Path) -> Vec<PathBuf>
1181where
1182    S: ConfigSchema,
1183{
1184    let root_base_dir = root_source_path.parent().unwrap_or_else(|| Path::new("."));
1185    let section_path =
1186        section_path_for_target::<S>(root_base_dir, node_source_path).unwrap_or_default();
1187    let source_base_dir = node_source_path.parent().unwrap_or_else(|| Path::new("."));
1188
1189    immediate_child_section_paths(&S::META, &section_path)
1190        .into_iter()
1191        .map(|child_section_path| {
1192            let child_path =
1193                root_base_dir.join(template_path_for_section::<S>(&child_section_path));
1194            path_relative_to(&child_path, source_base_dir)
1195        })
1196        .collect()
1197}
1198
1199fn collect_env_mapping(
1200    meta: &'static Meta,
1201    prefix: &str,
1202    env_to_path: &mut HashMap<String, String>,
1203    path_to_env: &mut HashMap<String, String>,
1204) {
1205    for field in meta.fields {
1206        let path = if prefix.is_empty() {
1207            field.name.to_owned()
1208        } else {
1209            format!("{prefix}.{}", field.name)
1210        };
1211
1212        match field.kind {
1213            FieldKind::Leaf { env: Some(env), .. } => {
1214                env_to_path.insert(env.to_ascii_uppercase(), path.clone());
1215                path_to_env.insert(path, env.to_owned());
1216            }
1217            FieldKind::Leaf { env: None, .. } => {}
1218            FieldKind::Nested { meta } => {
1219                collect_env_mapping(meta, &path, env_to_path, path_to_env);
1220            }
1221        }
1222    }
1223}
1224
1225fn load_dotenv_for_path(path: &Path) -> ConfigResult<()> {
1226    let path = absolutize_lexical(path)?;
1227    let mut current_dir = path.parent();
1228
1229    while let Some(dir) = current_dir {
1230        let dotenv_path = dir.join(".env");
1231        if dotenv_path.try_exists()? {
1232            dotenvy::from_path(&dotenv_path)?;
1233            break;
1234        }
1235        current_dir = dir.parent();
1236    }
1237
1238    Ok(())
1239}
1240
1241fn section_path_for_target<S>(root_base_dir: &Path, target_path: &Path) -> Option<Vec<&'static str>>
1242where
1243    S: ConfigSchema,
1244{
1245    let normalized_target = normalize_lexical(target_path);
1246
1247    for section_path in nested_section_paths(&S::META) {
1248        let section_target =
1249            normalize_lexical(root_base_dir.join(template_path_for_section::<S>(&section_path)));
1250        if section_target == normalized_target {
1251            return Some(section_path);
1252        }
1253    }
1254
1255    infer_section_path_from_path::<S>(target_path)
1256}
1257
1258fn template_path_for_section<S>(section_path: &[&str]) -> PathBuf
1259where
1260    S: ConfigSchema,
1261{
1262    if let Some(path) = S::template_path_for_section(section_path) {
1263        return path;
1264    }
1265
1266    let Some((last, parent_path)) = section_path.split_last() else {
1267        return PathBuf::new();
1268    };
1269
1270    if parent_path.is_empty() {
1271        return PathBuf::from("config").join(format!("{last}.yaml"));
1272    }
1273
1274    let parent_template_path = template_path_for_section::<S>(parent_path);
1275    parent_template_path
1276        .with_extension("")
1277        .join(format!("{last}.yaml"))
1278}
1279
1280fn path_relative_to(path: &Path, base: &Path) -> PathBuf {
1281    match path.strip_prefix(base) {
1282        Ok(relative) if !relative.as_os_str().is_empty() => relative.to_path_buf(),
1283        _ => path.to_path_buf(),
1284    }
1285}
1286
1287fn nested_section_paths(meta: &'static Meta) -> Vec<Vec<&'static str>> {
1288    let mut paths = Vec::new();
1289    collect_nested_section_paths(meta, &mut Vec::new(), &mut paths);
1290    paths
1291}
1292
1293fn collect_nested_section_paths(
1294    meta: &'static Meta,
1295    prefix: &mut Vec<&'static str>,
1296    paths: &mut Vec<Vec<&'static str>>,
1297) {
1298    for field in meta.fields {
1299        if let FieldKind::Nested { meta } = field.kind {
1300            prefix.push(field.name);
1301            paths.push(prefix.clone());
1302            collect_nested_section_paths(meta, prefix, paths);
1303            prefix.pop();
1304        }
1305    }
1306}
1307
1308fn immediate_child_section_paths(
1309    meta: &'static Meta,
1310    section_path: &[&'static str],
1311) -> Vec<Vec<&'static str>> {
1312    let Some(section_meta) = meta_at_path(meta, section_path) else {
1313        return Vec::new();
1314    };
1315
1316    section_meta
1317        .fields
1318        .iter()
1319        .filter_map(|field| match field.kind {
1320            FieldKind::Nested { .. } => {
1321                let mut path = section_path.to_vec();
1322                path.push(field.name);
1323                Some(path)
1324            }
1325            FieldKind::Leaf { .. } => None,
1326        })
1327        .collect()
1328}
1329
1330/// Emits Figment source metadata for every leaf field at TRACE level.
1331///
1332/// This function returns immediately unless `tracing` has TRACE enabled. Callers
1333/// can invoke it after initializing their tracing subscriber from the loaded log
1334/// configuration.
1335///
1336/// # Type Parameters
1337///
1338/// - `S`: Config schema whose metadata declares the field paths to trace.
1339///
1340/// # Arguments
1341///
1342/// - `figment`: Runtime source graph used to load the config.
1343///
1344/// # Returns
1345///
1346/// This function only emits tracing events and returns no value.
1347pub fn trace_config_sources<S>(figment: &Figment)
1348where
1349    S: ConfigSchema,
1350{
1351    if !tracing::enabled!(tracing::Level::TRACE) {
1352        return;
1353    }
1354
1355    for path in leaf_config_paths(&S::META) {
1356        let source = config_source_for_path(figment, &path);
1357        trace!(target: "rust_config_tree::config", config_key = %path, source = %source, "config source");
1358    }
1359}
1360
1361fn config_source_for_path(figment: &Figment, path: &str) -> String {
1362    match figment.find_metadata(path) {
1363        Some(metadata) => render_metadata(metadata, path),
1364        None => "confique default or unset optional field".to_owned(),
1365    }
1366}
1367
1368fn render_metadata(metadata: &Metadata, path: &str) -> String {
1369    match &metadata.source {
1370        Some(Source::File(path)) => format!("{} `{}`", metadata.name, path.display()),
1371        Some(Source::Custom(value)) => format!("{} `{value}`", metadata.name),
1372        Some(Source::Code(location)) => {
1373            format!("{} {}:{}", metadata.name, location.file(), location.line())
1374        }
1375        Some(_) => metadata.name.to_string(),
1376        None => {
1377            let parts = path.split('.').collect::<Vec<_>>();
1378            let native = metadata.interpolate(&Profile::Default, &parts);
1379
1380            format!("{} `{native}`", metadata.name)
1381        }
1382    }
1383}
1384
1385fn leaf_config_paths(meta: &'static Meta) -> Vec<String> {
1386    let mut paths = Vec::new();
1387    collect_leaf_config_paths(meta, "", &mut paths);
1388    paths
1389}
1390
1391fn collect_leaf_config_paths(meta: &'static Meta, prefix: &str, paths: &mut Vec<String>) {
1392    for field in meta.fields {
1393        let path = if prefix.is_empty() {
1394            field.name.to_owned()
1395        } else {
1396            format!("{prefix}.{}", field.name)
1397        };
1398
1399        match field.kind {
1400            FieldKind::Leaf { .. } => paths.push(path),
1401            FieldKind::Nested { meta } => collect_leaf_config_paths(meta, &path, paths),
1402        }
1403    }
1404}
1405
1406fn infer_section_path_from_path<S>(path: &Path) -> Option<Vec<&'static str>>
1407where
1408    S: ConfigSchema,
1409{
1410    let path_tokens = normalized_path_tokens(path);
1411    let file_token = path
1412        .file_stem()
1413        .and_then(OsStr::to_str)
1414        .map(normalize_token)
1415        .unwrap_or_default();
1416
1417    nested_section_paths(&S::META)
1418        .into_iter()
1419        .filter_map(|section_path| {
1420            let score = section_path_score(&section_path, &path_tokens, &file_token);
1421            (score > 0).then_some((score, section_path))
1422        })
1423        .max_by_key(|(score, section_path)| (*score, section_path.len()))
1424        .map(|(_, section_path)| section_path)
1425}
1426
1427fn normalized_path_tokens(path: &Path) -> Vec<String> {
1428    path.components()
1429        .filter_map(|component| component.as_os_str().to_str())
1430        .map(|component| {
1431            Path::new(component)
1432                .file_stem()
1433                .and_then(OsStr::to_str)
1434                .unwrap_or(component)
1435        })
1436        .map(normalize_token)
1437        .filter(|component| !component.is_empty())
1438        .collect()
1439}
1440
1441fn normalize_token(token: &str) -> String {
1442    token
1443        .chars()
1444        .filter_map(|character| match character {
1445            '-' | ' ' => Some('_'),
1446            '_' => Some('_'),
1447            character if character.is_ascii_alphanumeric() => Some(character.to_ascii_lowercase()),
1448            _ => None,
1449        })
1450        .collect()
1451}
1452
1453fn section_path_score(section_path: &[&str], path_tokens: &[String], file_token: &str) -> usize {
1454    let section_tokens = section_path
1455        .iter()
1456        .map(|segment| normalize_token(segment))
1457        .collect::<Vec<_>>();
1458
1459    if path_tokens.ends_with(&section_tokens) {
1460        return 1_000 + section_tokens.len();
1461    }
1462
1463    let Some(last_section_token) = section_tokens.last() else {
1464        return 0;
1465    };
1466
1467    if file_token == last_section_token {
1468        return 500 + section_tokens.len();
1469    }
1470
1471    if file_token.starts_with(last_section_token) || last_section_token.starts_with(file_token) {
1472        return 100 + last_section_token.len().min(file_token.len());
1473    }
1474
1475    0
1476}
1477
1478fn meta_at_path(meta: &'static Meta, section_path: &[&str]) -> Option<&'static Meta> {
1479    let mut current_meta = meta;
1480    for section in section_path {
1481        current_meta = current_meta.fields.iter().find_map(|field| {
1482            if field.name != *section {
1483                return None;
1484            }
1485
1486            match field.kind {
1487                FieldKind::Nested { meta } => Some(meta),
1488                FieldKind::Leaf { .. } => None,
1489            }
1490        })?;
1491    }
1492
1493    Some(current_meta)
1494}
1495
1496fn render_yaml_template(
1497    meta: &'static Meta,
1498    include_paths: &[PathBuf],
1499    section_path: &[&'static str],
1500    split_paths: &[Vec<&'static str>],
1501) -> String {
1502    let mut output = String::new();
1503    if !include_paths.is_empty() {
1504        output.push_str(&render_yaml_include(include_paths));
1505        output.push('\n');
1506    }
1507
1508    if section_path.is_empty() {
1509        render_yaml_fields(
1510            meta,
1511            &mut Vec::new(),
1512            split_paths,
1513            0,
1514            !include_paths.is_empty(),
1515            &mut output,
1516        );
1517    } else {
1518        render_yaml_section(meta, section_path, split_paths, &mut output);
1519    }
1520
1521    ensure_single_trailing_newline(&mut output);
1522    output
1523}
1524
1525fn render_yaml_section(
1526    meta: &'static Meta,
1527    section_path: &[&'static str],
1528    split_paths: &[Vec<&'static str>],
1529    output: &mut String,
1530) {
1531    let mut current_meta = meta;
1532    let mut current_path = Vec::new();
1533
1534    for (depth, section) in section_path.iter().enumerate() {
1535        write_yaml_indent(output, depth);
1536        output.push('#');
1537        output.push_str(section);
1538        output.push_str(":\n");
1539        current_path.push(*section);
1540
1541        let Some(next_meta) = meta_at_path(current_meta, &[*section]) else {
1542            return;
1543        };
1544        current_meta = next_meta;
1545    }
1546
1547    render_yaml_fields(
1548        current_meta,
1549        &mut current_path,
1550        split_paths,
1551        section_path.len(),
1552        false,
1553        output,
1554    );
1555}
1556
1557fn render_yaml_fields(
1558    meta: &'static Meta,
1559    current_path: &mut Vec<&'static str>,
1560    split_paths: &[Vec<&'static str>],
1561    depth: usize,
1562    skip_include_field: bool,
1563    output: &mut String,
1564) {
1565    let mut emitted_anything = false;
1566
1567    for field in meta.fields {
1568        let FieldKind::Leaf { env, kind } = field.kind else {
1569            continue;
1570        };
1571
1572        if skip_include_field && current_path.is_empty() && field.name == "include" {
1573            continue;
1574        }
1575
1576        if emitted_anything {
1577            output.push('\n');
1578        }
1579        emitted_anything = true;
1580        render_yaml_leaf(field.name, field.doc, env, kind, depth, output);
1581    }
1582
1583    for field in meta.fields {
1584        let FieldKind::Nested { meta } = field.kind else {
1585            continue;
1586        };
1587
1588        current_path.push(field.name);
1589        let split_exact = split_paths.iter().any(|path| path == current_path);
1590        let split_descendant = split_paths
1591            .iter()
1592            .any(|path| path.starts_with(current_path) && path.len() > current_path.len());
1593
1594        if split_exact {
1595            current_path.pop();
1596            continue;
1597        }
1598
1599        if emitted_anything {
1600            output.push('\n');
1601        }
1602        emitted_anything = true;
1603
1604        for doc in field.doc {
1605            write_yaml_indent(output, depth);
1606            output.push('#');
1607            output.push_str(doc);
1608            output.push('\n');
1609        }
1610        write_yaml_indent(output, depth);
1611        output.push_str(field.name);
1612        output.push_str(":\n");
1613
1614        let child_split_paths = if split_descendant { split_paths } else { &[] };
1615        render_yaml_fields(
1616            meta,
1617            current_path,
1618            child_split_paths,
1619            depth + 1,
1620            false,
1621            output,
1622        );
1623        current_path.pop();
1624    }
1625}
1626
1627fn render_yaml_leaf(
1628    name: &str,
1629    doc: &[&str],
1630    env: Option<&str>,
1631    kind: LeafKind,
1632    depth: usize,
1633    output: &mut String,
1634) {
1635    let mut emitted_doc_comment = false;
1636    for doc in doc {
1637        write_yaml_indent(output, depth);
1638        output.push('#');
1639        output.push_str(doc);
1640        output.push('\n');
1641        emitted_doc_comment = true;
1642    }
1643
1644    if let Some(env) = env {
1645        if emitted_doc_comment {
1646            write_yaml_indent(output, depth);
1647            output.push_str("#\n");
1648        }
1649        write_yaml_indent(output, depth);
1650        output.push_str("# Can also be specified via environment variable `");
1651        output.push_str(env);
1652        output.push_str("`.\n");
1653    }
1654
1655    match kind {
1656        LeafKind::Optional => {
1657            write_yaml_indent(output, depth);
1658            output.push('#');
1659            output.push_str(name);
1660            output.push_str(":\n");
1661        }
1662        LeafKind::Required { default } => {
1663            write_yaml_indent(output, depth);
1664            match default {
1665                Some(default) => {
1666                    output.push_str("# Default value: ");
1667                    output.push_str(&render_yaml_expr(&default));
1668                    output.push('\n');
1669                    write_yaml_indent(output, depth);
1670                    output.push('#');
1671                    output.push_str(name);
1672                    output.push_str(": ");
1673                    output.push_str(&render_yaml_expr(&default));
1674                    output.push('\n');
1675                }
1676                None => {
1677                    output.push_str("# Required! This value must be specified.\n");
1678                    write_yaml_indent(output, depth);
1679                    output.push('#');
1680                    output.push_str(name);
1681                    output.push_str(":\n");
1682                }
1683            }
1684        }
1685    }
1686}
1687
1688fn render_yaml_expr(expr: &Expr) -> String {
1689    match expr {
1690        Expr::Str(value) => render_plain_or_quoted_string(value),
1691        Expr::Float(value) => value.to_string(),
1692        Expr::Integer(value) => value.to_string(),
1693        Expr::Bool(value) => value.to_string(),
1694        Expr::Array(items) => {
1695            let items = items
1696                .iter()
1697                .map(render_yaml_expr)
1698                .collect::<Vec<_>>()
1699                .join(", ");
1700            format!("[{items}]")
1701        }
1702        Expr::Map(entries) => {
1703            let entries = entries
1704                .iter()
1705                .map(|entry| {
1706                    format!(
1707                        "{}: {}",
1708                        render_yaml_map_key(&entry.key),
1709                        render_yaml_expr(&entry.value)
1710                    )
1711                })
1712                .collect::<Vec<_>>()
1713                .join(", ");
1714            format!("{{ {entries} }}")
1715        }
1716        _ => String::new(),
1717    }
1718}
1719
1720fn render_yaml_map_key(key: &MapKey) -> String {
1721    match key {
1722        MapKey::Str(value) => render_plain_or_quoted_string(value),
1723        MapKey::Float(value) => value.to_string(),
1724        MapKey::Integer(value) => value.to_string(),
1725        MapKey::Bool(value) => value.to_string(),
1726        _ => String::new(),
1727    }
1728}
1729
1730fn render_plain_or_quoted_string(value: &str) -> String {
1731    let needs_quotes = value.is_empty()
1732        || value.starts_with([
1733            ' ', '#', '{', '}', '[', ']', ',', '&', '*', '!', '|', '>', '\'', '"',
1734        ])
1735        || value.contains([':', '\n', '\r', '\t']);
1736
1737    if needs_quotes {
1738        quote_path(Path::new(value))
1739    } else {
1740        value.to_owned()
1741    }
1742}
1743
1744fn write_yaml_indent(output: &mut String, depth: usize) {
1745    for _ in 0..depth {
1746        output.push_str("  ");
1747    }
1748}
1749
1750fn ensure_single_trailing_newline(output: &mut String) {
1751    if output.ends_with('\n') {
1752        while output.ends_with("\n\n") {
1753            output.pop();
1754        }
1755    } else {
1756        output.push('\n');
1757    }
1758}
1759
1760fn template_for_path_with_includes<S>(
1761    path: &Path,
1762    include_paths: &[PathBuf],
1763) -> ConfigResult<String>
1764where
1765    S: ConfigSchema,
1766{
1767    let template = template_for_path::<S>(path)?;
1768    if include_paths.is_empty() {
1769        return Ok(template);
1770    }
1771
1772    let template = match ConfigFormat::from_path(path) {
1773        ConfigFormat::Yaml => {
1774            let template = strip_prefix_once(&template, "# Default value: []\n#include: []\n\n");
1775            format!("{}\n{template}", render_yaml_include(include_paths))
1776        }
1777        ConfigFormat::Toml => {
1778            let template = strip_prefix_once(&template, "# Default value: []\n#include = []\n\n");
1779            format!("{}\n{template}", render_toml_include(include_paths))
1780        }
1781        ConfigFormat::Json => {
1782            let body = template.strip_prefix("{\n").unwrap_or(&template);
1783            let body = strip_prefix_once(body, "  // Default value: []\n  //include: [],\n\n");
1784            format!("{{\n{}\n{body}", render_json5_include(include_paths))
1785        }
1786    };
1787
1788    Ok(template)
1789}
1790
1791fn render_yaml_include(paths: &[PathBuf]) -> String {
1792    let mut out = String::from("include:\n");
1793    for path in paths {
1794        out.push_str("  - ");
1795        out.push_str(&quote_path(path));
1796        out.push('\n');
1797    }
1798    out
1799}
1800
1801fn render_toml_include(paths: &[PathBuf]) -> String {
1802    let entries = paths
1803        .iter()
1804        .map(|path| quote_path(path))
1805        .collect::<Vec<_>>()
1806        .join(", ");
1807    format!("include = [{entries}]\n")
1808}
1809
1810fn render_json5_include(paths: &[PathBuf]) -> String {
1811    let mut out = String::from("  include: [\n");
1812    for path in paths {
1813        out.push_str("    ");
1814        out.push_str(&quote_path(path));
1815        out.push_str(",\n");
1816    }
1817    out.push_str("  ],\n");
1818    out
1819}
1820
1821fn quote_path(path: &Path) -> String {
1822    serde_json::to_string(&path.to_string_lossy()).expect("path string serialization cannot fail")
1823}
1824
1825fn strip_prefix_once<'a>(value: &'a str, prefix: &str) -> &'a str {
1826    value.strip_prefix(prefix).unwrap_or(value)
1827}
1828
1829fn yaml_options() -> confique::yaml::FormatOptions {
1830    let mut options = confique::yaml::FormatOptions::default();
1831    options.indent = 2;
1832    options.general.comments = true;
1833    options.general.env_keys = true;
1834    options.general.nested_field_gap = 1;
1835    options
1836}
1837
1838fn toml_options() -> confique::toml::FormatOptions {
1839    let mut options = confique::toml::FormatOptions::default();
1840    options.general.comments = true;
1841    options.general.env_keys = true;
1842    options.general.nested_field_gap = 1;
1843    options
1844}
1845
1846fn json5_options() -> confique::json5::FormatOptions {
1847    let mut options = confique::json5::FormatOptions::default();
1848    options.indent = 2;
1849    options.general.comments = true;
1850    options.general.env_keys = true;
1851    options.general.nested_field_gap = 1;
1852    options
1853}
1854
1855#[cfg(test)]
1856#[path = "unit_tests/config.rs"]
1857mod unit_tests;