Skip to main content

cfgmatic_schema/
lib.rs

1//! Schema and introspection types for cfgmatic.
2
3#![warn(missing_docs)]
4#![deny(unsafe_code)]
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10/// Environment binding metadata for a schema node.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
12pub struct EnvBinding {
13    /// Optional environment variable prefix.
14    pub prefix: Option<String>,
15    /// Environment key name.
16    pub key: String,
17    /// Optional path separator.
18    pub separator: Option<String>,
19}
20
21/// Merge hint for a schema node.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum MergeHint {
25    /// Replace the existing value.
26    Replace,
27    /// Deep-merge nested object values.
28    DeepMerge,
29    /// Append array values.
30    Append,
31    /// Keep unique array values.
32    Unique,
33}
34
35/// Validation metadata for a schema node.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ValidationRule {
39    /// Minimum numeric value.
40    Min(i64),
41    /// Maximum numeric value.
42    Max(i64),
43    /// Minimum string or sequence length.
44    MinLen(usize),
45    /// Maximum string or sequence length.
46    MaxLen(usize),
47    /// Regular-expression pattern.
48    Pattern(String),
49    /// Named custom validator.
50    Custom(String),
51}
52
53/// Kind of schema node.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum SchemaKind {
57    /// Struct/object node.
58    Struct,
59    /// Enum node.
60    Enum,
61    /// Boolean node.
62    Bool,
63    /// Integer node.
64    Integer,
65    /// Float node.
66    Float,
67    /// String node.
68    String,
69    /// Sequence node.
70    Sequence,
71    /// Map node.
72    Map,
73    /// Arbitrary value node.
74    Any,
75}
76
77impl SchemaKind {
78    /// Return the display name of the kind.
79    #[must_use]
80    pub const fn as_str(&self) -> &'static str {
81        match self {
82            Self::Struct => "struct",
83            Self::Enum => "enum",
84            Self::Bool => "bool",
85            Self::Integer => "integer",
86            Self::Float => "float",
87            Self::String => "string",
88            Self::Sequence => "sequence",
89            Self::Map => "map",
90            Self::Any => "any",
91        }
92    }
93}
94
95impl std::fmt::Display for SchemaKind {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str(self.as_str())
98    }
99}
100
101/// Schema node describing a configuration path.
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct SchemaNode {
104    /// JSON Pointer path for this node.
105    pub path: String,
106    /// Logical field name.
107    pub name: String,
108    /// Schema kind.
109    pub kind: SchemaKind,
110    /// Rust type name.
111    pub rust_type: String,
112    /// Whether this node is required.
113    pub required: bool,
114    /// Whether this node accepts null.
115    pub nullable: bool,
116    /// Default value.
117    pub default: Option<serde_json::Value>,
118    /// Alias names for the node.
119    pub aliases: Vec<String>,
120    /// Environment binding metadata.
121    pub env: Option<EnvBinding>,
122    /// Merge hint for the node.
123    pub merge_hint: Option<MergeHint>,
124    /// Documentation string.
125    pub docs: Option<String>,
126    /// Validation rules.
127    pub validations: Vec<ValidationRule>,
128    /// Child nodes.
129    pub children: BTreeMap<String, Self>,
130}
131
132impl SchemaNode {
133    /// Create a new schema node.
134    #[must_use]
135    pub fn new(
136        path: impl Into<String>,
137        name: impl Into<String>,
138        kind: SchemaKind,
139        rust_type: impl Into<String>,
140    ) -> Self {
141        Self {
142            path: path.into(),
143            name: name.into(),
144            kind,
145            rust_type: rust_type.into(),
146            required: false,
147            nullable: false,
148            default: None,
149            aliases: Vec::new(),
150            env: None,
151            merge_hint: None,
152            docs: None,
153            validations: Vec::new(),
154            children: BTreeMap::new(),
155        }
156    }
157
158    /// Mark the node as required.
159    #[must_use]
160    pub const fn required(mut self, required: bool) -> Self {
161        self.required = required;
162        self
163    }
164
165    /// Mark the node as nullable.
166    #[must_use]
167    pub const fn nullable(mut self, nullable: bool) -> Self {
168        self.nullable = nullable;
169        self
170    }
171
172    /// Set the default value.
173    #[must_use]
174    pub fn default_value(mut self, default: serde_json::Value) -> Self {
175        self.default = Some(default);
176        self
177    }
178
179    /// Add an alias.
180    #[must_use]
181    pub fn alias(mut self, alias: impl Into<String>) -> Self {
182        self.aliases.push(alias.into());
183        self
184    }
185
186    /// Set environment metadata.
187    #[must_use]
188    pub fn env_binding(mut self, env: EnvBinding) -> Self {
189        self.env = Some(env);
190        self
191    }
192
193    /// Set merge hint.
194    #[must_use]
195    pub const fn merge_hint(mut self, merge_hint: MergeHint) -> Self {
196        self.merge_hint = Some(merge_hint);
197        self
198    }
199
200    /// Set docs text.
201    #[must_use]
202    pub fn docs(mut self, docs: impl Into<String>) -> Self {
203        self.docs = Some(docs.into());
204        self
205    }
206
207    /// Add a validation rule.
208    #[must_use]
209    pub fn validation(mut self, rule: ValidationRule) -> Self {
210        self.validations.push(rule);
211        self
212    }
213
214    /// Add a child node.
215    #[must_use]
216    pub fn child(mut self, key: impl Into<String>, child: Self) -> Self {
217        self.children.insert(key.into(), child);
218        self
219    }
220
221    /// Find a node by JSON Pointer path.
222    #[must_use]
223    pub fn find<'a>(&'a self, path: &str) -> Option<&'a Self> {
224        if path == self.path {
225            return Some(self);
226        }
227
228        if path == "/" {
229            return (self.path == "/").then_some(self);
230        }
231
232        let mut node = self;
233        for segment in path.trim_start_matches('/').split('/') {
234            if segment.is_empty() {
235                continue;
236            }
237            let decoded = segment.replace("~1", "/").replace("~0", "~");
238            node = node.children.get(&decoded)?;
239        }
240        Some(node)
241    }
242}
243
244/// Trait for configuration types that can expose a schema.
245pub trait ConfigSchema {
246    /// Return the schema for the configuration type.
247    fn schema() -> SchemaNode;
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    struct AppConfig;
255
256    impl ConfigSchema for AppConfig {
257        fn schema() -> SchemaNode {
258            SchemaNode::new("/", "root", SchemaKind::Struct, "AppConfig").child(
259                "server",
260                SchemaNode::new("/server", "server", SchemaKind::Struct, "ServerConfig").child(
261                    "port",
262                    SchemaNode::new("/server/port", "port", SchemaKind::Integer, "u16")
263                        .required(true),
264                ),
265            )
266        }
267    }
268
269    #[test]
270    fn test_find_nested_node() {
271        let schema = AppConfig::schema();
272
273        let node = schema.find("/server/port").unwrap();
274        assert_eq!(node.path, "/server/port");
275        assert_eq!(node.kind, SchemaKind::Integer);
276        assert!(node.required);
277    }
278
279    #[test]
280    fn test_config_schema_trait() {
281        let schema = AppConfig::schema();
282        assert_eq!(schema.path, "/");
283        assert!(schema.find("/server").is_some());
284    }
285
286    #[test]
287    fn test_schema_node_preserves_metadata_contract() {
288        let node = SchemaNode::new("/database/url", "url", SchemaKind::String, "String")
289            .required(true)
290            .nullable(false)
291            .default_value(serde_json::json!("postgres://localhost"))
292            .alias("DATABASE_URL")
293            .env_binding(EnvBinding {
294                prefix: Some("APP".to_string()),
295                key: "DATABASE__URL".to_string(),
296                separator: Some("__".to_string()),
297            })
298            .merge_hint(MergeHint::Replace)
299            .docs("Database connection string")
300            .validation(ValidationRule::MinLen(1))
301            .validation(ValidationRule::Pattern("^postgres://".to_string()));
302
303        assert_eq!(node.path, "/database/url");
304        assert_eq!(node.kind, SchemaKind::String);
305        assert_eq!(
306            node.default,
307            Some(serde_json::json!("postgres://localhost"))
308        );
309        assert_eq!(node.aliases, vec!["DATABASE_URL"]);
310        assert_eq!(node.env.as_ref().unwrap().key, "DATABASE__URL");
311        assert_eq!(node.merge_hint, Some(MergeHint::Replace));
312        assert_eq!(node.docs.as_deref(), Some("Database connection string"));
313        assert_eq!(node.validations.len(), 2);
314    }
315
316    #[test]
317    fn test_find_root_and_missing_path() {
318        let schema = AppConfig::schema();
319
320        assert_eq!(schema.find("/").unwrap().name, "root");
321        assert!(schema.find("/server/host").is_none());
322        assert!(schema.find("/missing").is_none());
323    }
324
325    #[test]
326    fn test_find_decodes_json_pointer_segments() {
327        let schema = SchemaNode::new("/", "root", SchemaKind::Struct, "Root")
328            .child(
329                "a/b",
330                SchemaNode::new("/a~1b", "a/b", SchemaKind::String, "String"),
331            )
332            .child(
333                "tilde~key",
334                SchemaNode::new("/tilde~0key", "tilde~key", SchemaKind::String, "String"),
335            );
336
337        assert_eq!(schema.find("/a~1b").unwrap().name, "a/b");
338        assert_eq!(schema.find("/tilde~0key").unwrap().name, "tilde~key");
339    }
340
341    #[test]
342    fn test_schema_kind_display_names() {
343        assert_eq!(SchemaKind::Struct.as_str(), "struct");
344        assert_eq!(SchemaKind::Integer.to_string(), "integer");
345        assert_eq!(SchemaKind::Any.to_string(), "any");
346    }
347
348    #[test]
349    fn test_children_are_kept_in_deterministic_order() {
350        let schema = SchemaNode::new("/", "root", SchemaKind::Struct, "Root")
351            .child(
352                "zeta",
353                SchemaNode::new("/zeta", "zeta", SchemaKind::Bool, "bool"),
354            )
355            .child(
356                "alpha",
357                SchemaNode::new("/alpha", "alpha", SchemaKind::Bool, "bool"),
358            );
359
360        let keys: Vec<_> = schema.children.keys().cloned().collect();
361        assert_eq!(keys, vec!["alpha".to_string(), "zeta".to_string()]);
362    }
363}