Skip to main content

kube_cel/validation/
values.rs

1//! Conversion from `serde_json::Value` to `cel::Value`.
2//!
3//! This module provides [`json_to_cel`] which recursively converts a JSON value
4//! into the CEL value representation used by the `cel` crate. The converted
5//! values can then be bound as variables (e.g. `self`, `oldSelf`) in a CEL
6//! evaluation context.
7//!
8//! For schema-aware conversion that respects `format: "date-time"` and
9//! `format: "duration"`, use [`json_to_cel_with_schema`] or
10//! [`json_to_cel_with_compiled`].
11
12use std::{collections::HashMap, sync::Arc};
13
14use cel::{
15    Value,
16    objects::{Key, Map},
17};
18
19use crate::validation::{compilation::CompiledSchema, escaping::escape_field_name};
20
21/// The `format` hint from an OpenAPI schema property.
22#[derive(Clone, Debug, Default, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum SchemaFormat {
25    /// `format: "date-time"` — strings should be parsed as CEL `Timestamp`.
26    DateTime,
27    /// `format: "duration"` — strings should be parsed as CEL `Duration`.
28    Duration,
29    /// `x-kubernetes-int-or-string: true` — field can be int or string.
30    /// Primarily a marker to prevent format: "date-time" etc from being interpreted.
31    IntOrString,
32    /// No recognized format or not a string type.
33    #[default]
34    None,
35}
36
37impl SchemaFormat {
38    /// Extract a `SchemaFormat` from a raw JSON schema node.
39    pub(crate) fn from_schema(schema: &serde_json::Value) -> Self {
40        if schema.get("x-kubernetes-int-or-string").and_then(|v| v.as_bool()) == Some(true) {
41            return SchemaFormat::IntOrString;
42        }
43        match schema.get("format").and_then(|f| f.as_str()) {
44            Some("date-time") => SchemaFormat::DateTime,
45            Some("duration") => SchemaFormat::Duration,
46            _ => SchemaFormat::None,
47        }
48    }
49}
50
51/// Convert a [`serde_json::Value`] into a [`cel::Value`].
52///
53/// Object keys are escaped via [`escape_field_name`]
54/// to handle CEL reserved words and special characters (`.`, `-`, `/`, `_`).
55///
56/// # Number conversion priority
57///
58/// JSON numbers are converted using the following priority:
59/// 1. `i64` — if the number fits in a signed 64-bit integer
60/// 2. `u64` — if the number fits in an unsigned 64-bit integer (but not `i64`)
61/// 3. `f64` — for all other numeric values (floating-point)
62#[must_use]
63pub(crate) fn json_to_cel(value: &serde_json::Value) -> Value {
64    match value {
65        serde_json::Value::Null => Value::Null,
66        serde_json::Value::Bool(b) => Value::Bool(*b),
67        serde_json::Value::Number(n) => convert_number(n),
68        serde_json::Value::String(s) => Value::String(Arc::new(s.clone())),
69        serde_json::Value::Array(arr) => {
70            let items: Vec<Value> = arr.iter().map(json_to_cel).collect();
71            Value::List(Arc::new(items))
72        }
73        serde_json::Value::Object(obj) => {
74            let mut map = HashMap::with_capacity(obj.len());
75            for (k, v) in obj {
76                map.insert(Key::String(Arc::new(escape_field_name(k))), json_to_cel(v));
77            }
78            Value::Map(Map { map: Arc::new(map) })
79        }
80    }
81}
82
83#[inline]
84fn convert_number(n: &serde_json::Number) -> Value {
85    if let Some(i) = n.as_i64() {
86        Value::Int(i)
87    } else if let Some(u) = n.as_u64() {
88        Value::UInt(u)
89    } else {
90        Value::Float(n.as_f64().unwrap_or(f64::NAN))
91    }
92}
93
94/// Convert a JSON value to a CEL value, using the raw JSON schema to recognize
95/// `format: "date-time"` and `format: "duration"` string fields.
96///
97/// This recursively walks both the value and the schema in parallel. For string
98/// values whose schema specifies a recognized format, the string is parsed into
99/// the corresponding CEL type (`Timestamp` or `Duration`). On parse failure,
100/// the value falls back to `Value::String`.
101#[must_use]
102pub(crate) fn json_to_cel_with_schema(value: &serde_json::Value, schema: &serde_json::Value) -> Value {
103    let format = SchemaFormat::from_schema(schema);
104    match value {
105        serde_json::Value::Null => Value::Null,
106        serde_json::Value::Bool(b) => Value::Bool(*b),
107        serde_json::Value::Number(n) => convert_number(n),
108        serde_json::Value::String(s) => convert_string_with_format(s, &format),
109        serde_json::Value::Array(arr) => {
110            let items_schema = schema.get("items");
111            let items: Vec<Value> = arr
112                .iter()
113                .map(|item| match items_schema {
114                    Some(s) => json_to_cel_with_schema(item, s),
115                    None => json_to_cel(item),
116                })
117                .collect();
118            Value::List(Arc::new(items))
119        }
120        serde_json::Value::Object(obj) => {
121            let props = schema.get("properties").and_then(|p| p.as_object());
122            let additional = schema.get("additionalProperties").filter(|a| a.is_object());
123
124            let mut map = HashMap::with_capacity(obj.len());
125            for (k, v) in obj {
126                let child_val = if let Some(prop_schema) = props.and_then(|p| p.get(k)) {
127                    json_to_cel_with_schema(v, prop_schema)
128                } else if let Some(additional_schema) = additional {
129                    json_to_cel_with_schema(v, additional_schema)
130                } else {
131                    json_to_cel(v)
132                };
133                map.insert(Key::String(Arc::new(escape_field_name(k))), child_val);
134            }
135
136            let is_embedded = schema
137                .get("x-kubernetes-embedded-resource")
138                .and_then(|v| v.as_bool())
139                == Some(true);
140            if is_embedded {
141                map.entry(Key::String(Arc::new("apiVersion".into())))
142                    .or_insert_with(|| Value::String(Arc::new(String::new())));
143                map.entry(Key::String(Arc::new("kind".into())))
144                    .or_insert_with(|| Value::String(Arc::new(String::new())));
145                map.entry(Key::String(Arc::new("metadata".into())))
146                    .or_insert_with(|| {
147                        Value::Map(Map {
148                            map: Arc::new(HashMap::new()),
149                        })
150                    });
151            }
152
153            Value::Map(Map { map: Arc::new(map) })
154        }
155    }
156}
157
158/// Convert a JSON value to a CEL value using a pre-compiled [`CompiledSchema`].
159///
160/// Behaves like [`json_to_cel_with_schema`] but uses the format metadata stored
161/// in the compiled schema tree instead of parsing the raw JSON schema.
162#[must_use]
163pub(crate) fn json_to_cel_with_compiled(value: &serde_json::Value, compiled: &CompiledSchema) -> Value {
164    match value {
165        serde_json::Value::Null => Value::Null,
166        serde_json::Value::Bool(b) => Value::Bool(*b),
167        serde_json::Value::Number(n) => convert_number(n),
168        serde_json::Value::String(s) => convert_string_with_format(s, &compiled.format),
169        serde_json::Value::Array(arr) => {
170            let items: Vec<Value> = arr
171                .iter()
172                .map(|item| match &compiled.items {
173                    Some(items_compiled) => json_to_cel_with_compiled(item, items_compiled),
174                    None => json_to_cel(item),
175                })
176                .collect();
177            Value::List(Arc::new(items))
178        }
179        serde_json::Value::Object(obj) => {
180            let mut map = HashMap::with_capacity(obj.len());
181            for (k, v) in obj {
182                let child_val = if let Some(prop_compiled) = compiled.properties.get(k) {
183                    json_to_cel_with_compiled(v, prop_compiled)
184                } else if let Some(ref additional) = compiled.additional_properties {
185                    json_to_cel_with_compiled(v, additional)
186                } else {
187                    json_to_cel(v)
188                };
189                map.insert(Key::String(Arc::new(escape_field_name(k))), child_val);
190            }
191
192            if compiled.embedded_resource {
193                map.entry(Key::String(Arc::new("apiVersion".into())))
194                    .or_insert_with(|| Value::String(Arc::new(String::new())));
195                map.entry(Key::String(Arc::new("kind".into())))
196                    .or_insert_with(|| Value::String(Arc::new(String::new())));
197                map.entry(Key::String(Arc::new("metadata".into())))
198                    .or_insert_with(|| {
199                        Value::Map(Map {
200                            map: Arc::new(HashMap::new()),
201                        })
202                    });
203            }
204
205            Value::Map(Map { map: Arc::new(map) })
206        }
207    }
208}
209
210/// Convert a string using the schema format hint.
211#[inline]
212fn convert_string_with_format(s: &str, format: &SchemaFormat) -> Value {
213    match format {
214        SchemaFormat::DateTime => {
215            if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
216                return Value::Timestamp(dt);
217            }
218            Value::String(Arc::new(s.to_string()))
219        }
220        SchemaFormat::Duration => {
221            if let Some(d) = parse_go_duration(s) {
222                return Value::Duration(d);
223            }
224            Value::String(Arc::new(s.to_string()))
225        }
226        SchemaFormat::IntOrString => Value::String(Arc::new(s.to_string())),
227        SchemaFormat::None => Value::String(Arc::new(s.to_string())),
228    }
229}
230
231/// Parse a Go-style duration string into a [`chrono::Duration`].
232///
233/// Supported units: `h` (hours), `m` (minutes), `s` (seconds), `ms` (milliseconds),
234/// `us` (microseconds), `ns` (nanoseconds). Multiple units can be combined
235/// (e.g., `"1h30m"`). An optional leading `-` makes the duration negative.
236/// The bare string `"0"` is treated as zero duration.
237///
238/// Returns `None` if the string cannot be parsed.
239pub(crate) fn parse_go_duration(input: &str) -> Option<chrono::Duration> {
240    let (input, negative) = if let Some(rest) = input.strip_prefix('-') {
241        (rest, true)
242    } else {
243        (input, false)
244    };
245
246    if input == "0" {
247        return Some(chrono::Duration::zero());
248    }
249
250    let mut remaining = input;
251    let mut total_nanos: i64 = 0;
252    let mut parsed_any = false;
253
254    while !remaining.is_empty() {
255        // Parse the numeric part (integer or float)
256        let num_end = remaining
257            .find(|c: char| !c.is_ascii_digit() && c != '.')
258            .unwrap_or(0);
259        if num_end == 0 {
260            return None; // no digits found
261        }
262        let num_str = &remaining[..num_end];
263        let num: f64 = num_str.parse().ok()?;
264        remaining = &remaining[num_end..];
265
266        // Parse the unit suffix
267        let (unit_nanos, unit_len) = if remaining.starts_with("ms") {
268            (1_000_000i64, 2)
269        } else if remaining.starts_with("us") {
270            (1_000i64, 2)
271        } else if remaining.starts_with("ns") {
272            (1i64, 2)
273        } else if remaining.starts_with('h') {
274            (3_600_000_000_000i64, 1)
275        } else if remaining.starts_with('m') {
276            (60_000_000_000i64, 1)
277        } else if remaining.starts_with('s') {
278            (1_000_000_000i64, 1)
279        } else {
280            return None; // unknown unit
281        };
282
283        remaining = &remaining[unit_len..];
284        total_nanos += (num * unit_nanos as f64).trunc() as i64;
285        parsed_any = true;
286    }
287
288    if !parsed_any {
289        return None;
290    }
291
292    if negative {
293        total_nanos = -total_nanos;
294    }
295    Some(chrono::Duration::nanoseconds(total_nanos))
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use serde_json::json;
302
303    #[test]
304    fn test_null() {
305        assert_eq!(json_to_cel(&json!(null)), Value::Null);
306    }
307
308    #[test]
309    fn test_bool() {
310        assert_eq!(json_to_cel(&json!(true)), Value::Bool(true));
311        assert_eq!(json_to_cel(&json!(false)), Value::Bool(false));
312    }
313
314    #[test]
315    fn test_i64() {
316        assert_eq!(json_to_cel(&json!(42)), Value::Int(42));
317        assert_eq!(json_to_cel(&json!(-1)), Value::Int(-1));
318        assert_eq!(json_to_cel(&json!(0)), Value::Int(0));
319    }
320
321    #[test]
322    fn test_u64_beyond_i64() {
323        let big: u64 = (i64::MAX as u64) + 1;
324        let v = json_to_cel(&serde_json::Value::Number(serde_json::Number::from(big)));
325        assert_eq!(v, Value::UInt(big));
326    }
327
328    #[test]
329    fn test_float() {
330        assert_eq!(json_to_cel(&json!(2.5)), Value::Float(2.5));
331        assert_eq!(json_to_cel(&json!(0.0)), Value::Float(0.0));
332    }
333
334    #[test]
335    fn test_string() {
336        assert_eq!(
337            json_to_cel(&json!("hello")),
338            Value::String(Arc::new("hello".into()))
339        );
340    }
341
342    #[test]
343    fn test_empty_string() {
344        assert_eq!(json_to_cel(&json!("")), Value::String(Arc::new(String::new())));
345    }
346
347    #[test]
348    fn test_array_mixed() {
349        let v = json_to_cel(&json!([1, "two", true, null]));
350        let expected = Value::List(Arc::new(vec![
351            Value::Int(1),
352            Value::String(Arc::new("two".into())),
353            Value::Bool(true),
354            Value::Null,
355        ]));
356        assert_eq!(v, expected);
357    }
358
359    #[test]
360    fn test_empty_array() {
361        assert_eq!(json_to_cel(&json!([])), Value::List(Arc::new(vec![])));
362    }
363
364    #[test]
365    fn test_object() {
366        let v = json_to_cel(&json!({"name": "test", "count": 5}));
367        if let Value::Map(map) = v {
368            assert_eq!(
369                map.map.get(&Key::String(Arc::new("name".into()))),
370                Some(&Value::String(Arc::new("test".into())))
371            );
372            assert_eq!(
373                map.map.get(&Key::String(Arc::new("count".into()))),
374                Some(&Value::Int(5))
375            );
376        } else {
377            panic!("expected Map");
378        }
379    }
380
381    #[test]
382    fn test_empty_object() {
383        let v = json_to_cel(&json!({}));
384        if let Value::Map(map) = v {
385            assert!(map.map.is_empty());
386        } else {
387            panic!("expected Map");
388        }
389    }
390
391    #[test]
392    fn test_nested_structure() {
393        let v = json_to_cel(&json!({
394            "spec": {
395                "replicas": 3,
396                "items": [1, 2, 3]
397            }
398        }));
399        if let Value::Map(outer) = v {
400            let spec = outer.map.get(&Key::String(Arc::new("spec".into()))).unwrap();
401            if let Value::Map(inner) = spec {
402                assert_eq!(
403                    inner.map.get(&Key::String(Arc::new("replicas".into()))),
404                    Some(&Value::Int(3))
405                );
406                assert_eq!(
407                    inner.map.get(&Key::String(Arc::new("items".into()))),
408                    Some(&Value::List(Arc::new(vec![
409                        Value::Int(1),
410                        Value::Int(2),
411                        Value::Int(3),
412                    ])))
413                );
414            } else {
415                panic!("expected inner Map");
416            }
417        } else {
418            panic!("expected outer Map");
419        }
420    }
421
422    #[test]
423    fn test_number_priority() {
424        // i64 range → Int
425        assert_eq!(json_to_cel(&json!(42)), Value::Int(42));
426        // u64 beyond i64 → UInt
427        let big: u64 = (i64::MAX as u64) + 1;
428        assert_eq!(
429            json_to_cel(&serde_json::Value::Number(serde_json::Number::from(big))),
430            Value::UInt(big)
431        );
432        // float → Float
433        assert_eq!(json_to_cel(&json!(1.5)), Value::Float(1.5));
434    }
435
436    // ── parse_go_duration tests ─────────────────────────────────────
437
438    #[test]
439    fn parse_duration_hours() {
440        assert_eq!(parse_go_duration("1h"), Some(chrono::Duration::hours(1)));
441    }
442
443    #[test]
444    fn parse_duration_minutes() {
445        assert_eq!(parse_go_duration("30m"), Some(chrono::Duration::minutes(30)));
446    }
447
448    #[test]
449    fn parse_duration_seconds() {
450        assert_eq!(parse_go_duration("45s"), Some(chrono::Duration::seconds(45)));
451    }
452
453    #[test]
454    fn parse_duration_milliseconds() {
455        assert_eq!(
456            parse_go_duration("500ms"),
457            Some(chrono::Duration::milliseconds(500))
458        );
459    }
460
461    #[test]
462    fn parse_duration_microseconds() {
463        assert_eq!(
464            parse_go_duration("100us"),
465            Some(chrono::Duration::microseconds(100))
466        );
467    }
468
469    #[test]
470    fn parse_duration_nanoseconds() {
471        assert_eq!(
472            parse_go_duration("999ns"),
473            Some(chrono::Duration::nanoseconds(999))
474        );
475    }
476
477    #[test]
478    fn parse_duration_compound() {
479        assert_eq!(
480            parse_go_duration("1h30m"),
481            Some(chrono::Duration::hours(1) + chrono::Duration::minutes(30))
482        );
483        assert_eq!(
484            parse_go_duration("1h30m10s"),
485            Some(chrono::Duration::hours(1) + chrono::Duration::minutes(30) + chrono::Duration::seconds(10))
486        );
487    }
488
489    #[test]
490    fn parse_duration_negative() {
491        assert_eq!(parse_go_duration("-1h"), Some(chrono::Duration::hours(-1)));
492        assert_eq!(parse_go_duration("-30s"), Some(chrono::Duration::seconds(-30)));
493    }
494
495    #[test]
496    fn parse_duration_zero() {
497        assert_eq!(parse_go_duration("0"), Some(chrono::Duration::zero()));
498    }
499
500    #[test]
501    fn parse_duration_invalid() {
502        assert_eq!(parse_go_duration(""), None);
503        assert_eq!(parse_go_duration("abc"), None);
504        assert_eq!(parse_go_duration("1x"), None);
505        assert_eq!(parse_go_duration("h"), None);
506    }
507
508    // ── Schema-aware conversion tests ───────────────────────────────
509
510    #[test]
511    fn timestamp_parsed_from_schema() {
512        let schema = json!({
513            "type": "string",
514            "format": "date-time"
515        });
516        let value = json!("2024-01-01T00:00:00Z");
517        let result = json_to_cel_with_schema(&value, &schema);
518        assert!(matches!(result, Value::Timestamp(_)));
519    }
520
521    #[test]
522    fn timestamp_parse_failure_falls_back_to_string() {
523        let schema = json!({
524            "type": "string",
525            "format": "date-time"
526        });
527        let value = json!("not-a-date");
528        let result = json_to_cel_with_schema(&value, &schema);
529        assert_eq!(result, Value::String(Arc::new("not-a-date".into())));
530    }
531
532    #[test]
533    fn duration_parsed_from_schema() {
534        let schema = json!({
535            "type": "string",
536            "format": "duration"
537        });
538        let value = json!("1h30m");
539        let result = json_to_cel_with_schema(&value, &schema);
540        assert!(matches!(result, Value::Duration(_)));
541    }
542
543    #[test]
544    fn duration_parse_failure_falls_back_to_string() {
545        let schema = json!({
546            "type": "string",
547            "format": "duration"
548        });
549        let value = json!("not-a-duration");
550        let result = json_to_cel_with_schema(&value, &schema);
551        assert_eq!(result, Value::String(Arc::new("not-a-duration".into())));
552    }
553
554    #[test]
555    fn nested_object_properties_format() {
556        let schema = json!({
557            "type": "object",
558            "properties": {
559                "createdAt": {
560                    "type": "string",
561                    "format": "date-time"
562                },
563                "name": {
564                    "type": "string"
565                },
566                "timeout": {
567                    "type": "string",
568                    "format": "duration"
569                }
570            }
571        });
572        let value = json!({
573            "createdAt": "2024-06-15T10:30:00Z",
574            "name": "test",
575            "timeout": "30s"
576        });
577        let result = json_to_cel_with_schema(&value, &schema);
578        if let Value::Map(map) = result {
579            assert!(matches!(
580                map.map.get(&Key::String(Arc::new("createdAt".into()))),
581                Some(Value::Timestamp(_))
582            ));
583            assert!(matches!(
584                map.map.get(&Key::String(Arc::new("name".into()))),
585                Some(Value::String(_))
586            ));
587            assert!(matches!(
588                map.map.get(&Key::String(Arc::new("timeout".into()))),
589                Some(Value::Duration(_))
590            ));
591        } else {
592            panic!("expected Map");
593        }
594    }
595
596    #[test]
597    fn array_items_format() {
598        let schema = json!({
599            "type": "array",
600            "items": {
601                "type": "string",
602                "format": "date-time"
603            }
604        });
605        let value = json!(["2024-01-01T00:00:00Z", "2024-06-15T12:00:00+09:00"]);
606        let result = json_to_cel_with_schema(&value, &schema);
607        if let Value::List(items) = result {
608            assert!(matches!(items[0], Value::Timestamp(_)));
609            assert!(matches!(items[1], Value::Timestamp(_)));
610        } else {
611            panic!("expected List");
612        }
613    }
614
615    #[test]
616    fn no_format_leaves_string_unchanged() {
617        let schema = json!({
618            "type": "string"
619        });
620        let value = json!("2024-01-01T00:00:00Z");
621        let result = json_to_cel_with_schema(&value, &schema);
622        assert_eq!(result, Value::String(Arc::new("2024-01-01T00:00:00Z".into())));
623    }
624
625    #[test]
626    fn json_to_cel_unchanged_with_no_schema() {
627        // Original json_to_cel should still work as before
628        let value = json!("2024-01-01T00:00:00Z");
629        let result = json_to_cel(&value);
630        assert_eq!(result, Value::String(Arc::new("2024-01-01T00:00:00Z".into())));
631    }
632
633    #[test]
634    fn int_or_string_schema_format_detected() {
635        let schema = json!({"x-kubernetes-int-or-string": true});
636        assert_eq!(SchemaFormat::from_schema(&schema), SchemaFormat::IntOrString);
637    }
638
639    #[test]
640    fn int_or_string_int_value_preserved() {
641        let schema = json!({"x-kubernetes-int-or-string": true});
642        let result = json_to_cel_with_schema(&json!(8080), &schema);
643        assert_eq!(result, Value::Int(8080));
644    }
645
646    #[test]
647    fn int_or_string_string_value_preserved() {
648        let schema = json!({"x-kubernetes-int-or-string": true});
649        let result = json_to_cel_with_schema(&json!("http"), &schema);
650        assert_eq!(result, Value::String(Arc::new("http".into())));
651    }
652
653    #[test]
654    fn int_or_string_overrides_format() {
655        // Even with format: date-time, int-or-string takes precedence
656        let schema = json!({"x-kubernetes-int-or-string": true, "format": "date-time"});
657        assert_eq!(SchemaFormat::from_schema(&schema), SchemaFormat::IntOrString);
658    }
659}
660
661/// End-to-end tests of the internal `json_to_cel` conversion through real CEL
662/// compilation + evaluation. Relocated from `tests/cel_evaluation.rs` when
663/// `json_to_cel` became `pub(crate)` — an integration test can no longer reach
664/// it, so its coverage lives here in-crate.
665#[cfg(test)]
666mod cel_evaluation_tests {
667    use std::sync::Arc;
668
669    use cel::{Context, Program, Value};
670    use serde_json::json;
671
672    use super::json_to_cel;
673    use crate::register_all;
674
675    /// Build a context with kube-cel functions, bind `self` from JSON, compile,
676    /// and return the evaluation result.
677    fn eval_self(json_val: serde_json::Value, expr: &str) -> Value {
678        let mut ctx = Context::default();
679        register_all(&mut ctx);
680        ctx.add_variable_from_value("self", json_to_cel(&json_val));
681        Program::compile(expr).unwrap().execute(&ctx).unwrap()
682    }
683
684    /// Same as `eval_self` but also binds `oldSelf`.
685    fn eval_transition(json_self: serde_json::Value, json_old: serde_json::Value, expr: &str) -> Value {
686        let mut ctx = Context::default();
687        register_all(&mut ctx);
688        ctx.add_variable_from_value("self", json_to_cel(&json_self));
689        ctx.add_variable_from_value("oldSelf", json_to_cel(&json_old));
690        Program::compile(expr).unwrap().execute(&ctx).unwrap()
691    }
692
693    #[test]
694    fn scalar_comparison() {
695        assert_eq!(eval_self(json!(10), "self >= 0"), Value::Bool(true));
696        assert_eq!(eval_self(json!(-1), "self >= 0"), Value::Bool(false));
697    }
698
699    #[test]
700    fn field_access_int() {
701        let obj = json!({"replicas": 3});
702        assert_eq!(eval_self(obj, "self.replicas"), Value::Int(3));
703    }
704
705    #[test]
706    fn field_access_string() {
707        let obj = json!({"name": "my-app"});
708        assert_eq!(
709            eval_self(obj, "self.name"),
710            Value::String(Arc::new("my-app".into()))
711        );
712    }
713
714    #[test]
715    fn nested_field_comparison() {
716        let obj = json!({"spec": {"replicas": 5, "minReplicas": 2}});
717        assert_eq!(
718            eval_self(obj, "self.spec.replicas >= self.spec.minReplicas"),
719            Value::Bool(true)
720        );
721    }
722
723    #[test]
724    fn transition_rule_oldself() {
725        let new = json!({"replicas": 5});
726        let old = json!({"replicas": 3});
727        assert_eq!(
728            eval_transition(new, old, "self.replicas >= oldSelf.replicas"),
729            Value::Bool(true)
730        );
731    }
732
733    #[test]
734    fn transition_rule_downscale_rejected() {
735        let new = json!({"replicas": 1});
736        let old = json!({"replicas": 3});
737        assert_eq!(
738            eval_transition(new, old, "self.replicas >= oldSelf.replicas"),
739            Value::Bool(false)
740        );
741    }
742
743    #[test]
744    fn detect_oldself_reference() {
745        let prog1 = Program::compile("self.replicas >= oldSelf.replicas").unwrap();
746        assert!(prog1.references().has_variable("oldSelf"));
747        assert!(prog1.references().has_variable("self"));
748
749        let prog2 = Program::compile("self.replicas >= 0").unwrap();
750        assert!(!prog2.references().has_variable("oldSelf"));
751        assert!(prog2.references().has_variable("self"));
752    }
753
754    #[test]
755    #[cfg(feature = "strings")]
756    fn extension_trim_lower_ascii() {
757        let obj = json!({"name": "  Hello World  "});
758        assert_eq!(
759            eval_self(obj, "self.name.trim().lowerAscii()"),
760            Value::String(Arc::new("hello world".into()))
761        );
762    }
763
764    #[test]
765    #[cfg(feature = "lists")]
766    fn extension_is_sorted() {
767        let obj = json!({"items": [1, 2, 3, 4]});
768        assert_eq!(eval_self(obj, "self.items.isSorted()"), Value::Bool(true));
769
770        let obj2 = json!({"items": [3, 1, 2]});
771        assert_eq!(eval_self(obj2, "self.items.isSorted()"), Value::Bool(false));
772    }
773
774    #[test]
775    fn array_indexing() {
776        let obj = json!({"containers": [{"name": "nginx"}, {"name": "sidecar"}]});
777        assert_eq!(
778            eval_self(obj, "self.containers[0].name"),
779            Value::String(Arc::new("nginx".into()))
780        );
781    }
782
783    #[test]
784    fn null_comparison() {
785        let obj = json!({"extra": null});
786        assert_eq!(eval_self(obj, "self.extra == null"), Value::Bool(true));
787    }
788
789    #[test]
790    fn non_null_comparison() {
791        let obj = json!({"extra": "present"});
792        assert_eq!(eval_self(obj, "self.extra == null"), Value::Bool(false));
793    }
794
795    #[test]
796    fn has_macro_present() {
797        let obj = json!({"name": "test"});
798        assert_eq!(eval_self(obj, "has(self.name)"), Value::Bool(true));
799    }
800
801    #[test]
802    fn has_macro_missing() {
803        let obj = json!({"name": "test"});
804        assert_eq!(eval_self(obj, "has(self.missing)"), Value::Bool(false));
805    }
806
807    #[test]
808    fn size_of_list() {
809        let obj = json!({"items": [1, 2, 3]});
810        assert_eq!(eval_self(obj, "size(self.items)"), Value::Int(3));
811    }
812
813    #[test]
814    fn size_of_string() {
815        let obj = json!({"name": "hello"});
816        assert_eq!(eval_self(obj, "size(self.name)"), Value::Int(5));
817    }
818}