Skip to main content

rust_config_tree/
config_schema.rs

1//! JSON Schema generation and section-schema splitting.
2//!
3//! `schemars` produces one full schema for the root config type. This module
4//! removes constraints that do not fit partial config files, strips
5//! `x-tree-split` marker metadata from the emitted JSON, and optionally emits
6//! separate schemas for marked nested sections.
7
8use std::{
9    collections::BTreeSet,
10    path::{Path, PathBuf},
11};
12
13use confique::meta::{FieldKind, Meta};
14use schemars::{JsonSchema, generate::SchemaSettings};
15use serde_json::Value;
16
17use crate::{
18    config::{ConfigResult, ConfigSchema},
19    config_output::write_template,
20    config_util::ensure_single_trailing_newline,
21};
22
23const TREE_SPLIT_SCHEMA_EXTENSION: &str = "x-tree-split";
24
25/// Generated JSON Schema content for one output path.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ConfigSchemaTarget {
28    /// Path that should receive the generated schema.
29    pub path: PathBuf,
30    /// Complete JSON Schema content to write to `path`.
31    pub content: String,
32}
33
34/// Builds the root Draft 7 schema and adapts it for partial config files.
35///
36/// # Type Parameters
37///
38/// - `S`: Config schema type to render with `schemars`.
39///
40/// # Arguments
41///
42/// This function has no arguments.
43///
44/// # Returns
45///
46/// Returns the root schema as JSON with `required` constraints removed.
47///
48/// # Examples
49///
50/// ```no_run
51/// let _ = ();
52/// ```
53pub(crate) fn root_config_schema<S>() -> ConfigResult<Value>
54where
55    S: JsonSchema,
56{
57    let generator = SchemaSettings::draft07().into_generator();
58    let schema = generator.into_root_schema_for::<S>();
59    let mut schema = serde_json::to_value(schema)?;
60    remove_required_recursively(&mut schema);
61
62    Ok(schema)
63}
64
65/// Serializes a schema value as stable pretty JSON for generated files.
66///
67/// # Arguments
68///
69/// - `schema`: Schema value to serialize.
70///
71/// # Returns
72///
73/// Returns pretty JSON with exactly one trailing newline.
74///
75/// # Examples
76///
77/// ```no_run
78/// let _ = ();
79/// ```
80fn schema_json(schema: &Value) -> ConfigResult<String> {
81    let mut json = serde_json::to_string_pretty(schema)?;
82    ensure_single_trailing_newline(&mut json);
83    Ok(json)
84}
85
86/// Removes every JSON Schema `required` list from a schema tree.
87///
88/// # Arguments
89///
90/// - `value`: Schema subtree to edit in place.
91///
92/// # Returns
93///
94/// Returns no value; `value` is updated directly.
95///
96/// # Examples
97///
98/// ```no_run
99/// let _ = ();
100/// ```
101fn remove_required_recursively(value: &mut Value) {
102    match value {
103        Value::Object(object) => {
104            object.remove("required");
105
106            for (key, child) in object.iter_mut() {
107                if is_schema_map_key(key) {
108                    // Schema maps contain child schemas keyed by property or
109                    // definition name; the map object itself is not a schema.
110                    remove_required_from_schema_map(child);
111                } else {
112                    remove_required_recursively(child);
113                }
114            }
115        }
116        Value::Array(items) => {
117            for item in items {
118                remove_required_recursively(item);
119            }
120        }
121        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
122    }
123}
124
125/// Returns whether a JSON object key names a map of child schemas.
126///
127/// # Arguments
128///
129/// - `key`: JSON object key to classify.
130///
131/// # Returns
132///
133/// Returns `true` when `key` names a schema map.
134///
135/// # Examples
136///
137/// ```no_run
138/// let _ = ();
139/// ```
140fn is_schema_map_key(key: &str) -> bool {
141    matches!(
142        key,
143        "$defs" | "definitions" | "properties" | "patternProperties"
144    )
145}
146
147/// Removes `required` lists from every schema inside a schema map.
148///
149/// # Arguments
150///
151/// - `value`: Schema map value or fallback schema value to edit in place.
152///
153/// # Returns
154///
155/// Returns no value; `value` is updated directly.
156///
157/// # Examples
158///
159/// ```no_run
160/// let _ = ();
161/// ```
162fn remove_required_from_schema_map(value: &mut Value) {
163    match value {
164        Value::Object(object) => {
165            for schema in object.values_mut() {
166                remove_required_recursively(schema);
167            }
168        }
169        _ => remove_required_recursively(value),
170    }
171}
172
173/// Extracts a nested section schema and wraps it as a standalone schema.
174///
175/// # Arguments
176///
177/// - `root_schema`: Full root schema used for traversal and reference lookup.
178/// - `section_path`: Nested section field path to extract.
179///
180/// # Returns
181///
182/// Returns a standalone section schema when the path exists.
183///
184/// # Examples
185///
186/// ```no_run
187/// let _ = ();
188/// ```
189fn section_schema_for_path(root_schema: &Value, section_path: &[&str]) -> Option<Value> {
190    let mut current = root_schema;
191
192    for section in section_path {
193        current = current.get("properties")?.get(*section)?;
194        current = resolve_schema_reference(root_schema, current).unwrap_or(current);
195    }
196
197    Some(standalone_section_schema(root_schema, current))
198}
199
200/// Resolves the local schema reference shape emitted by `schemars`.
201///
202/// # Arguments
203///
204/// - `root_schema`: Full root schema that owns referenced definitions.
205/// - `schema`: Schema value that may contain a local `$ref`.
206///
207/// # Returns
208///
209/// Returns the referenced schema when `schema` contains a supported reference.
210///
211/// # Examples
212///
213/// ```no_run
214/// let _ = ();
215/// ```
216fn resolve_schema_reference<'a>(root_schema: &'a Value, schema: &'a Value) -> Option<&'a Value> {
217    if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
218        return resolve_json_pointer_ref(root_schema, reference);
219    }
220
221    schema
222        .get("allOf")
223        .and_then(Value::as_array)
224        .and_then(|schemas| schemas.first())
225        .and_then(|schema| schema.get("$ref"))
226        .and_then(Value::as_str)
227        .and_then(|reference| resolve_json_pointer_ref(root_schema, reference))
228}
229
230/// Resolves a local JSON Pointer `$ref` against the root schema.
231///
232/// # Arguments
233///
234/// - `root_schema`: Full schema to query with the JSON Pointer.
235/// - `reference`: `$ref` string that must start with `#`.
236///
237/// # Returns
238///
239/// Returns the referenced schema value when the pointer resolves.
240///
241/// # Examples
242///
243/// ```no_run
244/// let _ = ();
245/// ```
246fn resolve_json_pointer_ref<'a>(root_schema: &'a Value, reference: &str) -> Option<&'a Value> {
247    let pointer = reference.strip_prefix('#')?;
248    root_schema.pointer(pointer)
249}
250
251/// Copies root-level schema metadata needed by an extracted section schema.
252///
253/// # Arguments
254///
255/// - `root_schema`: Full root schema that owns `$schema`, `definitions`, and
256///   `$defs`.
257/// - `section_schema`: Extracted section schema to make standalone.
258///
259/// # Returns
260///
261/// Returns a cloned section schema with necessary root metadata attached.
262///
263/// # Examples
264///
265/// ```no_run
266/// let _ = ();
267/// ```
268fn standalone_section_schema(root_schema: &Value, section_schema: &Value) -> Value {
269    let mut section_schema = section_schema.clone();
270    let Some(object) = section_schema.as_object_mut() else {
271        return section_schema;
272    };
273
274    if let Some(schema_uri) = root_schema.get("$schema") {
275        object
276            .entry("$schema".to_owned())
277            .or_insert_with(|| schema_uri.clone());
278    }
279
280    if let Some(definitions) = root_schema.get("definitions") {
281        object
282            .entry("definitions".to_owned())
283            .or_insert_with(|| definitions.clone());
284    }
285
286    if let Some(defs) = root_schema.get("$defs") {
287        object
288            .entry("$defs".to_owned())
289            .or_insert_with(|| defs.clone());
290    }
291
292    section_schema
293}
294
295/// Resolves the output path for a split section schema.
296///
297/// # Arguments
298///
299/// - `root_schema_path`: Output path for the root schema.
300/// - `section_path`: Nested section field path.
301///
302/// # Returns
303///
304/// Returns the generated schema path for `section_path`.
305///
306/// # Examples
307///
308/// ```no_run
309/// let _ = ();
310/// ```
311pub(crate) fn schema_path_for_section(root_schema_path: &Path, section_path: &[&str]) -> PathBuf {
312    let Some((last, parents)) = section_path.split_last() else {
313        return root_schema_path.to_path_buf();
314    };
315
316    let mut path = root_schema_path
317        .parent()
318        .unwrap_or_else(|| Path::new("."))
319        .to_path_buf();
320
321    for parent in parents {
322        path.push(*parent);
323    }
324
325    path.push(format!("{}.schema.json", *last));
326    path
327}
328
329/// Builds the schema content for either the root output or one split section.
330///
331/// # Arguments
332///
333/// - `full_schema`: Full root schema generated by `schemars`.
334/// - `section_path`: Empty for the root schema, or the split section path.
335/// - `split_paths`: All split section paths used to prune child sections.
336///
337/// # Returns
338///
339/// Returns the generated schema value for one output file.
340///
341/// # Examples
342///
343/// ```no_run
344/// let _ = ();
345/// ```
346fn schema_for_output_path(
347    full_schema: &Value,
348    section_path: &[&'static str],
349    split_paths: &[Vec<&'static str>],
350) -> ConfigResult<Value> {
351    let mut schema = if section_path.is_empty() {
352        full_schema.clone()
353    } else {
354        section_schema_for_path(full_schema, section_path).ok_or_else(|| {
355            std::io::Error::new(
356                std::io::ErrorKind::InvalidData,
357                format!(
358                    "failed to extract JSON Schema for config section {}",
359                    section_path.join(".")
360                ),
361            )
362        })?
363    };
364
365    // Each generated file owns only its direct fields. Split child sections are
366    // completed by their own schema files, so remove them from the parent.
367    remove_child_section_properties(&mut schema, section_path, split_paths);
368    prune_unused_schema_maps(&mut schema);
369    remove_tree_split_extensions(&mut schema);
370
371    Ok(schema)
372}
373
374/// Removes direct split child sections from the schema owned by this output.
375///
376/// # Arguments
377///
378/// - `schema`: Schema value for the current output file.
379/// - `section_path`: Section path owned by the current output file.
380/// - `split_paths`: All split section paths in the root schema.
381///
382/// # Returns
383///
384/// Returns no value; `schema` is updated directly.
385///
386/// # Examples
387///
388/// ```no_run
389/// let _ = ();
390/// ```
391fn remove_child_section_properties(
392    schema: &mut Value,
393    section_path: &[&'static str],
394    split_paths: &[Vec<&'static str>],
395) {
396    let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
397        return;
398    };
399
400    for child_section_path in direct_child_split_section_paths(section_path, split_paths) {
401        if let Some(child_name) = child_section_path.last() {
402            properties.remove(*child_name);
403        }
404    }
405}
406
407/// Drops unused `definitions` and `$defs` entries after section pruning.
408///
409/// # Arguments
410///
411/// - `schema`: Schema value whose schema maps should be pruned.
412///
413/// # Returns
414///
415/// Returns no value; `schema` is updated directly.
416///
417/// # Examples
418///
419/// ```no_run
420/// let _ = ();
421/// ```
422fn prune_unused_schema_maps(schema: &mut Value) {
423    let mut definitions = BTreeSet::new();
424    let mut defs = BTreeSet::new();
425
426    collect_schema_refs(schema, false, &mut definitions, &mut defs);
427
428    loop {
429        let previous_len = definitions.len() + defs.len();
430        collect_transitive_schema_refs(schema, &mut definitions, &mut defs);
431
432        if definitions.len() + defs.len() == previous_len {
433            break;
434        }
435    }
436
437    retain_schema_map(schema, "definitions", &definitions);
438    retain_schema_map(schema, "$defs", &defs);
439}
440
441/// Expands the reference set with references used by already retained schemas.
442///
443/// # Arguments
444///
445/// - `schema`: Root schema containing schema maps to inspect.
446/// - `definitions`: Referenced `definitions` names to expand in place.
447/// - `defs`: Referenced `$defs` names to expand in place.
448///
449/// # Returns
450///
451/// Returns no value; `definitions` and `defs` are updated directly.
452///
453/// # Examples
454///
455/// ```no_run
456/// let _ = ();
457/// ```
458fn collect_transitive_schema_refs(
459    schema: &Value,
460    definitions: &mut BTreeSet<String>,
461    defs: &mut BTreeSet<String>,
462) {
463    let current_definitions = definitions.clone();
464    let current_defs = defs.clone();
465    let mut referenced_definitions = BTreeSet::new();
466    let mut referenced_defs = BTreeSet::new();
467
468    if let Some(schema_map) = schema.get("definitions").and_then(Value::as_object) {
469        for name in &current_definitions {
470            if let Some(schema) = schema_map.get(name) {
471                collect_schema_refs(
472                    schema,
473                    true,
474                    &mut referenced_definitions,
475                    &mut referenced_defs,
476                );
477            }
478        }
479    }
480
481    if let Some(schema_map) = schema.get("$defs").and_then(Value::as_object) {
482        for name in &current_defs {
483            if let Some(schema) = schema_map.get(name) {
484                collect_schema_refs(
485                    schema,
486                    true,
487                    &mut referenced_definitions,
488                    &mut referenced_defs,
489                );
490            }
491        }
492    }
493
494    definitions.extend(referenced_definitions);
495    defs.extend(referenced_defs);
496}
497
498/// Walks a schema value and collects local references to schema maps.
499///
500/// # Arguments
501///
502/// - `value`: Schema subtree to inspect.
503/// - `include_schema_maps`: Whether nested `definitions` and `$defs` maps
504///   should also be traversed.
505/// - `definitions`: Output set of referenced `definitions` names.
506/// - `defs`: Output set of referenced `$defs` names.
507///
508/// # Returns
509///
510/// Returns no value; output sets are updated directly.
511///
512/// # Examples
513///
514/// ```no_run
515/// let _ = ();
516/// ```
517fn collect_schema_refs(
518    value: &Value,
519    include_schema_maps: bool,
520    definitions: &mut BTreeSet<String>,
521    defs: &mut BTreeSet<String>,
522) {
523    match value {
524        Value::Object(object) => {
525            if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
526                collect_schema_ref(reference, definitions, defs);
527            }
528
529            for (key, child) in object {
530                if !include_schema_maps && matches!(key.as_str(), "definitions" | "$defs") {
531                    continue;
532                }
533
534                collect_schema_refs(child, include_schema_maps, definitions, defs);
535            }
536        }
537        Value::Array(items) => {
538            for item in items {
539                collect_schema_refs(item, include_schema_maps, definitions, defs);
540            }
541        }
542        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
543    }
544}
545
546/// Records one local `$ref` if it points at `definitions` or `$defs`.
547///
548/// # Arguments
549///
550/// - `reference`: `$ref` string to inspect.
551/// - `definitions`: Output set of referenced `definitions` names.
552/// - `defs`: Output set of referenced `$defs` names.
553///
554/// # Returns
555///
556/// Returns no value; matching references are inserted into the output sets.
557///
558/// # Examples
559///
560/// ```no_run
561/// let _ = ();
562/// ```
563fn collect_schema_ref(
564    reference: &str,
565    definitions: &mut BTreeSet<String>,
566    defs: &mut BTreeSet<String>,
567) {
568    if let Some(name) = schema_ref_name(reference, "#/definitions/") {
569        definitions.insert(name);
570    } else if let Some(name) = schema_ref_name(reference, "#/$defs/") {
571        defs.insert(name);
572    }
573}
574
575/// Extracts and JSON-Pointer-decodes a schema-map entry name from a `$ref`.
576///
577/// # Arguments
578///
579/// - `reference`: `$ref` string to parse.
580/// - `prefix`: Schema-map pointer prefix to strip.
581///
582/// # Returns
583///
584/// Returns the decoded schema-map entry name when the reference matches.
585///
586/// # Examples
587///
588/// ```no_run
589/// let _ = ();
590/// ```
591fn schema_ref_name(reference: &str, prefix: &str) -> Option<String> {
592    let name = reference.strip_prefix(prefix)?.split('/').next()?;
593    Some(decode_json_pointer_token(name))
594}
595
596/// Decodes the escaping used by one JSON Pointer path token.
597///
598/// # Arguments
599///
600/// - `token`: Encoded JSON Pointer path token.
601///
602/// # Returns
603///
604/// Returns `token` with `~1` and `~0` escapes decoded.
605///
606/// # Examples
607///
608/// ```no_run
609/// let _ = ();
610/// ```
611fn decode_json_pointer_token(token: &str) -> String {
612    token.replace("~1", "/").replace("~0", "~")
613}
614
615/// Retains only referenced entries in a root schema map.
616///
617/// # Arguments
618///
619/// - `schema`: Root schema containing the schema map.
620/// - `key`: Schema-map key, such as `definitions` or `$defs`.
621/// - `used_names`: Entry names that should remain in the map.
622///
623/// # Returns
624///
625/// Returns no value; the map is pruned in place.
626///
627/// # Examples
628///
629/// ```no_run
630/// let _ = ();
631/// ```
632fn retain_schema_map(schema: &mut Value, key: &str, used_names: &BTreeSet<String>) {
633    let Some(object) = schema.as_object_mut() else {
634        return;
635    };
636
637    let Some(schema_map) = object.get_mut(key).and_then(Value::as_object_mut) else {
638        return;
639    };
640
641    schema_map.retain(|name, _| used_names.contains(name));
642
643    if schema_map.is_empty() {
644        object.remove(key);
645    }
646}
647
648/// Removes internal `x-tree-split` markers before writing public schemas.
649///
650/// # Arguments
651///
652/// - `value`: Schema subtree to sanitize.
653///
654/// # Returns
655///
656/// Returns no value; `value` is updated directly.
657///
658/// # Examples
659///
660/// ```no_run
661/// let _ = ();
662/// ```
663fn remove_tree_split_extensions(value: &mut Value) {
664    match value {
665        Value::Object(object) => {
666            object.remove(TREE_SPLIT_SCHEMA_EXTENSION);
667
668            for child in object.values_mut() {
669                remove_tree_split_extensions(child);
670            }
671        }
672        Value::Array(items) => {
673            for item in items {
674                remove_tree_split_extensions(item);
675            }
676        }
677        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
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.
701///
702/// # Examples
703///
704/// ```
705/// use confique::Config;
706/// use rust_config_tree::{ConfigSchema, write_config_schema};
707/// use schemars::JsonSchema;
708///
709/// #[derive(Config, JsonSchema)]
710/// struct AppConfig {
711///     #[config(default = [])]
712///     include: Vec<std::path::PathBuf>,
713///     #[config(default = "demo")]
714///     mode: String,
715/// }
716///
717/// impl ConfigSchema for AppConfig {
718///     fn include_paths(layer: &<Self as Config>::Layer) -> Vec<std::path::PathBuf> {
719///         layer.include.clone().unwrap_or_default()
720///     }
721/// }
722///
723/// let path = std::env::temp_dir().join("rust-config-tree-write-schema-doctest.json");
724/// write_config_schema::<AppConfig>(&path)?;
725///
726/// assert!(path.exists());
727/// # let _ = std::fs::remove_file(path);
728/// # Ok::<(), rust_config_tree::ConfigError>(())
729/// ```
730pub fn write_config_schema<S>(output_path: impl AsRef<Path>) -> ConfigResult<()>
731where
732    S: JsonSchema,
733{
734    let mut schema = root_config_schema::<S>()?;
735    remove_tree_split_extensions(&mut schema);
736    let json = schema_json(&schema)?;
737
738    write_template(output_path.as_ref(), &json)
739}
740
741/// Collects the root schema and section schemas for a config type.
742///
743/// The root schema is written to `output_path`. Nested `confique` sections are
744/// written next to it as `<section>.schema.json` when the nested field schema
745/// has `x-tree-split = true`; deeper split sections are nested in matching
746/// directories, for example `schemas/outer/inner.schema.json`. Each generated
747/// schema contains only the fields for its own template file; split child
748/// section fields are omitted and completed by their own section schemas.
749///
750/// # Type Parameters
751///
752/// - `S`: Config schema type that derives [`JsonSchema`] and exposes section
753///   metadata through [`ConfigSchema`].
754///
755/// # Arguments
756///
757/// - `output_path`: Destination path for the root JSON Schema.
758///
759/// # Returns
760///
761/// Returns all generated schema targets in traversal order.
762///
763/// # Examples
764///
765/// ```
766/// use confique::Config;
767/// use rust_config_tree::{ConfigSchema, config_schema_targets_for_path};
768/// use schemars::JsonSchema;
769///
770/// #[derive(Config, JsonSchema)]
771/// struct AppConfig {
772///     #[config(default = [])]
773///     include: Vec<std::path::PathBuf>,
774///     #[config(default = "demo")]
775///     mode: String,
776/// }
777///
778/// impl ConfigSchema for AppConfig {
779///     fn include_paths(layer: &<Self as Config>::Layer) -> Vec<std::path::PathBuf> {
780///         layer.include.clone().unwrap_or_default()
781///     }
782/// }
783///
784/// let targets = config_schema_targets_for_path::<AppConfig>("schemas/config.schema.json")?;
785///
786/// assert_eq!(targets.len(), 1);
787/// assert!(targets[0].content.contains("\"mode\""));
788/// # Ok::<(), rust_config_tree::ConfigError>(())
789/// ```
790pub fn config_schema_targets_for_path<S>(
791    output_path: impl AsRef<Path>,
792) -> ConfigResult<Vec<ConfigSchemaTarget>>
793where
794    S: ConfigSchema + JsonSchema,
795{
796    let output_path = output_path.as_ref();
797    let full_schema = root_config_schema::<S>()?;
798    let split_paths = split_section_paths::<S>(&full_schema);
799    let root_schema = schema_for_output_path(&full_schema, &[], &split_paths)?;
800    let mut targets = vec![ConfigSchemaTarget {
801        path: output_path.to_path_buf(),
802        content: schema_json(&root_schema)?,
803    }];
804
805    for section_path in &split_paths {
806        let schema_path = schema_path_for_section(output_path, section_path);
807        let section_schema = schema_for_output_path(&full_schema, section_path, &split_paths)?;
808
809        targets.push(ConfigSchemaTarget {
810            path: schema_path,
811            content: schema_json(&section_schema)?,
812        });
813    }
814
815    Ok(targets)
816}
817
818/// Writes the root schema and section schemas for a config type.
819///
820/// Parent directories are created before each schema is written. Generated
821/// schemas omit JSON Schema `required` constraints so they can be used for IDE
822/// completion against partial config files. The root schema does not complete
823/// split nested section fields.
824///
825/// # Type Parameters
826///
827/// - `S`: Config schema type that derives [`JsonSchema`] and exposes section
828///   metadata through [`ConfigSchema`].
829///
830/// # Arguments
831///
832/// - `output_path`: Destination path for the root JSON Schema.
833///
834/// # Returns
835///
836/// Returns `Ok(())` after all schema files have been written.
837///
838/// # Examples
839///
840/// ```
841/// use confique::Config;
842/// use rust_config_tree::{ConfigSchema, write_config_schemas};
843/// use schemars::JsonSchema;
844///
845/// #[derive(Config, JsonSchema)]
846/// struct AppConfig {
847///     #[config(default = [])]
848///     include: Vec<std::path::PathBuf>,
849///     #[config(default = "demo")]
850///     mode: String,
851/// }
852///
853/// impl ConfigSchema for AppConfig {
854///     fn include_paths(layer: &<Self as Config>::Layer) -> Vec<std::path::PathBuf> {
855///         layer.include.clone().unwrap_or_default()
856///     }
857/// }
858///
859/// let path = std::env::temp_dir()
860///     .join("rust-config-tree-write-schemas-doctest")
861///     .join("config.schema.json");
862/// write_config_schemas::<AppConfig>(&path)?;
863///
864/// assert!(path.exists());
865/// # let _ = std::fs::remove_file(&path);
866/// # if let Some(parent) = path.parent() { let _ = std::fs::remove_dir_all(parent); }
867/// # Ok::<(), rust_config_tree::ConfigError>(())
868/// ```
869pub fn write_config_schemas<S>(output_path: impl AsRef<Path>) -> ConfigResult<()>
870where
871    S: ConfigSchema + JsonSchema,
872{
873    for target in config_schema_targets_for_path::<S>(output_path)? {
874        write_template(&target.path, &target.content)?;
875    }
876
877    Ok(())
878}
879
880/// Collects every nested `confique` section path from schema metadata.
881///
882/// # Arguments
883///
884/// - `meta`: Root `confique` metadata to traverse.
885///
886/// # Returns
887///
888/// Returns nested section paths in metadata traversal order.
889///
890/// # Examples
891///
892/// ```no_run
893/// let _ = ();
894/// ```
895pub(crate) fn nested_section_paths(meta: &'static Meta) -> Vec<Vec<&'static str>> {
896    let mut paths = Vec::new();
897    collect_nested_section_paths(meta, &mut Vec::new(), &mut paths);
898    paths
899}
900
901/// Finds nested sections whose field schema opts into template/schema splitting.
902///
903/// # Type Parameters
904///
905/// - `S`: Config schema type whose metadata supplies nested section paths.
906///
907/// # Arguments
908///
909/// - `full_schema`: Full root schema containing `x-tree-split` markers.
910///
911/// # Returns
912///
913/// Returns nested section paths that should be split.
914///
915/// # Examples
916///
917/// ```no_run
918/// let _ = ();
919/// ```
920pub(crate) fn split_section_paths<S>(full_schema: &Value) -> Vec<Vec<&'static str>>
921where
922    S: ConfigSchema,
923{
924    nested_section_paths(&S::META)
925        .into_iter()
926        .filter(|section_path| section_has_tree_split_marker(full_schema, section_path))
927        .collect()
928}
929
930/// Checks whether a section property carries the split marker extension.
931///
932/// # Arguments
933///
934/// - `root_schema`: Full root schema to inspect.
935/// - `section_path`: Nested section field path to check.
936///
937/// # Returns
938///
939/// Returns `true` when the section schema carries `x-tree-split = true`.
940///
941/// # Examples
942///
943/// ```no_run
944/// let _ = ();
945/// ```
946fn section_has_tree_split_marker(root_schema: &Value, section_path: &[&str]) -> bool {
947    section_property_schema_for_path(root_schema, section_path)
948        .and_then(|schema| schema.get(TREE_SPLIT_SCHEMA_EXTENSION))
949        .and_then(Value::as_bool)
950        .unwrap_or(false)
951}
952
953/// Returns the original property schema for a nested section path.
954///
955/// # Arguments
956///
957/// - `root_schema`: Full root schema to traverse.
958/// - `section_path`: Nested section field path to locate.
959///
960/// # Returns
961///
962/// Returns the original property schema when the section path exists.
963///
964/// # Examples
965///
966/// ```no_run
967/// let _ = ();
968/// ```
969fn section_property_schema_for_path<'a>(
970    root_schema: &'a Value,
971    section_path: &[&str],
972) -> Option<&'a Value> {
973    let mut current = root_schema;
974
975    for (index, section) in section_path.iter().enumerate() {
976        let property = current.get("properties")?.get(*section)?;
977        if index + 1 == section_path.len() {
978            return Some(property);
979        }
980
981        current = resolve_schema_reference(root_schema, property).unwrap_or(property);
982    }
983
984    None
985}
986
987/// Recursively appends nested section paths to `paths`.
988///
989/// # Arguments
990///
991/// - `meta`: Current `confique` metadata node.
992/// - `prefix`: Mutable section path prefix for `meta`.
993/// - `paths`: Output list receiving discovered nested section paths.
994///
995/// # Returns
996///
997/// Returns no value; `paths` and `prefix` are updated during traversal.
998///
999/// # Examples
1000///
1001/// ```no_run
1002/// let _ = ();
1003/// ```
1004fn collect_nested_section_paths(
1005    meta: &'static Meta,
1006    prefix: &mut Vec<&'static str>,
1007    paths: &mut Vec<Vec<&'static str>>,
1008) {
1009    for field in meta.fields {
1010        if let FieldKind::Nested { meta } = field.kind {
1011            prefix.push(field.name);
1012            paths.push(prefix.clone());
1013            collect_nested_section_paths(meta, prefix, paths);
1014            prefix.pop();
1015        }
1016    }
1017}
1018
1019/// Returns split sections that are direct children of `section_path`.
1020///
1021/// # Arguments
1022///
1023/// - `section_path`: Parent section path to match.
1024/// - `split_paths`: All split section paths.
1025///
1026/// # Returns
1027///
1028/// Returns split paths whose parent is exactly `section_path`.
1029///
1030/// # Examples
1031///
1032/// ```no_run
1033/// let _ = ();
1034/// ```
1035pub(crate) fn direct_child_split_section_paths(
1036    section_path: &[&'static str],
1037    split_paths: &[Vec<&'static str>],
1038) -> Vec<Vec<&'static str>> {
1039    split_paths
1040        .iter()
1041        .filter(|path| path.len() == section_path.len() + 1 && path.starts_with(section_path))
1042        .cloned()
1043        .collect()
1044}