use std::{
collections::BTreeSet,
path::{Path, PathBuf},
};
use confique::meta::{FieldKind, Meta};
use schemars::{JsonSchema, generate::SchemaSettings};
use serde_json::Value;
use crate::{
config::{ConfigResult, ConfigSchema},
config_output::write_template,
config_util::ensure_single_trailing_newline,
};
const TREE_SPLIT_SCHEMA_EXTENSION: &str = "x-tree-split";
const ENV_ONLY_SCHEMA_EXTENSION: &str = "x-env-only";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigSchemaTarget {
pub path: PathBuf,
pub content: String,
}
pub(crate) fn root_config_schema<S>() -> ConfigResult<Value>
where
S: JsonSchema,
{
let generator = SchemaSettings::draft07().into_generator();
let schema = generator.into_root_schema_for::<S>();
let mut schema = serde_json::to_value(schema)?;
remove_required_recursively(&mut schema);
Ok(schema)
}
fn schema_json(schema: &Value) -> ConfigResult<String> {
let mut json = serde_json::to_string_pretty(schema)?;
ensure_single_trailing_newline(&mut json);
Ok(json)
}
fn remove_required_recursively(value: &mut Value) {
match value {
Value::Object(object) => {
object.remove("required");
for (key, child) in object.iter_mut() {
if is_schema_map_key(key) {
remove_required_from_schema_map(child);
} else {
remove_required_recursively(child);
}
}
}
Value::Array(items) => {
for item in items {
remove_required_recursively(item);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
fn is_schema_map_key(key: &str) -> bool {
matches!(
key,
"$defs" | "definitions" | "properties" | "patternProperties"
)
}
fn remove_required_from_schema_map(value: &mut Value) {
match value {
Value::Object(object) => {
for schema in object.values_mut() {
remove_required_recursively(schema);
}
}
_ => remove_required_recursively(value),
}
}
fn section_schema_for_path(root_schema: &Value, section_path: &[&str]) -> Option<Value> {
let mut current = root_schema;
for section in section_path {
current = current.get("properties")?.get(*section)?;
current = resolve_schema_reference(root_schema, current).unwrap_or(current);
}
Some(standalone_section_schema(root_schema, current))
}
fn resolve_schema_reference<'a>(root_schema: &'a Value, schema: &'a Value) -> Option<&'a Value> {
if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
return resolve_json_pointer_ref(root_schema, reference);
}
schema
.get("allOf")
.and_then(Value::as_array)
.and_then(|schemas| schemas.first())
.and_then(|schema| schema.get("$ref"))
.and_then(Value::as_str)
.and_then(|reference| resolve_json_pointer_ref(root_schema, reference))
}
fn resolve_json_pointer_ref<'a>(root_schema: &'a Value, reference: &str) -> Option<&'a Value> {
let pointer = reference.strip_prefix('#')?;
root_schema.pointer(pointer)
}
fn standalone_section_schema(root_schema: &Value, section_schema: &Value) -> Value {
let mut section_schema = section_schema.clone();
let Some(object) = section_schema.as_object_mut() else {
return section_schema;
};
if let Some(schema_uri) = root_schema.get("$schema") {
object
.entry("$schema".to_owned())
.or_insert_with(|| schema_uri.clone());
}
if let Some(definitions) = root_schema.get("definitions") {
object
.entry("definitions".to_owned())
.or_insert_with(|| definitions.clone());
}
if let Some(defs) = root_schema.get("$defs") {
object
.entry("$defs".to_owned())
.or_insert_with(|| defs.clone());
}
section_schema
}
pub(crate) fn schema_path_for_section(root_schema_path: &Path, section_path: &[&str]) -> PathBuf {
let Some((last, parents)) = section_path.split_last() else {
return root_schema_path.to_path_buf();
};
let mut path = root_schema_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
for parent in parents {
path.push(*parent);
}
path.push(format!("{}.schema.json", *last));
path
}
fn schema_for_output_path(
full_schema: &Value,
section_path: &[&'static str],
split_paths: &[Vec<&'static str>],
) -> ConfigResult<Value> {
let mut schema = if section_path.is_empty() {
full_schema.clone()
} else {
section_schema_for_path(full_schema, section_path).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"failed to extract JSON Schema for config section {}",
section_path.join(".")
),
)
})?
};
remove_child_section_properties(&mut schema, section_path, split_paths);
remove_env_only_properties(&mut schema);
remove_empty_object_properties(&mut schema);
prune_unused_schema_maps(&mut schema);
remove_schema_extensions(&mut schema);
Ok(schema)
}
fn remove_child_section_properties(
schema: &mut Value,
section_path: &[&'static str],
split_paths: &[Vec<&'static str>],
) {
let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
return;
};
for child_section_path in direct_child_split_section_paths(section_path, split_paths) {
if let Some(child_name) = child_section_path.last() {
properties.remove(*child_name);
}
}
}
fn remove_env_only_properties(value: &mut Value) {
match value {
Value::Object(object) => {
if let Some(properties) = object.get_mut("properties").and_then(Value::as_object_mut) {
properties.retain(|_, schema| {
!schema
.get(ENV_ONLY_SCHEMA_EXTENSION)
.and_then(Value::as_bool)
.unwrap_or(false)
});
for schema in properties.values_mut() {
remove_env_only_properties(schema);
}
}
for (key, child) in object.iter_mut() {
if key != "properties" {
remove_env_only_properties(child);
}
}
}
Value::Array(items) => {
for item in items {
remove_env_only_properties(item);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
fn remove_empty_object_properties(schema: &mut Value) {
loop {
let root_schema = schema.clone();
if !remove_empty_object_properties_with_root(schema, &root_schema) {
break;
}
}
}
fn remove_empty_object_properties_with_root(value: &mut Value, root_schema: &Value) -> bool {
let mut changed = false;
match value {
Value::Object(object) => {
if let Some(properties) = object.get_mut("properties").and_then(Value::as_object_mut) {
let before_len = properties.len();
properties.retain(|_, schema| !is_empty_object_schema(root_schema, schema));
changed |= properties.len() != before_len;
for schema in properties.values_mut() {
changed |= remove_empty_object_properties_with_root(schema, root_schema);
}
}
for (key, child) in object.iter_mut() {
if key != "properties" {
changed |= remove_empty_object_properties_with_root(child, root_schema);
}
}
}
Value::Array(items) => {
for item in items {
changed |= remove_empty_object_properties_with_root(item, root_schema);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
changed
}
fn is_empty_object_schema(root_schema: &Value, schema: &Value) -> bool {
let schema = resolve_schema_reference(root_schema, schema).unwrap_or(schema);
let Some(object) = schema.as_object() else {
return false;
};
let is_object = object.get("type").and_then(Value::as_str) == Some("object")
|| object.contains_key("properties");
let has_properties = object
.get("properties")
.and_then(Value::as_object)
.is_some_and(|properties| !properties.is_empty());
let has_dynamic_properties =
object.contains_key("additionalProperties") || object.contains_key("patternProperties");
is_object && !has_properties && !has_dynamic_properties
}
fn prune_unused_schema_maps(schema: &mut Value) {
let mut definitions = BTreeSet::new();
let mut defs = BTreeSet::new();
collect_schema_refs(schema, false, &mut definitions, &mut defs);
loop {
let previous_len = definitions.len() + defs.len();
collect_transitive_schema_refs(schema, &mut definitions, &mut defs);
if definitions.len() + defs.len() == previous_len {
break;
}
}
retain_schema_map(schema, "definitions", &definitions);
retain_schema_map(schema, "$defs", &defs);
}
fn collect_transitive_schema_refs(
schema: &Value,
definitions: &mut BTreeSet<String>,
defs: &mut BTreeSet<String>,
) {
let current_definitions = definitions.clone();
let current_defs = defs.clone();
let mut referenced_definitions = BTreeSet::new();
let mut referenced_defs = BTreeSet::new();
if let Some(schema_map) = schema.get("definitions").and_then(Value::as_object) {
for name in ¤t_definitions {
if let Some(schema) = schema_map.get(name) {
collect_schema_refs(
schema,
true,
&mut referenced_definitions,
&mut referenced_defs,
);
}
}
}
if let Some(schema_map) = schema.get("$defs").and_then(Value::as_object) {
for name in ¤t_defs {
if let Some(schema) = schema_map.get(name) {
collect_schema_refs(
schema,
true,
&mut referenced_definitions,
&mut referenced_defs,
);
}
}
}
definitions.extend(referenced_definitions);
defs.extend(referenced_defs);
}
fn collect_schema_refs(
value: &Value,
include_schema_maps: bool,
definitions: &mut BTreeSet<String>,
defs: &mut BTreeSet<String>,
) {
match value {
Value::Object(object) => {
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
collect_schema_ref(reference, definitions, defs);
}
for (key, child) in object {
if !include_schema_maps && matches!(key.as_str(), "definitions" | "$defs") {
continue;
}
collect_schema_refs(child, include_schema_maps, definitions, defs);
}
}
Value::Array(items) => {
for item in items {
collect_schema_refs(item, include_schema_maps, definitions, defs);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
fn collect_schema_ref(
reference: &str,
definitions: &mut BTreeSet<String>,
defs: &mut BTreeSet<String>,
) {
if let Some(name) = schema_ref_name(reference, "#/definitions/") {
definitions.insert(name);
} else if let Some(name) = schema_ref_name(reference, "#/$defs/") {
defs.insert(name);
}
}
fn schema_ref_name(reference: &str, prefix: &str) -> Option<String> {
let name = reference.strip_prefix(prefix)?.split('/').next()?;
Some(decode_json_pointer_token(name))
}
fn decode_json_pointer_token(token: &str) -> String {
token.replace("~1", "/").replace("~0", "~")
}
fn retain_schema_map(schema: &mut Value, key: &str, used_names: &BTreeSet<String>) {
let Some(object) = schema.as_object_mut() else {
return;
};
let Some(schema_map) = object.get_mut(key).and_then(Value::as_object_mut) else {
return;
};
schema_map.retain(|name, _| used_names.contains(name));
if schema_map.is_empty() {
object.remove(key);
}
}
fn remove_schema_extensions(value: &mut Value) {
match value {
Value::Object(object) => {
object.remove(TREE_SPLIT_SCHEMA_EXTENSION);
object.remove(ENV_ONLY_SCHEMA_EXTENSION);
for child in object.values_mut() {
remove_schema_extensions(child);
}
}
Value::Array(items) => {
for item in items {
remove_schema_extensions(item);
}
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
pub fn write_config_schema<S>(output_path: impl AsRef<Path>) -> ConfigResult<()>
where
S: JsonSchema,
{
let mut schema = root_config_schema::<S>()?;
remove_env_only_properties(&mut schema);
remove_empty_object_properties(&mut schema);
prune_unused_schema_maps(&mut schema);
remove_schema_extensions(&mut schema);
let json = schema_json(&schema)?;
write_template(output_path.as_ref(), &json)
}
pub fn config_schema_targets_for_path<S>(
output_path: impl AsRef<Path>,
) -> ConfigResult<Vec<ConfigSchemaTarget>>
where
S: ConfigSchema + JsonSchema,
{
let output_path = output_path.as_ref();
let full_schema = root_config_schema::<S>()?;
let split_paths = split_section_paths::<S>(&full_schema);
let root_schema = schema_for_output_path(&full_schema, &[], &split_paths)?;
let mut targets = vec![ConfigSchemaTarget {
path: output_path.to_path_buf(),
content: schema_json(&root_schema)?,
}];
for section_path in &split_paths {
let schema_path = schema_path_for_section(output_path, section_path);
let section_schema = schema_for_output_path(&full_schema, section_path, &split_paths)?;
targets.push(ConfigSchemaTarget {
path: schema_path,
content: schema_json(§ion_schema)?,
});
}
Ok(targets)
}
pub fn write_config_schemas<S>(output_path: impl AsRef<Path>) -> ConfigResult<()>
where
S: ConfigSchema + JsonSchema,
{
for target in config_schema_targets_for_path::<S>(output_path)? {
write_template(&target.path, &target.content)?;
}
Ok(())
}
pub(crate) fn nested_section_paths(meta: &'static Meta) -> Vec<Vec<&'static str>> {
let mut paths = Vec::new();
collect_nested_section_paths(meta, &mut Vec::new(), &mut paths);
paths
}
pub(crate) fn split_section_paths<S>(full_schema: &Value) -> Vec<Vec<&'static str>>
where
S: ConfigSchema,
{
nested_section_paths(&S::META)
.into_iter()
.filter(|section_path| section_has_tree_split_marker(full_schema, section_path))
.collect()
}
pub(crate) fn env_only_field_paths<S>(full_schema: &Value) -> Vec<Vec<&'static str>>
where
S: ConfigSchema,
{
let mut paths = Vec::new();
collect_env_only_field_paths(&S::META, full_schema, &mut Vec::new(), &mut paths);
paths
}
fn section_has_tree_split_marker(root_schema: &Value, section_path: &[&str]) -> bool {
property_schema_for_path(root_schema, section_path)
.and_then(|schema| schema.get(TREE_SPLIT_SCHEMA_EXTENSION))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn field_has_env_only_marker(root_schema: &Value, field_path: &[&str]) -> bool {
property_schema_for_path(root_schema, field_path)
.and_then(|schema| schema.get(ENV_ONLY_SCHEMA_EXTENSION))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn property_schema_for_path<'a>(root_schema: &'a Value, path: &[&str]) -> Option<&'a Value> {
let mut current = root_schema;
for (index, section) in path.iter().enumerate() {
let property = current.get("properties")?.get(*section)?;
if index + 1 == path.len() {
return Some(property);
}
current = resolve_schema_reference(root_schema, property).unwrap_or(property);
}
None
}
fn collect_nested_section_paths(
meta: &'static Meta,
prefix: &mut Vec<&'static str>,
paths: &mut Vec<Vec<&'static str>>,
) {
for field in meta.fields {
if let FieldKind::Nested { meta } = field.kind {
prefix.push(field.name);
paths.push(prefix.clone());
collect_nested_section_paths(meta, prefix, paths);
prefix.pop();
}
}
}
fn collect_env_only_field_paths(
meta: &'static Meta,
root_schema: &Value,
prefix: &mut Vec<&'static str>,
paths: &mut Vec<Vec<&'static str>>,
) {
for field in meta.fields {
prefix.push(field.name);
match field.kind {
FieldKind::Leaf { .. } => {
if field_has_env_only_marker(root_schema, prefix) {
paths.push(prefix.clone());
}
}
FieldKind::Nested { meta } => {
collect_env_only_field_paths(meta, root_schema, prefix, paths);
}
}
prefix.pop();
}
}
pub(crate) fn direct_child_split_section_paths(
section_path: &[&'static str],
split_paths: &[Vec<&'static str>],
) -> Vec<Vec<&'static str>> {
split_paths
.iter()
.filter(|path| path.len() == section_path.len() + 1 && path.starts_with(section_path))
.cloned()
.collect()
}