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 ¤t_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 ¤t_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(§ion_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}