Skip to main content

husako_core/
validate.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::path::Path;
4
5use serde_json::Value;
6
7use crate::quantity;
8
9const MAX_DEPTH: usize = 64;
10
11// ---------------------------------------------------------------------------
12// SchemaStore
13// ---------------------------------------------------------------------------
14
15/// Loaded `_schema.json` with resolved GVK index.
16#[derive(Debug, Clone)]
17pub struct SchemaStore {
18    gvk_index: HashMap<String, String>,
19    schemas: HashMap<String, Value>,
20}
21
22impl SchemaStore {
23    /// Load from parsed `_schema.json` content.
24    pub fn from_json(value: &Value) -> Option<Self> {
25        let obj = value.as_object()?;
26
27        let version = obj.get("version")?.as_u64()?;
28        if version != 2 {
29            return None;
30        }
31
32        let gvk_index: HashMap<String, String> = obj
33            .get("gvk_index")?
34            .as_object()?
35            .iter()
36            .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
37            .collect();
38
39        let schemas: HashMap<String, Value> = obj
40            .get("schemas")?
41            .as_object()?
42            .iter()
43            .map(|(k, v)| (k.clone(), v.clone()))
44            .collect();
45
46        Some(Self { gvk_index, schemas })
47    }
48
49    fn schema_for_gvk(&self, api_version: &str, kind: &str) -> Option<&Value> {
50        let key = format!("{api_version}:{kind}");
51        let schema_name = self.gvk_index.get(&key)?;
52        self.schemas.get(schema_name)
53    }
54
55    fn resolve_ref(&self, ref_name: &str) -> Option<&Value> {
56        self.schemas.get(ref_name)
57    }
58}
59
60/// Load a `SchemaStore` from `.husako/types/k8s/_schema.json` if it exists.
61pub fn load_schema_store(project_root: &Path) -> Option<SchemaStore> {
62    let path = project_root.join(".husako/types/k8s/_schema.json");
63    let content = std::fs::read_to_string(path).ok()?;
64    let value: Value = serde_json::from_str(&content).ok()?;
65    SchemaStore::from_json(&value)
66}
67
68// ---------------------------------------------------------------------------
69// Errors
70// ---------------------------------------------------------------------------
71
72#[derive(Debug)]
73pub struct ValidationError {
74    pub doc_index: usize,
75    pub path: String,
76    pub kind: ValidationErrorKind,
77}
78
79#[derive(Debug)]
80pub enum ValidationErrorKind {
81    TypeMismatch { expected: &'static str, got: String },
82    MissingRequired { field: String },
83    InvalidEnum { value: String, allowed: Vec<String> },
84    InvalidQuantity { value: String },
85    PatternMismatch { value: String, pattern: String },
86    BelowMinimum { value: f64, minimum: f64 },
87    AboveMaximum { value: f64, maximum: f64 },
88}
89
90impl fmt::Display for ValidationError {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        write!(f, "doc[{}] at {}: ", self.doc_index, self.path)?;
93        match &self.kind {
94            ValidationErrorKind::TypeMismatch { expected, got } => {
95                write!(f, "expected type {expected}, got {got}")
96            }
97            ValidationErrorKind::MissingRequired { field } => {
98                write!(f, "missing required field \"{field}\"")
99            }
100            ValidationErrorKind::InvalidEnum { value, allowed } => {
101                let opts = allowed.join(", ");
102                write!(f, "invalid value \"{value}\", expected one of: {opts}")
103            }
104            ValidationErrorKind::InvalidQuantity { value } => {
105                write!(f, "invalid quantity \"{value}\"")
106            }
107            ValidationErrorKind::PatternMismatch { value, pattern } => {
108                write!(f, "value \"{value}\" does not match pattern \"{pattern}\"")
109            }
110            ValidationErrorKind::BelowMinimum { value, minimum } => {
111                write!(f, "value {value} is below minimum {minimum}")
112            }
113            ValidationErrorKind::AboveMaximum { value, maximum } => {
114                write!(f, "value {value} is above maximum {maximum}")
115            }
116        }
117    }
118}
119
120// ---------------------------------------------------------------------------
121// Validation entry point
122// ---------------------------------------------------------------------------
123
124/// Validate all documents in the build output.
125///
126/// If a `SchemaStore` is available, validates each document against its
127/// schema (looked up by apiVersion + kind). Falls back to quantity-only
128/// heuristic validation when no store is available or no schema matches.
129pub fn validate(value: &Value, store: Option<&SchemaStore>) -> Result<(), Vec<ValidationError>> {
130    let docs = match value.as_array() {
131        Some(arr) => arr,
132        None => return Ok(()),
133    };
134
135    let mut errors = Vec::new();
136
137    for (idx, doc) in docs.iter().enumerate() {
138        if let Some(store) = store {
139            let api_version = doc.get("apiVersion").and_then(Value::as_str).unwrap_or("");
140            let kind = doc.get("kind").and_then(Value::as_str).unwrap_or("");
141
142            if let Some(schema) = store.schema_for_gvk(api_version, kind) {
143                validate_value(doc, schema, store, "$", idx, 0, &mut errors);
144                continue;
145            }
146        }
147        // Fallback: quantity-only heuristic
148        validate_doc_fallback(doc, idx, &mut errors);
149    }
150
151    if errors.is_empty() {
152        Ok(())
153    } else {
154        Err(errors)
155    }
156}
157
158fn validate_doc_fallback(doc: &Value, doc_index: usize, errors: &mut Vec<ValidationError>) {
159    let mut qty_errors = Vec::new();
160    quantity::validate_doc_fallback(doc, doc_index, &mut qty_errors);
161    for qe in qty_errors {
162        errors.push(ValidationError {
163            doc_index: qe.doc_index,
164            path: qe.path,
165            kind: ValidationErrorKind::InvalidQuantity { value: qe.value },
166        });
167    }
168}
169
170// ---------------------------------------------------------------------------
171// Recursive schema walker
172// ---------------------------------------------------------------------------
173
174fn validate_value(
175    value: &Value,
176    schema: &Value,
177    store: &SchemaStore,
178    path: &str,
179    doc_index: usize,
180    depth: usize,
181    errors: &mut Vec<ValidationError>,
182) {
183    if depth > MAX_DEPTH {
184        return;
185    }
186
187    // Skip null values (treat as "not set")
188    if value.is_null() {
189        return;
190    }
191
192    // Handle $ref
193    if let Some(ref_name) = schema.get("$ref").and_then(Value::as_str) {
194        if let Some(resolved) = store.resolve_ref(ref_name) {
195            validate_value(value, resolved, store, path, doc_index, depth + 1, errors);
196        }
197        return;
198    }
199
200    // Handle allOf
201    if let Some(all_of) = schema.get("allOf").and_then(Value::as_array) {
202        for sub in all_of {
203            validate_value(value, sub, store, path, doc_index, depth + 1, errors);
204        }
205        return;
206    }
207
208    // Handle x-kubernetes-int-or-string
209    if schema
210        .get("x-kubernetes-int-or-string")
211        .and_then(Value::as_bool)
212        == Some(true)
213    {
214        match value {
215            Value::Number(_) | Value::String(_) => {}
216            _ => {
217                errors.push(ValidationError {
218                    doc_index,
219                    path: path.to_string(),
220                    kind: ValidationErrorKind::TypeMismatch {
221                        expected: "integer or string",
222                        got: json_type_name(value).to_string(),
223                    },
224                });
225            }
226        }
227        return;
228    }
229
230    // Check format (dispatch before generic type check)
231    if let Some(format) = schema.get("format").and_then(Value::as_str)
232        && format == "quantity"
233    {
234        validate_quantity(value, path, doc_index, errors);
235        return;
236    }
237
238    // Check type
239    if let Some(type_str) = schema.get("type").and_then(Value::as_str)
240        && !check_type(value, type_str)
241    {
242        errors.push(ValidationError {
243            doc_index,
244            path: path.to_string(),
245            kind: ValidationErrorKind::TypeMismatch {
246                expected: type_str_to_label(type_str),
247                got: json_type_name(value).to_string(),
248            },
249        });
250        return;
251    }
252
253    // Check enum
254    if let Some(enum_vals) = schema.get("enum").and_then(Value::as_array)
255        && let Value::String(s) = value
256    {
257        let allowed: Vec<String> = enum_vals
258            .iter()
259            .filter_map(Value::as_str)
260            .map(String::from)
261            .collect();
262        if !allowed.iter().any(|a| a == s) {
263            errors.push(ValidationError {
264                doc_index,
265                path: path.to_string(),
266                kind: ValidationErrorKind::InvalidEnum {
267                    value: s.clone(),
268                    allowed,
269                },
270            });
271            return;
272        }
273    }
274
275    // Check numeric bounds
276    if let Some(n) = value_as_f64(value) {
277        if let Some(min) = schema.get("minimum").and_then(value_as_f64_ref)
278            && n < min
279        {
280            errors.push(ValidationError {
281                doc_index,
282                path: path.to_string(),
283                kind: ValidationErrorKind::BelowMinimum {
284                    value: n,
285                    minimum: min,
286                },
287            });
288        }
289        if let Some(max) = schema.get("maximum").and_then(value_as_f64_ref)
290            && n > max
291        {
292            errors.push(ValidationError {
293                doc_index,
294                path: path.to_string(),
295                kind: ValidationErrorKind::AboveMaximum {
296                    value: n,
297                    maximum: max,
298                },
299            });
300        }
301    }
302
303    // Check pattern
304    if let Some(pattern) = schema.get("pattern").and_then(Value::as_str)
305        && let Value::String(s) = value
306        && let Ok(re) = regex_lite::Regex::new(pattern)
307        && !re.is_match(s)
308    {
309        errors.push(ValidationError {
310            doc_index,
311            path: path.to_string(),
312            kind: ValidationErrorKind::PatternMismatch {
313                value: s.clone(),
314                pattern: pattern.to_string(),
315            },
316        });
317    }
318
319    // Check required fields + recurse into properties/additionalProperties
320    if let Value::Object(obj) = value {
321        if let Some(required) = schema.get("required").and_then(Value::as_array) {
322            for req in required {
323                if let Some(field) = req.as_str()
324                    && !obj.contains_key(field)
325                {
326                    errors.push(ValidationError {
327                        doc_index,
328                        path: path.to_string(),
329                        kind: ValidationErrorKind::MissingRequired {
330                            field: field.to_string(),
331                        },
332                    });
333                }
334            }
335        }
336
337        if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
338            for (prop_name, prop_schema) in properties {
339                if let Some(child) = obj.get(prop_name) {
340                    let child_path = format!("{path}.{prop_name}");
341                    validate_value(
342                        child,
343                        prop_schema,
344                        store,
345                        &child_path,
346                        doc_index,
347                        depth + 1,
348                        errors,
349                    );
350                }
351            }
352        }
353
354        if let Some(additional) = schema.get("additionalProperties") {
355            let known_props: std::collections::HashSet<&str> = schema
356                .get("properties")
357                .and_then(Value::as_object)
358                .map(|p| p.keys().map(String::as_str).collect())
359                .unwrap_or_default();
360
361            for (key, child) in obj {
362                if !known_props.contains(key.as_str()) {
363                    let child_path = format!("{path}.{key}");
364                    validate_value(
365                        child,
366                        additional,
367                        store,
368                        &child_path,
369                        doc_index,
370                        depth + 1,
371                        errors,
372                    );
373                }
374            }
375        }
376    }
377
378    // Recurse into array items
379    if let Value::Array(arr) = value
380        && let Some(items) = schema.get("items")
381    {
382        for (i, item) in arr.iter().enumerate() {
383            let item_path = format!("{path}[{i}]");
384            validate_value(item, items, store, &item_path, doc_index, depth + 1, errors);
385        }
386    }
387}
388
389// ---------------------------------------------------------------------------
390// Helpers
391// ---------------------------------------------------------------------------
392
393fn validate_quantity(
394    value: &Value,
395    path: &str,
396    doc_index: usize,
397    errors: &mut Vec<ValidationError>,
398) {
399    match value {
400        Value::String(s) => {
401            if !quantity::is_valid_quantity(s) {
402                errors.push(ValidationError {
403                    doc_index,
404                    path: path.to_string(),
405                    kind: ValidationErrorKind::InvalidQuantity { value: s.clone() },
406                });
407            }
408        }
409        Value::Number(_) | Value::Null => {} // valid
410        _ => {
411            errors.push(ValidationError {
412                doc_index,
413                path: path.to_string(),
414                kind: ValidationErrorKind::TypeMismatch {
415                    expected: "string or number (quantity)",
416                    got: json_type_name(value).to_string(),
417                },
418            });
419        }
420    }
421}
422
423fn check_type(value: &Value, type_str: &str) -> bool {
424    match type_str {
425        "string" => value.is_string(),
426        "integer" => value.is_i64() || value.is_u64(),
427        "number" => value.is_number(),
428        "boolean" => value.is_boolean(),
429        "array" => value.is_array(),
430        "object" => value.is_object(),
431        _ => true, // unknown type → pass
432    }
433}
434
435fn type_str_to_label(s: &str) -> &'static str {
436    match s {
437        "string" => "string",
438        "integer" => "integer",
439        "number" => "number",
440        "boolean" => "boolean",
441        "array" => "array",
442        "object" => "object",
443        _ => "unknown",
444    }
445}
446
447fn json_type_name(value: &Value) -> &'static str {
448    match value {
449        Value::Null => "null",
450        Value::Bool(_) => "boolean",
451        Value::Number(_) => "number",
452        Value::String(_) => "string",
453        Value::Array(_) => "array",
454        Value::Object(_) => "object",
455    }
456}
457
458fn value_as_f64(value: &Value) -> Option<f64> {
459    value.as_f64()
460}
461
462fn value_as_f64_ref(value: &Value) -> Option<f64> {
463    value.as_f64()
464}
465
466// ---------------------------------------------------------------------------
467// Tests
468// ---------------------------------------------------------------------------
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use serde_json::json;
474
475    fn make_store(schemas_json: Value, gvk_json: Value) -> SchemaStore {
476        let store_json = json!({
477            "version": 2,
478            "gvk_index": gvk_json,
479            "schemas": schemas_json
480        });
481        SchemaStore::from_json(&store_json).unwrap()
482    }
483
484    fn simple_store() -> SchemaStore {
485        make_store(
486            json!({
487                "io.k8s.api.apps.v1.Deployment": {
488                    "properties": {
489                        "apiVersion": {"type": "string"},
490                        "kind": {"type": "string"},
491                        "metadata": {"$ref": "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"},
492                        "spec": {"$ref": "io.k8s.api.apps.v1.DeploymentSpec"}
493                    },
494                    "required": ["spec"]
495                },
496                "io.k8s.api.apps.v1.DeploymentSpec": {
497                    "properties": {
498                        "replicas": {"type": "integer"},
499                        "selector": {"$ref": "io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector"},
500                        "strategy": {"$ref": "io.k8s.api.apps.v1.DeploymentStrategy"},
501                        "template": {"$ref": "io.k8s.api.core.v1.PodTemplateSpec"}
502                    },
503                    "required": ["selector"]
504                },
505                "io.k8s.api.apps.v1.DeploymentStrategy": {
506                    "properties": {
507                        "type": {
508                            "type": "string",
509                            "enum": ["Recreate", "RollingUpdate"]
510                        }
511                    }
512                },
513                "io.k8s.api.core.v1.PodTemplateSpec": {
514                    "properties": {
515                        "spec": {"$ref": "io.k8s.api.core.v1.PodSpec"}
516                    }
517                },
518                "io.k8s.api.core.v1.PodSpec": {
519                    "properties": {
520                        "containers": {
521                            "type": "array",
522                            "items": {"$ref": "io.k8s.api.core.v1.Container"}
523                        }
524                    }
525                },
526                "io.k8s.api.core.v1.Container": {
527                    "properties": {
528                        "name": {"type": "string"},
529                        "image": {"type": "string"},
530                        "imagePullPolicy": {
531                            "type": "string",
532                            "enum": ["Always", "IfNotPresent", "Never"]
533                        },
534                        "ports": {
535                            "type": "array",
536                            "items": {"$ref": "io.k8s.api.core.v1.ContainerPort"}
537                        },
538                        "resources": {"$ref": "io.k8s.api.core.v1.ResourceRequirements"}
539                    }
540                },
541                "io.k8s.api.core.v1.ContainerPort": {
542                    "properties": {
543                        "containerPort": {
544                            "type": "integer",
545                            "minimum": 1,
546                            "maximum": 65535
547                        },
548                        "protocol": {
549                            "type": "string",
550                            "enum": ["TCP", "UDP", "SCTP"]
551                        }
552                    }
553                },
554                "io.k8s.api.core.v1.ResourceRequirements": {
555                    "properties": {
556                        "limits": {
557                            "type": "object",
558                            "additionalProperties": {"$ref": "io.k8s.apimachinery.pkg.api.resource.Quantity"}
559                        },
560                        "requests": {
561                            "type": "object",
562                            "additionalProperties": {"$ref": "io.k8s.apimachinery.pkg.api.resource.Quantity"}
563                        }
564                    }
565                },
566                "io.k8s.apimachinery.pkg.api.resource.Quantity": {
567                    "type": "string",
568                    "format": "quantity"
569                },
570                "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": {
571                    "properties": {
572                        "name": {"type": "string"},
573                        "namespace": {"type": "string"},
574                        "labels": {
575                            "type": "object",
576                            "additionalProperties": {"type": "string"}
577                        }
578                    }
579                },
580                "io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector": {
581                    "properties": {
582                        "matchLabels": {
583                            "type": "object",
584                            "additionalProperties": {"type": "string"}
585                        }
586                    }
587                }
588            }),
589            json!({
590                "apps/v1:Deployment": "io.k8s.api.apps.v1.Deployment"
591            }),
592        )
593    }
594
595    // --- Type checks ---
596
597    #[test]
598    fn type_mismatch_string_at_integer() {
599        let store = simple_store();
600        let doc = json!([{
601            "apiVersion": "apps/v1",
602            "kind": "Deployment",
603            "spec": {
604                "selector": {},
605                "replicas": "abc"
606            }
607        }]);
608        let errs = validate(&doc, Some(&store)).unwrap_err();
609        assert_eq!(errs.len(), 1);
610        assert!(errs[0].path.contains("replicas"));
611        assert!(matches!(
612            &errs[0].kind,
613            ValidationErrorKind::TypeMismatch {
614                expected: "integer",
615                ..
616            }
617        ));
618        assert!(errs[0].to_string().contains("expected type integer"));
619        assert!(errs[0].to_string().contains("string"));
620    }
621
622    // --- Required ---
623
624    #[test]
625    fn missing_required_field() {
626        let store = simple_store();
627        let doc = json!([{
628            "apiVersion": "apps/v1",
629            "kind": "Deployment",
630            "spec": {
631                "replicas": 3
632            }
633        }]);
634        let errs = validate(&doc, Some(&store)).unwrap_err();
635        assert!(errs.iter().any(|e| matches!(
636            &e.kind,
637            ValidationErrorKind::MissingRequired { field } if field == "selector"
638        )));
639    }
640
641    // --- Enum ---
642
643    #[test]
644    fn invalid_enum_value() {
645        let store = simple_store();
646        let doc = json!([{
647            "apiVersion": "apps/v1",
648            "kind": "Deployment",
649            "spec": {
650                "selector": {},
651                "strategy": {
652                    "type": "bluegreen"
653                }
654            }
655        }]);
656        let errs = validate(&doc, Some(&store)).unwrap_err();
657        assert_eq!(errs.len(), 1);
658        assert!(
659            matches!(&errs[0].kind, ValidationErrorKind::InvalidEnum { value, allowed }
660                if value == "bluegreen" && allowed.contains(&"Recreate".to_string())
661            )
662        );
663        assert!(errs[0].to_string().contains("bluegreen"));
664    }
665
666    #[test]
667    fn valid_enum_value() {
668        let store = simple_store();
669        let doc = json!([{
670            "apiVersion": "apps/v1",
671            "kind": "Deployment",
672            "spec": {
673                "selector": {},
674                "template": {
675                    "spec": {
676                        "containers": [{
677                            "imagePullPolicy": "Always"
678                        }]
679                    }
680                }
681            }
682        }]);
683        assert!(validate(&doc, Some(&store)).is_ok());
684    }
685
686    // --- Format: quantity ---
687
688    #[test]
689    fn valid_quantity() {
690        let store = simple_store();
691        let doc = json!([{
692            "apiVersion": "apps/v1",
693            "kind": "Deployment",
694            "spec": {
695                "selector": {},
696                "template": {
697                    "spec": {
698                        "containers": [{
699                            "resources": {
700                                "requests": {"cpu": "500m", "memory": "1Gi"}
701                            }
702                        }]
703                    }
704                }
705            }
706        }]);
707        assert!(validate(&doc, Some(&store)).is_ok());
708    }
709
710    #[test]
711    fn invalid_quantity() {
712        let store = simple_store();
713        let doc = json!([{
714            "apiVersion": "apps/v1",
715            "kind": "Deployment",
716            "spec": {
717                "selector": {},
718                "template": {
719                    "spec": {
720                        "containers": [{
721                            "resources": {
722                                "requests": {"cpu": "2gb"}
723                            }
724                        }]
725                    }
726                }
727            }
728        }]);
729        let errs = validate(&doc, Some(&store)).unwrap_err();
730        assert_eq!(errs.len(), 1);
731        assert!(
732            matches!(&errs[0].kind, ValidationErrorKind::InvalidQuantity { value } if value == "2gb")
733        );
734    }
735
736    #[test]
737    fn number_at_quantity_is_valid() {
738        let store = simple_store();
739        let doc = json!([{
740            "apiVersion": "apps/v1",
741            "kind": "Deployment",
742            "spec": {
743                "selector": {},
744                "template": {
745                    "spec": {
746                        "containers": [{
747                            "resources": {
748                                "limits": {"cpu": 1}
749                            }
750                        }]
751                    }
752                }
753            }
754        }]);
755        assert!(validate(&doc, Some(&store)).is_ok());
756    }
757
758    // --- Pattern ---
759
760    #[test]
761    fn pattern_match_ok() {
762        let store = make_store(
763            json!({
764                "test.Resource": {
765                    "properties": {
766                        "name": {
767                            "type": "string",
768                            "pattern": "^[a-z][a-z0-9-]*$"
769                        }
770                    }
771                }
772            }),
773            json!({ "v1:Test": "test.Resource" }),
774        );
775        let doc = json!([{
776            "apiVersion": "v1",
777            "kind": "Test",
778            "name": "my-resource-1"
779        }]);
780        assert!(validate(&doc, Some(&store)).is_ok());
781    }
782
783    #[test]
784    fn pattern_mismatch() {
785        let store = make_store(
786            json!({
787                "test.Resource": {
788                    "properties": {
789                        "name": {
790                            "type": "string",
791                            "pattern": "^[a-z][a-z0-9-]*$"
792                        }
793                    }
794                }
795            }),
796            json!({ "v1:Test": "test.Resource" }),
797        );
798        let doc = json!([{
799            "apiVersion": "v1",
800            "kind": "Test",
801            "name": "INVALID_NAME"
802        }]);
803        let errs = validate(&doc, Some(&store)).unwrap_err();
804        assert_eq!(errs.len(), 1);
805        assert!(matches!(
806            &errs[0].kind,
807            ValidationErrorKind::PatternMismatch { .. }
808        ));
809    }
810
811    // --- Bounds ---
812
813    #[test]
814    fn port_in_range() {
815        let store = simple_store();
816        let doc = json!([{
817            "apiVersion": "apps/v1",
818            "kind": "Deployment",
819            "spec": {
820                "selector": {},
821                "template": {
822                    "spec": {
823                        "containers": [{
824                            "ports": [{"containerPort": 80}]
825                        }]
826                    }
827                }
828            }
829        }]);
830        assert!(validate(&doc, Some(&store)).is_ok());
831    }
832
833    #[test]
834    fn port_below_minimum() {
835        let store = simple_store();
836        let doc = json!([{
837            "apiVersion": "apps/v1",
838            "kind": "Deployment",
839            "spec": {
840                "selector": {},
841                "template": {
842                    "spec": {
843                        "containers": [{
844                            "ports": [{"containerPort": 0}]
845                        }]
846                    }
847                }
848            }
849        }]);
850        let errs = validate(&doc, Some(&store)).unwrap_err();
851        assert!(
852            errs.iter()
853                .any(|e| matches!(&e.kind, ValidationErrorKind::BelowMinimum { .. }))
854        );
855    }
856
857    #[test]
858    fn port_above_maximum() {
859        let store = simple_store();
860        let doc = json!([{
861            "apiVersion": "apps/v1",
862            "kind": "Deployment",
863            "spec": {
864                "selector": {},
865                "template": {
866                    "spec": {
867                        "containers": [{
868                            "ports": [{"containerPort": 70000}]
869                        }]
870                    }
871                }
872            }
873        }]);
874        let errs = validate(&doc, Some(&store)).unwrap_err();
875        assert!(
876            errs.iter()
877                .any(|e| matches!(&e.kind, ValidationErrorKind::AboveMaximum { .. }))
878        );
879    }
880
881    // --- x-kubernetes-int-or-string ---
882
883    #[test]
884    fn int_or_string_number_ok() {
885        let store = make_store(
886            json!({
887                "test.Resource": {
888                    "properties": {
889                        "field": {"x-kubernetes-int-or-string": true}
890                    }
891                }
892            }),
893            json!({ "v1:Test": "test.Resource" }),
894        );
895        let doc = json!([{ "apiVersion": "v1", "kind": "Test", "field": 42 }]);
896        assert!(validate(&doc, Some(&store)).is_ok());
897    }
898
899    #[test]
900    fn int_or_string_string_ok() {
901        let store = make_store(
902            json!({
903                "test.Resource": {
904                    "properties": {
905                        "field": {"x-kubernetes-int-or-string": true}
906                    }
907                }
908            }),
909            json!({ "v1:Test": "test.Resource" }),
910        );
911        let doc = json!([{ "apiVersion": "v1", "kind": "Test", "field": "50%" }]);
912        assert!(validate(&doc, Some(&store)).is_ok());
913    }
914
915    #[test]
916    fn int_or_string_boolean_error() {
917        let store = make_store(
918            json!({
919                "test.Resource": {
920                    "properties": {
921                        "field": {"x-kubernetes-int-or-string": true}
922                    }
923                }
924            }),
925            json!({ "v1:Test": "test.Resource" }),
926        );
927        let doc = json!([{ "apiVersion": "v1", "kind": "Test", "field": true }]);
928        let errs = validate(&doc, Some(&store)).unwrap_err();
929        assert_eq!(errs.len(), 1);
930        assert!(matches!(
931            &errs[0].kind,
932            ValidationErrorKind::TypeMismatch {
933                expected: "integer or string",
934                ..
935            }
936        ));
937    }
938
939    // --- allOf ---
940
941    #[test]
942    fn allof_validates_all_sub_schemas() {
943        let store = make_store(
944            json!({
945                "test.Resource": {
946                    "properties": {
947                        "value": {
948                            "allOf": [
949                                {"type": "integer"},
950                                {"minimum": 1, "maximum": 100}
951                            ]
952                        }
953                    }
954                }
955            }),
956            json!({ "v1:Test": "test.Resource" }),
957        );
958
959        // Valid
960        let doc = json!([{ "apiVersion": "v1", "kind": "Test", "value": 50 }]);
961        assert!(validate(&doc, Some(&store)).is_ok());
962
963        // Below minimum
964        let doc = json!([{ "apiVersion": "v1", "kind": "Test", "value": 0 }]);
965        let errs = validate(&doc, Some(&store)).unwrap_err();
966        assert!(
967            errs.iter()
968                .any(|e| matches!(&e.kind, ValidationErrorKind::BelowMinimum { .. }))
969        );
970    }
971
972    // --- $ref resolution ---
973
974    #[test]
975    fn ref_resolution() {
976        let store = simple_store();
977        let doc = json!([{
978            "apiVersion": "apps/v1",
979            "kind": "Deployment",
980            "spec": {
981                "selector": {},
982                "template": {
983                    "spec": {
984                        "containers": [{
985                            "name": 123
986                        }]
987                    }
988                }
989            }
990        }]);
991        let errs = validate(&doc, Some(&store)).unwrap_err();
992        assert!(errs.iter().any(|e| e.path.contains("name")
993            && matches!(
994                &e.kind,
995                ValidationErrorKind::TypeMismatch {
996                    expected: "string",
997                    ..
998                }
999            )));
1000    }
1001
1002    // --- Null at optional position ---
1003
1004    #[test]
1005    fn null_at_optional_skip() {
1006        let store = simple_store();
1007        let doc = json!([{
1008            "apiVersion": "apps/v1",
1009            "kind": "Deployment",
1010            "spec": {
1011                "selector": {},
1012                "replicas": null
1013            }
1014        }]);
1015        assert!(validate(&doc, Some(&store)).is_ok());
1016    }
1017
1018    // --- Depth limit ---
1019
1020    #[test]
1021    fn depth_limit_no_stack_overflow() {
1022        let store = make_store(
1023            json!({
1024                "test.Recursive": {
1025                    "properties": {
1026                        "nested": {"$ref": "test.Recursive"}
1027                    }
1028                }
1029            }),
1030            json!({ "v1:Test": "test.Recursive" }),
1031        );
1032
1033        // Build a deeply nested doc (won't reach 64 in practice, but the schema refs itself)
1034        let mut inner = json!({"val": 1});
1035        for _ in 0..10 {
1036            inner = json!({"nested": inner});
1037        }
1038        let doc = json!([{
1039            "apiVersion": "v1",
1040            "kind": "Test",
1041            "nested": inner
1042        }]);
1043        // Should not panic; errors or success both acceptable
1044        let _ = validate(&doc, Some(&store));
1045    }
1046
1047    // --- Fallback ---
1048
1049    #[test]
1050    fn fallback_no_schema_store() {
1051        let doc = json!([{
1052            "apiVersion": "apps/v1",
1053            "kind": "Deployment",
1054            "spec": {
1055                "template": {
1056                    "spec": {
1057                        "containers": [{
1058                            "resources": {
1059                                "requests": {"cpu": "2gb"}
1060                            }
1061                        }]
1062                    }
1063                }
1064            }
1065        }]);
1066        let errs = validate(&doc, None).unwrap_err();
1067        assert_eq!(errs.len(), 1);
1068        assert!(matches!(
1069            &errs[0].kind,
1070            ValidationErrorKind::InvalidQuantity { .. }
1071        ));
1072    }
1073
1074    #[test]
1075    fn fallback_unknown_gvk() {
1076        let store = simple_store();
1077        let doc = json!([{
1078            "apiVersion": "unknown/v1",
1079            "kind": "Custom",
1080            "spec": {
1081                "resources": {
1082                    "requests": {"cpu": "2gb"}
1083                }
1084            }
1085        }]);
1086        let errs = validate(&doc, Some(&store)).unwrap_err();
1087        assert_eq!(errs.len(), 1);
1088        assert!(matches!(
1089            &errs[0].kind,
1090            ValidationErrorKind::InvalidQuantity { .. }
1091        ));
1092    }
1093
1094    // --- SchemaStore loading ---
1095
1096    #[test]
1097    fn schema_store_from_json_wrong_version() {
1098        let json = json!({"version": 1, "gvk_index": {}, "schemas": {}});
1099        assert!(SchemaStore::from_json(&json).is_none());
1100    }
1101
1102    #[test]
1103    fn schema_store_from_json_valid() {
1104        let json = json!({"version": 2, "gvk_index": {"v1:Ns": "some.Schema"}, "schemas": {"some.Schema": {"type": "object"}}});
1105        let store = SchemaStore::from_json(&json).unwrap();
1106        assert!(store.resolve_ref("some.Schema").is_some());
1107    }
1108
1109    // --- Display ---
1110
1111    #[test]
1112    fn error_display_format() {
1113        let err = ValidationError {
1114            doc_index: 0,
1115            path: "$.spec.replicas".to_string(),
1116            kind: ValidationErrorKind::TypeMismatch {
1117                expected: "integer",
1118                got: "string".to_string(),
1119            },
1120        };
1121        let s = err.to_string();
1122        assert_eq!(
1123            s,
1124            "doc[0] at $.spec.replicas: expected type integer, got string"
1125        );
1126    }
1127
1128    #[test]
1129    fn error_display_enum() {
1130        let err = ValidationError {
1131            doc_index: 0,
1132            path: "$.spec.strategy.type".to_string(),
1133            kind: ValidationErrorKind::InvalidEnum {
1134                value: "bluegreen".to_string(),
1135                allowed: vec!["Recreate".to_string(), "RollingUpdate".to_string()],
1136            },
1137        };
1138        let s = err.to_string();
1139        assert_eq!(
1140            s,
1141            "doc[0] at $.spec.strategy.type: invalid value \"bluegreen\", expected one of: Recreate, RollingUpdate"
1142        );
1143    }
1144}