Skip to main content

nap_core/validation/
mod.rs

1//! Validation layer for NAP structured merge.
2//!
3//! Three stages:
4//!
5//! **Stage 1 — SDL validation**
6//! Ensure the SDL document itself is valid:
7//! - Valid merge strategy for each property
8//! - Required fields exist
9//! - Identity keys defined where required
10//! - Property names don't contain dots (V1 limitation)
11//!
12//! **Stage 2 — Manifest validation**
13//! Ensure the manifest conforms to the SDL:
14//! - Required fields are present
15//! - Types match the schema
16//!
17//! **Stage 3 — Merge strategy validation**
18//! Ensure merge strategies have all required metadata:
19//! - `ordered_unique` has identity rule
20//! - `edge_list` has source_key, target_key, identity rule
21//!
22//! All validation MUST complete before persistence.
23
24pub mod manifest;
25pub mod merge;
26pub mod sdl;
27
28use crate::merge::conflict::MergeResult;
29use crate::merge::sdl::SdlDocument;
30use serde_json::Value;
31
32/// A validation error.
33#[derive(Debug, Clone)]
34pub struct ValidationError {
35    /// Human-readable description of the error.
36    pub message: String,
37
38    /// Optional path to the field that caused the error.
39    pub path: Option<String>,
40}
41
42impl std::fmt::Display for ValidationError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match &self.path {
45            Some(p) => write!(f, "{}: {}", p, self.message),
46            None => write!(f, "{}", self.message),
47        }
48    }
49}
50
51/// Result type for validation operations.
52pub type ValidationResult = Result<(), Vec<ValidationError>>;
53
54/// Run the full validation pipeline on a merge result.
55///
56/// Stages:
57/// 1. SDL validation (call `validate_sdl` separately, before merge)
58/// 2. Manifest validation (call `validate_manifest` separately, before merge)
59/// 3. Post-merge validation — validates the merge result against the SDL
60///
61/// This is the "before persistence" gate.
62pub fn validate_before_persist(schema: &SdlDocument, merged: &MergeResult) -> ValidationResult {
63    match merged {
64        MergeResult::Conflicts(_) => Err(vec![ValidationError {
65            message: "cannot persist — merge produced unresolved conflicts".to_string(),
66            path: None,
67        }]),
68        MergeResult::Merged(value) => merge::validate_merged(schema, value),
69    }
70}
71
72/// Check that all required paths exist in a value.
73pub fn check_required_paths(schema: &SdlDocument, value: &Value) -> Vec<ValidationError> {
74    let mut errors = Vec::new();
75
76    for required_path in &schema.schema.required {
77        // Parse the path and check existence
78        match crate::merge::path::CanonicalPath::parse(required_path) {
79            Ok(path) => {
80                if crate::merge::path::resolve_path(value, &path).is_none() {
81                    errors.push(ValidationError {
82                        message: format!("required field '{}' is missing", required_path),
83                        path: Some(required_path.clone()),
84                    });
85                }
86            }
87            Err(_) => {
88                errors.push(ValidationError {
89                    message: format!("invalid required path '{}'", required_path),
90                    path: Some(required_path.clone()),
91                });
92            }
93        }
94    }
95
96    errors
97}
98
99/// Check that a value's type matches the SDL property type.
100pub fn check_type_match(
101    type_: &crate::merge::sdl::PropertyType,
102    value: &Value,
103    path: &str,
104) -> Option<ValidationError> {
105    // null is technically valid for any type (it's "unset")
106    if value.is_null() {
107        return None;
108    }
109
110    let type_matches = match type_ {
111        crate::merge::sdl::PropertyType::String => value.is_string(),
112        crate::merge::sdl::PropertyType::Number => value.is_number(),
113        crate::merge::sdl::PropertyType::Boolean => value.is_boolean(),
114        crate::merge::sdl::PropertyType::Object => value.is_object(),
115        crate::merge::sdl::PropertyType::Array => value.is_array(),
116    };
117
118    if !type_matches {
119        Some(ValidationError {
120            message: format!(
121                "type mismatch: expected {:?}, got {}",
122                type_,
123                type_name(value)
124            ),
125            path: Some(path.to_string()),
126        })
127    } else {
128        None
129    }
130}
131
132fn type_name(value: &Value) -> &'static str {
133    match value {
134        Value::Null => "null",
135        Value::Bool(_) => "boolean",
136        Value::Number(_) => "number",
137        Value::String(_) => "string",
138        Value::Array(_) => "array",
139        Value::Object(_) => "object",
140    }
141}
142
143impl ValidationError {
144    pub fn new(message: impl Into<String>, path: impl Into<String>) -> Self {
145        ValidationError {
146            message: message.into(),
147            path: Some(path.into()),
148        }
149    }
150
151    pub fn global(message: impl Into<String>) -> Self {
152        ValidationError {
153            message: message.into(),
154            path: None,
155        }
156    }
157}
158
159/// Combine multiple validation results.
160pub fn combine(results: Vec<ValidationResult>) -> ValidationResult {
161    let mut all_errors = Vec::new();
162    for result in results {
163        match result {
164            Ok(()) => {}
165            Err(errors) => all_errors.extend(errors),
166        }
167    }
168    if all_errors.is_empty() {
169        Ok(())
170    } else {
171        Err(all_errors)
172    }
173}