Skip to main content

rust_config_tree/config_schema/
adapt.rs

1//! Schema adaptation for split sections, env-only fields, and public output.
2
3use std::collections::BTreeSet;
4
5use serde_json::Value;
6
7use crate::config::ConfigResult;
8
9use super::{
10    marker::{
11        ENV_ONLY_SCHEMA_EXTENSION, TREE_INNER_FIELD_EXTENSION, TREE_SPLIT_SCHEMA_EXTENSION,
12        TREE_TRANSPARENT_ARRAY_EXTENSION,
13    },
14    paths::{direct_child_split_section_paths, inner_field_for_section},
15    reference::{
16        collect_schema_refs, collect_transitive_schema_refs, resolve_schema_reference,
17        retain_schema_map,
18    },
19};
20
21/// Extracts a nested section schema and wraps it as a standalone schema.
22///
23/// # Arguments
24///
25/// - `root_schema`: Full root schema used for traversal and reference lookup.
26/// - `section_path`: Nested section field path to extract.
27///
28/// # Returns
29///
30/// Returns a standalone section schema when the path exists.
31///
32/// # Examples
33///
34/// ```no_run
35/// let _ = ();
36/// ```
37fn section_schema_for_path(root_schema: &Value, section_path: &[&str]) -> Option<Value> {
38    let property = property_schema_for_path(root_schema, section_path)?;
39    let resolved = resolve_schema_reference(root_schema, property).unwrap_or(property);
40
41    if section_has_transparent_array_marker(root_schema, section_path) {
42        return transparent_array_section_schema(root_schema, section_path, resolved);
43    }
44
45    Some(standalone_section_schema(root_schema, resolved))
46}
47
48/// Returns the property schema for one nested section path.
49fn property_schema_for_path<'a>(root_schema: &'a Value, path: &[&str]) -> Option<&'a Value> {
50    let mut current = root_schema;
51
52    for (index, section) in path.iter().enumerate() {
53        let property = current.get("properties")?.get(*section)?;
54        if index + 1 == path.len() {
55            return Some(property);
56        }
57
58        current = resolve_schema_reference(root_schema, property).unwrap_or(property);
59    }
60
61    None
62}
63
64/// Returns whether one section path uses transparent array serialization.
65fn section_has_transparent_array_marker(root_schema: &Value, section_path: &[&str]) -> bool {
66    property_schema_for_path(root_schema, section_path)
67        .and_then(|schema| schema.get(TREE_TRANSPARENT_ARRAY_EXTENSION))
68        .and_then(Value::as_bool)
69        .unwrap_or(false)
70}
71
72/// Builds a standalone array schema for one transparent array section.
73fn transparent_array_section_schema(
74    root_schema: &Value,
75    section_path: &[&str],
76    section_schema: &Value,
77) -> Option<Value> {
78    let inner_field = inner_field_for_section(root_schema, section_path);
79    let resolved = resolve_schema_reference(root_schema, section_schema).unwrap_or(section_schema);
80    let inner_schema = resolved
81        .get("properties")
82        .and_then(|properties| properties.get(inner_field))
83        .or_else(|| resolved.get("items"))?;
84    let inner_schema = resolve_schema_reference(root_schema, inner_schema).unwrap_or(inner_schema);
85
86    Some(standalone_section_schema(
87        root_schema,
88        &serde_json::json!({
89            "type": "array",
90            "items": inner_schema.clone(),
91        }),
92    ))
93}
94/// Copies root-level schema metadata needed by an extracted section schema.
95///
96/// # Arguments
97///
98/// - `root_schema`: Full root schema that owns `$schema`, `definitions`, and
99///   `$defs`.
100/// - `section_schema`: Extracted section schema to make standalone.
101///
102/// # Returns
103///
104/// Returns a cloned section schema with necessary root metadata attached.
105///
106/// # Examples
107///
108/// ```no_run
109/// let _ = ();
110/// ```
111fn standalone_section_schema(root_schema: &Value, section_schema: &Value) -> Value {
112    let mut section_schema = section_schema.clone();
113    let Some(object) = section_schema.as_object_mut() else {
114        return section_schema;
115    };
116
117    if let Some(schema_uri) = root_schema.get("$schema") {
118        object
119            .entry("$schema".to_owned())
120            .or_insert_with(|| schema_uri.clone());
121    }
122
123    if let Some(definitions) = root_schema.get("definitions") {
124        object
125            .entry("definitions".to_owned())
126            .or_insert_with(|| definitions.clone());
127    }
128
129    if let Some(defs) = root_schema.get("$defs") {
130        object
131            .entry("$defs".to_owned())
132            .or_insert_with(|| defs.clone());
133    }
134
135    section_schema
136}
137/// Builds the schema content for either the root output or one split section.
138///
139/// # Arguments
140///
141/// - `full_schema`: Full root schema generated by `schemars`.
142/// - `section_path`: Empty for the root schema, or the split section path.
143/// - `split_paths`: All split section paths used to prune child sections.
144///
145/// # Returns
146///
147/// Returns the generated schema value for one output file.
148///
149/// # Examples
150///
151/// ```no_run
152/// let _ = ();
153/// ```
154pub fn schema_for_output_path(
155    full_schema: &Value,
156    section_path: &[&'static str],
157    split_paths: &[Vec<&'static str>],
158) -> ConfigResult<Value> {
159    let mut schema = if section_path.is_empty() {
160        full_schema.clone()
161    } else {
162        section_schema_for_path(full_schema, section_path).ok_or_else(|| {
163            std::io::Error::new(
164                std::io::ErrorKind::InvalidData,
165                format!(
166                    "failed to extract JSON Schema for config section {}",
167                    section_path.join(".")
168                ),
169            )
170        })?
171    };
172
173    // Each generated file owns only its direct fields. Split child sections are
174    // completed by their own schema files, so remove them from the parent.
175    remove_child_section_properties(&mut schema, section_path, split_paths);
176    remove_env_only_properties(&mut schema);
177    remove_empty_object_properties(&mut schema);
178    prune_unused_schema_maps(&mut schema);
179    remove_schema_extensions(&mut schema);
180
181    Ok(schema)
182}
183
184/// Removes direct split child sections from the schema owned by this output.
185///
186/// # Arguments
187///
188/// - `schema`: Schema value for the current output file.
189/// - `section_path`: Section path owned by the current output file.
190/// - `split_paths`: All split section paths in the root schema.
191///
192/// # Returns
193///
194/// Returns no value; `schema` is updated directly.
195///
196/// # Examples
197///
198/// ```no_run
199/// let _ = ();
200/// ```
201fn remove_child_section_properties(
202    schema: &mut Value,
203    section_path: &[&'static str],
204    split_paths: &[Vec<&'static str>],
205) {
206    let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
207        return;
208    };
209
210    for child_section_path in direct_child_split_section_paths(section_path, split_paths) {
211        if let Some(child_name) = child_section_path.last() {
212            properties.remove(*child_name);
213        }
214    }
215}
216
217/// Removes properties marked with `x-env-only`.
218///
219/// # Arguments
220///
221/// - `value`: Schema subtree to edit in place.
222///
223/// # Returns
224///
225/// Returns no value; `value` is updated directly.
226///
227/// # Examples
228///
229/// ```no_run
230/// let _ = ();
231/// ```
232pub fn remove_env_only_properties(value: &mut Value) {
233    match value {
234        Value::Object(object) => {
235            if let Some(properties) = object.get_mut("properties").and_then(Value::as_object_mut) {
236                properties.retain(|_, schema| {
237                    !schema
238                        .get(ENV_ONLY_SCHEMA_EXTENSION)
239                        .and_then(Value::as_bool)
240                        .unwrap_or(false)
241                });
242
243                for schema in properties.values_mut() {
244                    remove_env_only_properties(schema);
245                }
246            }
247
248            for (key, child) in object.iter_mut() {
249                if key != "properties" {
250                    remove_env_only_properties(child);
251                }
252            }
253        }
254        Value::Array(items) => {
255            for item in items {
256                remove_env_only_properties(item);
257            }
258        }
259        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
260    }
261}
262
263/// Removes object properties whose schema became empty after env-only pruning.
264///
265/// # Arguments
266///
267/// - `schema`: Schema subtree to edit in place.
268///
269/// # Returns
270///
271/// Returns no value; `schema` is updated directly.
272///
273/// # Examples
274///
275/// ```no_run
276/// let _ = ();
277/// ```
278pub fn remove_empty_object_properties(schema: &mut Value) {
279    loop {
280        let root_schema = schema.clone();
281        if !remove_empty_object_properties_with_root(schema, &root_schema) {
282            break;
283        }
284    }
285}
286
287/// Removes empty object properties using `root_schema` for local `$ref` lookup.
288///
289/// # Arguments
290///
291/// - `value`: Schema subtree to edit in place.
292/// - `root_schema`: Root schema used to resolve local references.
293///
294/// # Returns
295///
296/// Returns `true` when at least one property was removed.
297///
298/// # Examples
299///
300/// ```no_run
301/// let _ = ();
302/// ```
303fn remove_empty_object_properties_with_root(value: &mut Value, root_schema: &Value) -> bool {
304    let mut changed = false;
305
306    match value {
307        Value::Object(object) => {
308            if let Some(properties) = object.get_mut("properties").and_then(Value::as_object_mut) {
309                let before_len = properties.len();
310                properties.retain(|_, schema| !is_empty_object_schema(root_schema, schema));
311                changed |= properties.len() != before_len;
312
313                for schema in properties.values_mut() {
314                    changed |= remove_empty_object_properties_with_root(schema, root_schema);
315                }
316            }
317
318            for (key, child) in object.iter_mut() {
319                if key != "properties" {
320                    changed |= remove_empty_object_properties_with_root(child, root_schema);
321                }
322            }
323        }
324        Value::Array(items) => {
325            for item in items {
326                changed |= remove_empty_object_properties_with_root(item, root_schema);
327            }
328        }
329        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
330    }
331
332    changed
333}
334
335/// Returns whether a schema resolves to an empty object schema.
336///
337/// # Arguments
338///
339/// - `root_schema`: Root schema used to resolve local references.
340/// - `schema`: Candidate schema to inspect.
341///
342/// # Returns
343///
344/// Returns `true` when the schema is an object with no properties.
345///
346/// # Examples
347///
348/// ```no_run
349/// let _ = ();
350/// ```
351fn is_empty_object_schema(root_schema: &Value, schema: &Value) -> bool {
352    let schema = resolve_schema_reference(root_schema, schema).unwrap_or(schema);
353    let Some(object) = schema.as_object() else {
354        return false;
355    };
356
357    let is_object = object.get("type").and_then(Value::as_str) == Some("object")
358        || object.contains_key("properties");
359    let has_properties = object
360        .get("properties")
361        .and_then(Value::as_object)
362        .is_some_and(|properties| !properties.is_empty());
363    let has_dynamic_properties =
364        object.contains_key("additionalProperties") || object.contains_key("patternProperties");
365
366    is_object && !has_properties && !has_dynamic_properties
367}
368
369/// Drops unused `definitions` and `$defs` entries after section pruning.
370///
371/// # Arguments
372///
373/// - `schema`: Schema value whose schema maps should be pruned.
374///
375/// # Returns
376///
377/// Returns no value; `schema` is updated directly.
378///
379/// # Examples
380///
381/// ```no_run
382/// let _ = ();
383/// ```
384pub fn prune_unused_schema_maps(schema: &mut Value) {
385    let mut definitions = BTreeSet::new();
386    let mut defs = BTreeSet::new();
387
388    collect_schema_refs(schema, false, &mut definitions, &mut defs);
389
390    loop {
391        let previous_len = definitions.len() + defs.len();
392        collect_transitive_schema_refs(schema, &mut definitions, &mut defs);
393
394        if definitions.len() + defs.len() == previous_len {
395            break;
396        }
397    }
398
399    retain_schema_map(schema, "definitions", &definitions);
400    retain_schema_map(schema, "$defs", &defs);
401}
402
403/// Removes internal extension markers before writing public schemas.
404///
405/// # Arguments
406///
407/// - `value`: Schema subtree to sanitize.
408///
409/// # Returns
410///
411/// Returns no value; `value` is updated directly.
412///
413/// # Examples
414///
415/// ```no_run
416/// let _ = ();
417/// ```
418pub fn remove_schema_extensions(value: &mut Value) {
419    match value {
420        Value::Object(object) => {
421            object.remove(TREE_SPLIT_SCHEMA_EXTENSION);
422            object.remove(TREE_TRANSPARENT_ARRAY_EXTENSION);
423            object.remove(TREE_INNER_FIELD_EXTENSION);
424            object.remove(ENV_ONLY_SCHEMA_EXTENSION);
425
426            for child in object.values_mut() {
427                remove_schema_extensions(child);
428            }
429        }
430        Value::Array(items) => {
431            for item in items {
432                remove_schema_extensions(item);
433            }
434        }
435        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
436    }
437}