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