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