Skip to main content

rust_config_tree/
config_load_adapt.rs

1//! YAML adaptation for transparent array configuration sections.
2
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6use figment::{
7    Figment,
8    providers::{Format, Serialized, Yaml},
9};
10use schemars::JsonSchema;
11use serde_json::Value;
12use serde_yaml::{Mapping, Value as YamlValue};
13
14use crate::{
15    config::{ConfigResult, ConfigSchema},
16    config_format::ConfigFormat,
17    config_schema::{
18        generate::root_config_schema,
19        paths::{inner_field_for_section, split_section_paths, transparent_array_section_paths},
20    },
21    config_templates::section::section_path_for_target,
22    path::absolutize_lexical,
23};
24
25/// Tracks transparent section keys observed while merging config files.
26#[derive(Debug, Default, Clone)]
27pub struct TransparentSectionTracker {
28    /// Top-level section field names present in merged config sources.
29    pub seen_sections: HashSet<String>,
30}
31
32impl TransparentSectionTracker {
33    /// Records one transparent section key observed in a merged config source.
34    pub fn record_section(&mut self, section: &str) {
35        if !section.is_empty() {
36            self.seen_sections.insert(section.to_string());
37        }
38    }
39}
40
41/// Runtime metadata used to adapt transparent array sections during loading.
42#[derive(Debug, Clone)]
43pub struct TransparentSectionContext {
44    /// Root config directory used to resolve split section template paths.
45    pub root_base_dir: PathBuf,
46    /// Split section paths marked with `x-tree-split`.
47    pub split_paths: Vec<Vec<&'static str>>,
48    /// Split section paths marked with `x-tree-transparent-array`.
49    pub transparent_paths: Vec<Vec<&'static str>>,
50    /// Full root JSON Schema used to resolve inner field names.
51    pub full_schema: Value,
52}
53
54impl TransparentSectionContext {
55    /// Builds transparent section metadata for one config schema type.
56    pub fn for_schema<S>(root_config_path: &Path) -> ConfigResult<Self>
57    where
58        S: ConfigSchema + JsonSchema,
59    {
60        let root_config_path = absolutize_lexical(root_config_path)?;
61        let root_base_dir = root_config_path
62            .parent()
63            .unwrap_or_else(|| Path::new("."))
64            .to_path_buf();
65        let full_schema = root_config_schema::<S>()?;
66        let split_paths = split_section_paths::<S>(&full_schema);
67        let transparent_paths = transparent_array_section_paths::<S>(&full_schema);
68
69        Ok(Self {
70            root_base_dir,
71            split_paths,
72            transparent_paths,
73            full_schema,
74        })
75    }
76
77    /// Returns the split section path represented by one config file, if any.
78    pub fn section_path_for_file<S>(&self, path: &Path) -> Option<Vec<&'static str>>
79    where
80        S: ConfigSchema,
81    {
82        section_path_for_target::<S>(&self.root_base_dir, path, &self.split_paths)
83    }
84
85    /// Returns whether one section path serializes as a transparent array.
86    pub fn is_transparent_section(&self, section_path: &[&str]) -> bool {
87        self.transparent_paths
88            .iter()
89            .any(|path| path.as_slice() == section_path)
90    }
91
92    /// Returns the confique inner field for one transparent section path.
93    pub fn inner_field_for_section(&self, section_path: &[&str]) -> String {
94        inner_field_for_section(&self.full_schema, section_path)
95    }
96}
97
98/// Returns whether one config file represents a split section body file.
99pub fn is_split_section_file<S>(context: &TransparentSectionContext, path: &Path) -> bool
100where
101    S: ConfigSchema,
102{
103    context.section_path_for_file::<S>(path).is_some()
104}
105
106/// Merges one config file into Figment after adapting transparent array sections.
107pub fn merge_adapted_file<S>(
108    figment: Figment,
109    path: &Path,
110    context: &TransparentSectionContext,
111    tracker: &mut TransparentSectionTracker,
112) -> ConfigResult<Figment>
113where
114    S: ConfigSchema + JsonSchema,
115{
116    if let Some(section_path) = context.section_path_for_file::<S>(path) {
117        let section_key = section_path
118            .last()
119            .copied()
120            .expect("split section path must not be empty");
121
122        if context.is_transparent_section(&section_path) && !yaml_has_root_key(path, section_key) {
123            tracker.record_section(section_key);
124            let body = read_yaml_value(path)?;
125            let inner_field = context.inner_field_for_section(&section_path);
126            let section_body = wrap_inner_field(body, inner_field.as_str());
127            let merged = nest_section_mapping(&section_path, section_body);
128            return Ok(figment.merge(Serialized::defaults(YamlValue::Mapping(merged))));
129        }
130    }
131
132    merge_mapping_file::<S>(figment, path, context, tracker)
133}
134
135/// Merges explicit empty transparent sections that never appeared in config files.
136///
137/// This prevents `confique` template sample defaults from becoming runtime values
138/// when a transparent section is omitted entirely from the loaded config tree.
139pub fn merge_missing_transparent_sections(
140    figment: Figment,
141    context: &TransparentSectionContext,
142    tracker: &TransparentSectionTracker,
143) -> Figment {
144    let mut figment = figment;
145
146    for section_path in &context.transparent_paths {
147        let Some(section_key) = section_path.last().copied() else {
148            continue;
149        };
150
151        if tracker.seen_sections.contains(section_key) {
152            continue;
153        }
154
155        let inner_field = context.inner_field_for_section(section_path);
156        let empty_items = wrap_inner_field(YamlValue::Sequence(Vec::new()), inner_field.as_str());
157        let merged = nest_section_mapping(section_path, empty_items);
158        figment = figment.merge(Serialized::defaults(YamlValue::Mapping(merged)));
159    }
160
161    figment
162}
163
164fn nest_section_mapping(section_path: &[&str], body: YamlValue) -> Mapping {
165    let mut current = body;
166    for section in section_path.iter().rev() {
167        let mut map = Mapping::new();
168        map.insert(YamlValue::String(section.to_string()), current);
169        current = YamlValue::Mapping(map);
170    }
171
172    match current {
173        YamlValue::Mapping(map) => map,
174        other => {
175            let mut map = Mapping::new();
176            if let Some(section) = section_path.last() {
177                map.insert(YamlValue::String(section.to_string()), other);
178            }
179            map
180        }
181    }
182}
183
184fn merge_mapping_file<S>(
185    figment: Figment,
186    path: &Path,
187    context: &TransparentSectionContext,
188    tracker: &mut TransparentSectionTracker,
189) -> ConfigResult<Figment>
190where
191    S: ConfigSchema,
192{
193    match ConfigFormat::from_path(path) {
194        ConfigFormat::Yaml => {
195            let value = read_yaml_value(path)?;
196            if matches!(value, YamlValue::Null) {
197                return Ok(figment);
198            }
199            let split_file = context.section_path_for_file::<S>(path);
200            record_transparent_sections_in_value(&value, context, tracker);
201            let adapted = adapt_config_yaml(value, context, split_file.as_deref());
202            Ok(figment.merge(Serialized::defaults(adapted)))
203        }
204        ConfigFormat::Toml => Ok(figment.merge(figment::providers::Toml::file(path))),
205        ConfigFormat::Json => Ok(figment.merge(figment::providers::Json::file(path))),
206    }
207}
208
209fn record_transparent_sections_in_value(
210    value: &YamlValue,
211    context: &TransparentSectionContext,
212    tracker: &mut TransparentSectionTracker,
213) {
214    let YamlValue::Mapping(map) = value else {
215        return;
216    };
217
218    for key in map.keys() {
219        if is_transparent_section_key(key, context) {
220            if let Some(section) = key.as_str() {
221                tracker.record_section(section);
222            }
223        }
224    }
225}
226
227/// Adapts one YAML document for transparent array sections before Figment merge.
228pub fn adapt_config_yaml(
229    value: YamlValue,
230    context: &TransparentSectionContext,
231    split_file: Option<&[&str]>,
232) -> YamlValue {
233    match value {
234        YamlValue::Sequence(_) if split_file.is_some() => {
235            adapt_split_section_body(value, context, split_file.expect("split section path"))
236        }
237        YamlValue::Mapping(map) => {
238            let mut adapted = Mapping::new();
239            for (key, child) in map {
240                let next = if is_transparent_section_key(&key, context) {
241                    let section = key.as_str().unwrap_or("");
242                    adapt_section_value(child, context, section)
243                } else {
244                    adapt_config_yaml(child, context, None)
245                };
246                adapted.insert(key, next);
247            }
248            YamlValue::Mapping(adapted)
249        }
250        other => other,
251    }
252}
253
254fn adapt_split_section_body(
255    value: YamlValue,
256    context: &TransparentSectionContext,
257    section_path: &[&str],
258) -> YamlValue {
259    let inner_field_name = context.inner_field_for_section(section_path);
260    let inner_field = inner_field_name.as_str();
261    match value {
262        YamlValue::Sequence(sequence) => {
263            wrap_inner_field(YamlValue::Sequence(sequence), inner_field)
264        }
265        YamlValue::Mapping(map)
266            if map.contains_key(YamlValue::String(inner_field_name.clone())) =>
267        {
268            YamlValue::Mapping(map)
269        }
270        other => other,
271    }
272}
273
274fn adapt_section_value(
275    value: YamlValue,
276    context: &TransparentSectionContext,
277    section: &str,
278) -> YamlValue {
279    let fallback = [section];
280    let section_path = context
281        .transparent_paths
282        .iter()
283        .find(|path| path.last() == Some(&section))
284        .map(Vec::as_slice)
285        .unwrap_or(&fallback);
286
287    let inner_field_name = context.inner_field_for_section(section_path);
288    let inner_field = inner_field_name.as_str();
289    match value {
290        YamlValue::Sequence(sequence) => {
291            wrap_inner_field(YamlValue::Sequence(sequence), inner_field)
292        }
293        YamlValue::Mapping(map)
294            if map.contains_key(YamlValue::String(inner_field_name.clone())) =>
295        {
296            YamlValue::Mapping(map)
297        }
298        other => other,
299    }
300}
301
302fn wrap_inner_field(value: YamlValue, inner_field: &str) -> YamlValue {
303    let mut map = Mapping::new();
304    map.insert(YamlValue::String(inner_field.to_string()), value);
305    YamlValue::Mapping(map)
306}
307
308fn is_transparent_section_key(key: &YamlValue, context: &TransparentSectionContext) -> bool {
309    key.as_str().is_some_and(|name| {
310        context
311            .transparent_paths
312            .iter()
313            .any(|path| path.last() == Some(&name))
314    })
315}
316
317fn yaml_has_root_key(path: &Path, key: &str) -> bool {
318    if ConfigFormat::from_path(path) != ConfigFormat::Yaml || key.is_empty() {
319        return false;
320    }
321
322    Figment::from(Yaml::file(path)).find_value(key).is_ok()
323}
324
325fn read_yaml_value(path: &Path) -> ConfigResult<YamlValue> {
326    let content = std::fs::read_to_string(path)?;
327    serde_yaml::from_str(&content).map_err(|error| {
328        figment::Error::from(figment::error::Kind::Message(error.to_string())).into()
329    })
330}