cfgmatic-schema 5.0.0

Schema and introspection types for cfgmatic configuration framework
Documentation
//! Schema and introspection types for cfgmatic.

#![warn(missing_docs)]
#![deny(unsafe_code)]

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// Environment binding metadata for a schema node.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct EnvBinding {
    /// Optional environment variable prefix.
    pub prefix: Option<String>,
    /// Environment key name.
    pub key: String,
    /// Optional path separator.
    pub separator: Option<String>,
}

/// Merge hint for a schema node.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MergeHint {
    /// Replace the existing value.
    Replace,
    /// Deep-merge nested object values.
    DeepMerge,
    /// Append array values.
    Append,
    /// Keep unique array values.
    Unique,
}

/// Validation metadata for a schema node.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidationRule {
    /// Minimum numeric value.
    Min(i64),
    /// Maximum numeric value.
    Max(i64),
    /// Minimum string or sequence length.
    MinLen(usize),
    /// Maximum string or sequence length.
    MaxLen(usize),
    /// Regular-expression pattern.
    Pattern(String),
    /// Named custom validator.
    Custom(String),
}

/// Kind of schema node.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SchemaKind {
    /// Struct/object node.
    Struct,
    /// Enum node.
    Enum,
    /// Boolean node.
    Bool,
    /// Integer node.
    Integer,
    /// Float node.
    Float,
    /// String node.
    String,
    /// Sequence node.
    Sequence,
    /// Map node.
    Map,
    /// Arbitrary value node.
    Any,
}

impl SchemaKind {
    /// Return the display name of the kind.
    #[must_use]
    pub const fn as_str(&self) -> &'static str {
        match self {
            Self::Struct => "struct",
            Self::Enum => "enum",
            Self::Bool => "bool",
            Self::Integer => "integer",
            Self::Float => "float",
            Self::String => "string",
            Self::Sequence => "sequence",
            Self::Map => "map",
            Self::Any => "any",
        }
    }
}

impl std::fmt::Display for SchemaKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Schema node describing a configuration path.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SchemaNode {
    /// JSON Pointer path for this node.
    pub path: String,
    /// Logical field name.
    pub name: String,
    /// Schema kind.
    pub kind: SchemaKind,
    /// Rust type name.
    pub rust_type: String,
    /// Whether this node is required.
    pub required: bool,
    /// Whether this node accepts null.
    pub nullable: bool,
    /// Default value.
    pub default: Option<serde_json::Value>,
    /// Alias names for the node.
    pub aliases: Vec<String>,
    /// Environment binding metadata.
    pub env: Option<EnvBinding>,
    /// Merge hint for the node.
    pub merge_hint: Option<MergeHint>,
    /// Documentation string.
    pub docs: Option<String>,
    /// Validation rules.
    pub validations: Vec<ValidationRule>,
    /// Child nodes.
    pub children: BTreeMap<String, Self>,
}

impl SchemaNode {
    /// Create a new schema node.
    #[must_use]
    pub fn new(
        path: impl Into<String>,
        name: impl Into<String>,
        kind: SchemaKind,
        rust_type: impl Into<String>,
    ) -> Self {
        Self {
            path: path.into(),
            name: name.into(),
            kind,
            rust_type: rust_type.into(),
            required: false,
            nullable: false,
            default: None,
            aliases: Vec::new(),
            env: None,
            merge_hint: None,
            docs: None,
            validations: Vec::new(),
            children: BTreeMap::new(),
        }
    }

    /// Mark the node as required.
    #[must_use]
    pub const fn required(mut self, required: bool) -> Self {
        self.required = required;
        self
    }

    /// Mark the node as nullable.
    #[must_use]
    pub const fn nullable(mut self, nullable: bool) -> Self {
        self.nullable = nullable;
        self
    }

    /// Set the default value.
    #[must_use]
    pub fn default_value(mut self, default: serde_json::Value) -> Self {
        self.default = Some(default);
        self
    }

    /// Add an alias.
    #[must_use]
    pub fn alias(mut self, alias: impl Into<String>) -> Self {
        self.aliases.push(alias.into());
        self
    }

    /// Set environment metadata.
    #[must_use]
    pub fn env_binding(mut self, env: EnvBinding) -> Self {
        self.env = Some(env);
        self
    }

    /// Set merge hint.
    #[must_use]
    pub const fn merge_hint(mut self, merge_hint: MergeHint) -> Self {
        self.merge_hint = Some(merge_hint);
        self
    }

    /// Set docs text.
    #[must_use]
    pub fn docs(mut self, docs: impl Into<String>) -> Self {
        self.docs = Some(docs.into());
        self
    }

    /// Add a validation rule.
    #[must_use]
    pub fn validation(mut self, rule: ValidationRule) -> Self {
        self.validations.push(rule);
        self
    }

    /// Add a child node.
    #[must_use]
    pub fn child(mut self, key: impl Into<String>, child: Self) -> Self {
        self.children.insert(key.into(), child);
        self
    }

    /// Find a node by JSON Pointer path.
    #[must_use]
    pub fn find<'a>(&'a self, path: &str) -> Option<&'a Self> {
        if path == self.path {
            return Some(self);
        }

        if path == "/" {
            return (self.path == "/").then_some(self);
        }

        let mut node = self;
        for segment in path.trim_start_matches('/').split('/') {
            if segment.is_empty() {
                continue;
            }
            let decoded = segment.replace("~1", "/").replace("~0", "~");
            node = node.children.get(&decoded)?;
        }
        Some(node)
    }
}

/// Trait for configuration types that can expose a schema.
pub trait ConfigSchema {
    /// Return the schema for the configuration type.
    fn schema() -> SchemaNode;
}

#[cfg(test)]
mod tests {
    use super::*;

    struct AppConfig;

    impl ConfigSchema for AppConfig {
        fn schema() -> SchemaNode {
            SchemaNode::new("/", "root", SchemaKind::Struct, "AppConfig").child(
                "server",
                SchemaNode::new("/server", "server", SchemaKind::Struct, "ServerConfig").child(
                    "port",
                    SchemaNode::new("/server/port", "port", SchemaKind::Integer, "u16")
                        .required(true),
                ),
            )
        }
    }

    #[test]
    fn test_find_nested_node() {
        let schema = AppConfig::schema();

        let node = schema.find("/server/port").unwrap();
        assert_eq!(node.path, "/server/port");
        assert_eq!(node.kind, SchemaKind::Integer);
        assert!(node.required);
    }

    #[test]
    fn test_config_schema_trait() {
        let schema = AppConfig::schema();
        assert_eq!(schema.path, "/");
        assert!(schema.find("/server").is_some());
    }

    #[test]
    fn test_schema_node_preserves_metadata_contract() {
        let node = SchemaNode::new("/database/url", "url", SchemaKind::String, "String")
            .required(true)
            .nullable(false)
            .default_value(serde_json::json!("postgres://localhost"))
            .alias("DATABASE_URL")
            .env_binding(EnvBinding {
                prefix: Some("APP".to_string()),
                key: "DATABASE__URL".to_string(),
                separator: Some("__".to_string()),
            })
            .merge_hint(MergeHint::Replace)
            .docs("Database connection string")
            .validation(ValidationRule::MinLen(1))
            .validation(ValidationRule::Pattern("^postgres://".to_string()));

        assert_eq!(node.path, "/database/url");
        assert_eq!(node.kind, SchemaKind::String);
        assert_eq!(
            node.default,
            Some(serde_json::json!("postgres://localhost"))
        );
        assert_eq!(node.aliases, vec!["DATABASE_URL"]);
        assert_eq!(node.env.as_ref().unwrap().key, "DATABASE__URL");
        assert_eq!(node.merge_hint, Some(MergeHint::Replace));
        assert_eq!(node.docs.as_deref(), Some("Database connection string"));
        assert_eq!(node.validations.len(), 2);
    }

    #[test]
    fn test_find_root_and_missing_path() {
        let schema = AppConfig::schema();

        assert_eq!(schema.find("/").unwrap().name, "root");
        assert!(schema.find("/server/host").is_none());
        assert!(schema.find("/missing").is_none());
    }

    #[test]
    fn test_find_decodes_json_pointer_segments() {
        let schema = SchemaNode::new("/", "root", SchemaKind::Struct, "Root")
            .child(
                "a/b",
                SchemaNode::new("/a~1b", "a/b", SchemaKind::String, "String"),
            )
            .child(
                "tilde~key",
                SchemaNode::new("/tilde~0key", "tilde~key", SchemaKind::String, "String"),
            );

        assert_eq!(schema.find("/a~1b").unwrap().name, "a/b");
        assert_eq!(schema.find("/tilde~0key").unwrap().name, "tilde~key");
    }

    #[test]
    fn test_schema_kind_display_names() {
        assert_eq!(SchemaKind::Struct.as_str(), "struct");
        assert_eq!(SchemaKind::Integer.to_string(), "integer");
        assert_eq!(SchemaKind::Any.to_string(), "any");
    }

    #[test]
    fn test_children_are_kept_in_deterministic_order() {
        let schema = SchemaNode::new("/", "root", SchemaKind::Struct, "Root")
            .child(
                "zeta",
                SchemaNode::new("/zeta", "zeta", SchemaKind::Bool, "bool"),
            )
            .child(
                "alpha",
                SchemaNode::new("/alpha", "alpha", SchemaKind::Bool, "bool"),
            );

        let keys: Vec<_> = schema.children.keys().cloned().collect();
        assert_eq!(keys, vec!["alpha".to_string(), "zeta".to_string()]);
    }
}