1use ciborium::value::Value as CborValue;
2use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum Severity {
6 Error,
7 Warning,
8}
9
10#[derive(Debug, Clone)]
11pub struct SchemaDiagnostic {
12 pub code: &'static str,
13 pub severity: Severity,
14 pub message: String,
15 pub path: String,
16}
17
18pub fn validate_value_against_schema(
19 schema: &SchemaIr,
20 value: &CborValue,
21) -> Vec<SchemaDiagnostic> {
22 let mut diags = Vec::new();
23 validate_inner(schema, value, "$", &mut diags);
24 diags
25}
26
27fn validate_inner(
28 schema: &SchemaIr,
29 value: &CborValue,
30 path: &str,
31 diags: &mut Vec<SchemaDiagnostic>,
32) {
33 match schema {
34 SchemaIr::Object {
35 properties,
36 required,
37 additional,
38 } => validate_object(properties, required, additional, value, path, diags),
39 SchemaIr::Array {
40 items,
41 min_items,
42 max_items,
43 } => validate_array(items, *min_items, *max_items, value, path, diags),
44 SchemaIr::String {
45 min_len,
46 max_len,
47 regex,
48 format,
49 } => validate_string(
50 *min_len,
51 *max_len,
52 regex.as_deref(),
53 format.as_deref(),
54 value,
55 path,
56 diags,
57 ),
58 SchemaIr::Int { min, max } => validate_int(*min, *max, value, path, diags),
59 SchemaIr::Float { min, max } => validate_float(*min, *max, value, path, diags),
60 SchemaIr::Bool => require_kind("boolean", matches!(value, CborValue::Bool(_)), path, diags),
61 SchemaIr::Null => require_kind("null", matches!(value, CborValue::Null), path, diags),
62 SchemaIr::Bytes => require_kind("bytes", matches!(value, CborValue::Bytes(_)), path, diags),
63 SchemaIr::Enum { values } => validate_enum(values, value, path, diags),
64 SchemaIr::OneOf { variants } => validate_one_of(variants, value, path, diags),
65 SchemaIr::Ref { id } => {
66 diags.push(SchemaDiagnostic {
67 code: "SCHEMA_REF_UNSUPPORTED",
68 severity: Severity::Error,
69 message: format!("schema ref '{}' is not supported", id),
70 path: path.to_string(),
71 });
72 }
73 }
74}
75
76fn require_kind(kind: &str, ok: bool, path: &str, diags: &mut Vec<SchemaDiagnostic>) {
77 if !ok {
78 diags.push(SchemaDiagnostic {
79 code: "SCHEMA_TYPE_MISMATCH",
80 severity: Severity::Error,
81 message: format!("expected {kind} at {path}"),
82 path: path.to_string(),
83 });
84 }
85}
86
87fn validate_object(
88 properties: &std::collections::BTreeMap<String, SchemaIr>,
89 required: &[String],
90 additional: &AdditionalProperties,
91 value: &CborValue,
92 path: &str,
93 diags: &mut Vec<SchemaDiagnostic>,
94) {
95 let map = match value {
96 CborValue::Map(entries) => entries,
97 _ => {
98 require_kind("object", false, path, diags);
99 return;
100 }
101 };
102
103 let mut values: std::collections::BTreeMap<String, &CborValue> =
104 std::collections::BTreeMap::new();
105 for (k, v) in map {
106 match k {
107 CborValue::Text(s) => {
108 values.insert(s.clone(), v);
109 }
110 _ => {
111 diags.push(SchemaDiagnostic {
112 code: "SCHEMA_INVALID_KEY",
113 severity: Severity::Error,
114 message: format!("non-string object key at {path}"),
115 path: path.to_string(),
116 });
117 }
118 }
119 }
120
121 for key in required {
122 if !values.contains_key(key) {
123 diags.push(SchemaDiagnostic {
124 code: "SCHEMA_REQUIRED_MISSING",
125 severity: Severity::Error,
126 message: format!("missing required field '{key}' at {path}"),
127 path: format!("{path}.{key}"),
128 });
129 }
130 }
131
132 for (key, val) in values {
133 if let Some(prop_schema) = properties.get(&key) {
134 validate_inner(prop_schema, val, &format!("{path}.{key}"), diags);
135 continue;
136 }
137 match additional {
138 AdditionalProperties::Allow => {}
139 AdditionalProperties::Forbid => {
140 diags.push(SchemaDiagnostic {
141 code: "SCHEMA_ADDITIONAL_FORBIDDEN",
142 severity: Severity::Error,
143 message: format!("additional property '{key}' not allowed at {path}"),
144 path: format!("{path}.{key}"),
145 });
146 }
147 AdditionalProperties::Schema(schema) => {
148 validate_inner(schema, val, &format!("{path}.{key}"), diags);
149 }
150 }
151 }
152}
153
154fn validate_array(
155 items: &SchemaIr,
156 min_items: Option<u64>,
157 max_items: Option<u64>,
158 value: &CborValue,
159 path: &str,
160 diags: &mut Vec<SchemaDiagnostic>,
161) {
162 let items_val = match value {
163 CborValue::Array(items) => items,
164 _ => {
165 require_kind("array", false, path, diags);
166 return;
167 }
168 };
169 let len = items_val.len() as u64;
170 if let Some(min) = min_items
171 && len < min
172 {
173 diags.push(SchemaDiagnostic {
174 code: "SCHEMA_ARRAY_MIN_ITEMS",
175 severity: Severity::Error,
176 message: format!("array length {len} < min_items {min} at {path}"),
177 path: path.to_string(),
178 });
179 }
180 if let Some(max) = max_items
181 && len > max
182 {
183 diags.push(SchemaDiagnostic {
184 code: "SCHEMA_ARRAY_MAX_ITEMS",
185 severity: Severity::Error,
186 message: format!("array length {len} > max_items {max} at {path}"),
187 path: path.to_string(),
188 });
189 }
190 for (idx, item) in items_val.iter().enumerate() {
191 validate_inner(items, item, &format!("{path}[{idx}]"), diags);
192 }
193}
194
195fn validate_string(
196 min_len: Option<u64>,
197 max_len: Option<u64>,
198 regex: Option<&str>,
199 format: Option<&str>,
200 value: &CborValue,
201 path: &str,
202 diags: &mut Vec<SchemaDiagnostic>,
203) {
204 let text = match value {
205 CborValue::Text(s) => s,
206 _ => {
207 require_kind("string", false, path, diags);
208 return;
209 }
210 };
211 let len = text.chars().count() as u64;
212 if let Some(min) = min_len
213 && len < min
214 {
215 diags.push(SchemaDiagnostic {
216 code: "SCHEMA_STRING_MIN_LEN",
217 severity: Severity::Error,
218 message: format!("string length {len} < min_len {min} at {path}"),
219 path: path.to_string(),
220 });
221 }
222 if let Some(max) = max_len
223 && len > max
224 {
225 diags.push(SchemaDiagnostic {
226 code: "SCHEMA_STRING_MAX_LEN",
227 severity: Severity::Error,
228 message: format!("string length {len} > max_len {max} at {path}"),
229 path: path.to_string(),
230 });
231 }
232 if regex.is_some() {
233 diags.push(SchemaDiagnostic {
234 code: "SCHEMA_REGEX_UNSUPPORTED",
235 severity: Severity::Warning,
236 message: format!("regex constraint not enforced at {path}"),
237 path: path.to_string(),
238 });
239 }
240 if format.is_some() {
241 diags.push(SchemaDiagnostic {
242 code: "SCHEMA_FORMAT_UNSUPPORTED",
243 severity: Severity::Warning,
244 message: format!("format constraint not enforced at {path}"),
245 path: path.to_string(),
246 });
247 }
248}
249
250fn validate_int(
251 min: Option<i64>,
252 max: Option<i64>,
253 value: &CborValue,
254 path: &str,
255 diags: &mut Vec<SchemaDiagnostic>,
256) {
257 let num = match value {
258 CborValue::Integer(i) => i128::from(*i),
259 _ => {
260 require_kind("integer", false, path, diags);
261 return;
262 }
263 };
264 if let Some(min) = min
265 && num < min as i128
266 {
267 diags.push(SchemaDiagnostic {
268 code: "SCHEMA_INT_MIN",
269 severity: Severity::Error,
270 message: format!("integer {num} < min {min} at {path}"),
271 path: path.to_string(),
272 });
273 }
274 if let Some(max) = max
275 && num > max as i128
276 {
277 diags.push(SchemaDiagnostic {
278 code: "SCHEMA_INT_MAX",
279 severity: Severity::Error,
280 message: format!("integer {num} > max {max} at {path}"),
281 path: path.to_string(),
282 });
283 }
284}
285
286fn validate_float(
287 min: Option<f64>,
288 max: Option<f64>,
289 value: &CborValue,
290 path: &str,
291 diags: &mut Vec<SchemaDiagnostic>,
292) {
293 let num = match value {
294 CborValue::Float(f) => *f,
295 CborValue::Integer(i) => i128::from(*i) as f64,
296 _ => {
297 require_kind("number", false, path, diags);
298 return;
299 }
300 };
301 if let Some(min) = min
302 && num < min
303 {
304 diags.push(SchemaDiagnostic {
305 code: "SCHEMA_FLOAT_MIN",
306 severity: Severity::Error,
307 message: format!("number {num} < min {min} at {path}"),
308 path: path.to_string(),
309 });
310 }
311 if let Some(max) = max
312 && num > max
313 {
314 diags.push(SchemaDiagnostic {
315 code: "SCHEMA_FLOAT_MAX",
316 severity: Severity::Error,
317 message: format!("number {num} > max {max} at {path}"),
318 path: path.to_string(),
319 });
320 }
321}
322
323fn validate_enum(
324 values: &[CborValue],
325 value: &CborValue,
326 path: &str,
327 diags: &mut Vec<SchemaDiagnostic>,
328) {
329 if values.iter().any(|candidate| candidate == value) {
330 return;
331 }
332 diags.push(SchemaDiagnostic {
333 code: "SCHEMA_ENUM",
334 severity: Severity::Error,
335 message: format!("value is not in enum at {path}"),
336 path: path.to_string(),
337 });
338}
339
340fn validate_one_of(
341 variants: &[SchemaIr],
342 value: &CborValue,
343 path: &str,
344 diags: &mut Vec<SchemaDiagnostic>,
345) {
346 for variant in variants {
347 let mut local = Vec::new();
348 validate_inner(variant, value, path, &mut local);
349 if local.iter().all(|d| d.severity != Severity::Error) {
350 return;
351 }
352 }
353 diags.push(SchemaDiagnostic {
354 code: "SCHEMA_ONE_OF",
355 severity: Severity::Error,
356 message: format!("value does not match any oneOf variant at {path}"),
357 path: path.to_string(),
358 });
359}