1#![warn(missing_docs)]
4#![deny(unsafe_code)]
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
12pub struct EnvBinding {
13 pub prefix: Option<String>,
15 pub key: String,
17 pub separator: Option<String>,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum MergeHint {
25 Replace,
27 DeepMerge,
29 Append,
31 Unique,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ValidationRule {
39 Min(i64),
41 Max(i64),
43 MinLen(usize),
45 MaxLen(usize),
47 Pattern(String),
49 Custom(String),
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum SchemaKind {
57 Struct,
59 Enum,
61 Bool,
63 Integer,
65 Float,
67 String,
69 Sequence,
71 Map,
73 Any,
75}
76
77impl SchemaKind {
78 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct SchemaNode {
104 pub path: String,
106 pub name: String,
108 pub kind: SchemaKind,
110 pub rust_type: String,
112 pub required: bool,
114 pub nullable: bool,
116 pub default: Option<serde_json::Value>,
118 pub aliases: Vec<String>,
120 pub env: Option<EnvBinding>,
122 pub merge_hint: Option<MergeHint>,
124 pub docs: Option<String>,
126 pub validations: Vec<ValidationRule>,
128 pub children: BTreeMap<String, Self>,
130}
131
132impl SchemaNode {
133 #[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 #[must_use]
160 pub const fn required(mut self, required: bool) -> Self {
161 self.required = required;
162 self
163 }
164
165 #[must_use]
167 pub const fn nullable(mut self, nullable: bool) -> Self {
168 self.nullable = nullable;
169 self
170 }
171
172 #[must_use]
174 pub fn default_value(mut self, default: serde_json::Value) -> Self {
175 self.default = Some(default);
176 self
177 }
178
179 #[must_use]
181 pub fn alias(mut self, alias: impl Into<String>) -> Self {
182 self.aliases.push(alias.into());
183 self
184 }
185
186 #[must_use]
188 pub fn env_binding(mut self, env: EnvBinding) -> Self {
189 self.env = Some(env);
190 self
191 }
192
193 #[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 #[must_use]
202 pub fn docs(mut self, docs: impl Into<String>) -> Self {
203 self.docs = Some(docs.into());
204 self
205 }
206
207 #[must_use]
209 pub fn validation(mut self, rule: ValidationRule) -> Self {
210 self.validations.push(rule);
211 self
212 }
213
214 #[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 #[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
244pub trait ConfigSchema {
246 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}