holoconf_core/
schema.rs

1//! Schema validation for configuration (ADR-007, FEAT-004)
2//!
3//! Provides JSON Schema based validation with two-phase validation:
4//! - Phase 1 (structural): Validates structure after merge, interpolations allowed
5//! - Phase 2 (type/value): Validates resolved values against constraints
6
7use std::path::Path;
8use std::sync::Arc;
9
10use crate::error::{Error, Result};
11use crate::value::Value;
12
13/// Schema for validating configuration
14#[derive(Debug, Clone)]
15pub struct Schema {
16    /// The JSON Schema as a serde_json::Value
17    schema: serde_json::Value,
18    /// Compiled JSON Schema validator (wrapped in Arc for Clone)
19    compiled: Arc<jsonschema::Validator>,
20}
21
22impl Schema {
23    /// Load a schema from a JSON string
24    pub fn from_json(json: &str) -> Result<Self> {
25        let schema: serde_json::Value = serde_json::from_str(json)
26            .map_err(|e| Error::parse(format!("Invalid JSON schema: {}", e)))?;
27        Self::from_value(schema)
28    }
29
30    /// Load a schema from a YAML string
31    pub fn from_yaml(yaml: &str) -> Result<Self> {
32        let schema: serde_json::Value = serde_yaml::from_str(yaml)
33            .map_err(|e| Error::parse(format!("Invalid YAML schema: {}", e)))?;
34        Self::from_value(schema)
35    }
36
37    /// Load a schema from a file (JSON or YAML based on extension)
38    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
39        let path = path.as_ref();
40        let content = std::fs::read_to_string(path)
41            .map_err(|_| Error::file_not_found(path.display().to_string(), None))?;
42
43        match path.extension().and_then(|e| e.to_str()) {
44            Some("json") => Self::from_json(&content),
45            Some("yaml") | Some("yml") => Self::from_yaml(&content),
46            _ => Self::from_yaml(&content), // Default to YAML
47        }
48    }
49
50    /// Create a schema from a serde_json::Value
51    fn from_value(schema: serde_json::Value) -> Result<Self> {
52        let compiled = jsonschema::validator_for(&schema)
53            .map_err(|e| Error::parse(format!("Invalid JSON Schema: {}", e)))?;
54        Ok(Self {
55            schema,
56            compiled: Arc::new(compiled),
57        })
58    }
59
60    /// Validate a Value against this schema
61    ///
62    /// Returns Ok(()) if valid, or an error with details about the first validation failure.
63    pub fn validate(&self, value: &Value) -> Result<()> {
64        // Convert Value to serde_json::Value for validation
65        let json_value = value_to_json(value);
66
67        // Use iter_errors to get an iterator of validation errors
68        let mut errors = self.compiled.iter_errors(&json_value);
69        if let Some(error) = errors.next() {
70            let path = error.instance_path.to_string();
71            let message = error.to_string();
72            return Err(Error::validation(
73                if path.is_empty() { "<root>" } else { &path },
74                &message,
75            ));
76        }
77        Ok(())
78    }
79
80    /// Validate and collect all errors (instead of failing on first)
81    pub fn validate_collect(&self, value: &Value) -> Vec<ValidationError> {
82        let json_value = value_to_json(value);
83
84        self.compiled
85            .iter_errors(&json_value)
86            .map(|e| ValidationError {
87                path: e.instance_path.to_string(),
88                message: e.to_string(),
89            })
90            .collect()
91    }
92
93    /// Get the raw schema value
94    pub fn as_value(&self) -> &serde_json::Value {
95        &self.schema
96    }
97}
98
99/// A single validation error
100#[derive(Debug, Clone)]
101pub struct ValidationError {
102    /// Path to the invalid value (e.g., "/database/port")
103    pub path: String,
104    /// Error message
105    pub message: String,
106}
107
108impl std::fmt::Display for ValidationError {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        if self.path.is_empty() {
111            write!(f, "{}", self.message)
112        } else {
113            write!(f, "{}: {}", self.path, self.message)
114        }
115    }
116}
117
118/// Convert a holoconf Value to serde_json::Value
119fn value_to_json(value: &Value) -> serde_json::Value {
120    match value {
121        Value::Null => serde_json::Value::Null,
122        Value::Bool(b) => serde_json::Value::Bool(*b),
123        Value::Integer(i) => serde_json::Value::Number((*i).into()),
124        Value::Float(f) => serde_json::Number::from_f64(*f)
125            .map(serde_json::Value::Number)
126            .unwrap_or(serde_json::Value::Null),
127        Value::String(s) => serde_json::Value::String(s.clone()),
128        Value::Sequence(seq) => serde_json::Value::Array(seq.iter().map(value_to_json).collect()),
129        Value::Mapping(map) => {
130            let obj: serde_json::Map<String, serde_json::Value> = map
131                .iter()
132                .map(|(k, v)| (k.clone(), value_to_json(v)))
133                .collect();
134            serde_json::Value::Object(obj)
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_schema_from_yaml() {
145        let schema_yaml = r#"
146type: object
147required:
148  - name
149properties:
150  name:
151    type: string
152  port:
153    type: integer
154    minimum: 1
155    maximum: 65535
156"#;
157        let schema = Schema::from_yaml(schema_yaml).unwrap();
158        assert!(schema.as_value().is_object());
159    }
160
161    #[test]
162    fn test_validate_valid_config() {
163        let schema = Schema::from_yaml(
164            r#"
165type: object
166properties:
167  name:
168    type: string
169  port:
170    type: integer
171"#,
172        )
173        .unwrap();
174
175        let mut map = indexmap::IndexMap::new();
176        map.insert("name".into(), Value::String("myapp".into()));
177        map.insert("port".into(), Value::Integer(8080));
178        let config = Value::Mapping(map);
179
180        assert!(schema.validate(&config).is_ok());
181    }
182
183    #[test]
184    fn test_validate_missing_required() {
185        let schema = Schema::from_yaml(
186            r#"
187type: object
188required:
189  - name
190properties:
191  name:
192    type: string
193"#,
194        )
195        .unwrap();
196
197        let config = Value::Mapping(indexmap::IndexMap::new());
198        let result = schema.validate(&config);
199        assert!(result.is_err());
200        let err = result.unwrap_err();
201        assert!(err.to_string().contains("name"));
202    }
203
204    #[test]
205    fn test_validate_wrong_type() {
206        let schema = Schema::from_yaml(
207            r#"
208type: object
209properties:
210  port:
211    type: integer
212"#,
213        )
214        .unwrap();
215
216        let mut map = indexmap::IndexMap::new();
217        map.insert("port".into(), Value::String("not-a-number".into()));
218        let config = Value::Mapping(map);
219
220        let result = schema.validate(&config);
221        assert!(result.is_err());
222    }
223
224    #[test]
225    fn test_validate_constraint_violation() {
226        let schema = Schema::from_yaml(
227            r#"
228type: object
229properties:
230  port:
231    type: integer
232    minimum: 1
233    maximum: 65535
234"#,
235        )
236        .unwrap();
237
238        let mut map = indexmap::IndexMap::new();
239        map.insert("port".into(), Value::Integer(70000));
240        let config = Value::Mapping(map);
241
242        let result = schema.validate(&config);
243        assert!(result.is_err());
244    }
245
246    #[test]
247    fn test_validate_enum() {
248        let schema = Schema::from_yaml(
249            r#"
250type: object
251properties:
252  log_level:
253    type: string
254    enum: [debug, info, warn, error]
255"#,
256        )
257        .unwrap();
258
259        // Valid enum value
260        let mut map = indexmap::IndexMap::new();
261        map.insert("log_level".into(), Value::String("info".into()));
262        let config = Value::Mapping(map);
263        assert!(schema.validate(&config).is_ok());
264
265        // Invalid enum value
266        let mut map = indexmap::IndexMap::new();
267        map.insert("log_level".into(), Value::String("verbose".into()));
268        let config = Value::Mapping(map);
269        assert!(schema.validate(&config).is_err());
270    }
271
272    #[test]
273    fn test_validate_nested() {
274        let schema = Schema::from_yaml(
275            r#"
276type: object
277properties:
278  database:
279    type: object
280    required: [host]
281    properties:
282      host:
283        type: string
284      port:
285        type: integer
286        default: 5432
287"#,
288        )
289        .unwrap();
290
291        // Valid nested config
292        let mut db = indexmap::IndexMap::new();
293        db.insert("host".into(), Value::String("localhost".into()));
294        db.insert("port".into(), Value::Integer(5432));
295        let mut map = indexmap::IndexMap::new();
296        map.insert("database".into(), Value::Mapping(db));
297        let config = Value::Mapping(map);
298        assert!(schema.validate(&config).is_ok());
299
300        // Missing required nested key
301        let db = indexmap::IndexMap::new();
302        let mut map = indexmap::IndexMap::new();
303        map.insert("database".into(), Value::Mapping(db));
304        let config = Value::Mapping(map);
305        assert!(schema.validate(&config).is_err());
306    }
307
308    #[test]
309    fn test_validate_collect_multiple_errors() {
310        let schema = Schema::from_yaml(
311            r#"
312type: object
313required:
314  - name
315  - port
316properties:
317  name:
318    type: string
319  port:
320    type: integer
321"#,
322        )
323        .unwrap();
324
325        let config = Value::Mapping(indexmap::IndexMap::new());
326        let errors = schema.validate_collect(&config);
327        // Should have at least one error about missing required fields
328        assert!(!errors.is_empty());
329    }
330
331    #[test]
332    fn test_validate_additional_properties_allowed() {
333        // By default, additional properties are allowed
334        let schema = Schema::from_yaml(
335            r#"
336type: object
337properties:
338  name:
339    type: string
340"#,
341        )
342        .unwrap();
343
344        let mut map = indexmap::IndexMap::new();
345        map.insert("name".into(), Value::String("myapp".into()));
346        map.insert("extra".into(), Value::String("allowed".into()));
347        let config = Value::Mapping(map);
348        assert!(schema.validate(&config).is_ok());
349    }
350
351    #[test]
352    fn test_validate_additional_properties_denied() {
353        let schema = Schema::from_yaml(
354            r#"
355type: object
356properties:
357  name:
358    type: string
359additionalProperties: false
360"#,
361        )
362        .unwrap();
363
364        let mut map = indexmap::IndexMap::new();
365        map.insert("name".into(), Value::String("myapp".into()));
366        map.insert("extra".into(), Value::String("not allowed".into()));
367        let config = Value::Mapping(map);
368        assert!(schema.validate(&config).is_err());
369    }
370
371    #[test]
372    fn test_validate_array() {
373        let schema = Schema::from_yaml(
374            r#"
375type: object
376properties:
377  servers:
378    type: array
379    items:
380      type: string
381"#,
382        )
383        .unwrap();
384
385        let mut map = indexmap::IndexMap::new();
386        map.insert(
387            "servers".into(),
388            Value::Sequence(vec![
389                Value::String("server1".into()),
390                Value::String("server2".into()),
391            ]),
392        );
393        let config = Value::Mapping(map);
394        assert!(schema.validate(&config).is_ok());
395
396        // Wrong item type
397        let mut map = indexmap::IndexMap::new();
398        map.insert(
399            "servers".into(),
400            Value::Sequence(vec![Value::String("server1".into()), Value::Integer(123)]),
401        );
402        let config = Value::Mapping(map);
403        assert!(schema.validate(&config).is_err());
404    }
405
406    #[test]
407    fn test_validate_pattern() {
408        let schema = Schema::from_yaml(
409            r#"
410type: object
411properties:
412  version:
413    type: string
414    pattern: "^\\d+\\.\\d+\\.\\d+$"
415"#,
416        )
417        .unwrap();
418
419        // Valid semver
420        let mut map = indexmap::IndexMap::new();
421        map.insert("version".into(), Value::String("1.2.3".into()));
422        let config = Value::Mapping(map);
423        assert!(schema.validate(&config).is_ok());
424
425        // Invalid format
426        let mut map = indexmap::IndexMap::new();
427        map.insert("version".into(), Value::String("v1.2".into()));
428        let config = Value::Mapping(map);
429        assert!(schema.validate(&config).is_err());
430    }
431}