Skip to main content

helm_schema_core/
guard.rs

1use std::fmt;
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use serde_json::Number;
5
6use crate::ValuesPath;
7
8/// Scalar literal used by values-decidable guard comparisons.
9///
10/// Helm `eq` / `ne` conditions can compare against strings, booleans, numbers,
11/// and nil. Keeping the literal typed prevents static analysis from degrading
12/// `eq .Values.enabled false` into a misleading truthiness guard.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub enum GuardValue {
15    /// UTF-8 string literal.
16    String(String),
17    /// Boolean literal.
18    Bool(bool),
19    /// Signed integer literal.
20    Int(i64),
21    /// Finite floating-point literal stored in canonical textual form.
22    Float(String),
23    /// Explicit null literal.
24    Null,
25}
26
27impl GuardValue {
28    /// Creates a string guard literal.
29    #[must_use]
30    pub fn string(value: impl Into<String>) -> Self {
31        Self::String(value.into())
32    }
33
34    /// Creates a finite floating-point guard literal, rejecting NaN and infinity.
35    #[must_use]
36    pub fn float(value: f64) -> Option<Self> {
37        value.is_finite().then(|| Self::Float(value.to_string()))
38    }
39}
40
41impl Serialize for GuardValue {
42    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
43    where
44        S: Serializer,
45    {
46        match self {
47            Self::String(value) => serializer.serialize_str(value),
48            Self::Bool(value) => serializer.serialize_bool(*value),
49            Self::Int(value) => serializer.serialize_i64(*value),
50            Self::Float(value) => {
51                let number = value
52                    .parse::<f64>()
53                    .ok()
54                    .and_then(Number::from_f64)
55                    .ok_or_else(|| serde::ser::Error::custom("invalid float guard value"))?;
56                number.serialize(serializer)
57            }
58            Self::Null => serializer.serialize_none(),
59        }
60    }
61}
62
63impl<'de> Deserialize<'de> for GuardValue {
64    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
65    where
66        D: Deserializer<'de>,
67    {
68        let value = serde_json::Value::deserialize(deserializer)?;
69        match value {
70            serde_json::Value::String(value) => Ok(Self::String(value)),
71            serde_json::Value::Bool(value) => Ok(Self::Bool(value)),
72            serde_json::Value::Number(value) => {
73                if let Some(value) = value.as_i64() {
74                    Ok(Self::Int(value))
75                } else {
76                    Ok(Self::Float(value.to_string()))
77                }
78            }
79            serde_json::Value::Null => Ok(Self::Null),
80            _ => Err(serde::de::Error::custom(
81                "guard comparison value must be a scalar literal",
82            )),
83        }
84    }
85}
86
87impl fmt::Display for GuardValue {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            Self::String(value) => value.fmt(f),
91            Self::Bool(value) => value.fmt(f),
92            Self::Int(value) => value.fmt(f),
93            #[expect(
94                clippy::match_same_arms,
95                reason = "string and float variants require distinct bindings despite identical formatting"
96            )]
97            Self::Float(value) => value.fmt(f),
98            Self::Null => f.write_str("null"),
99        }
100    }
101}
102
103/// A guard condition from an `if`, `with`, or `range` block.
104#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
105#[serde(tag = "type", rename_all = "snake_case")]
106pub enum Guard {
107    /// Simple truthy check: `if .Values.X`
108    Truthy {
109        /// Values path tested for truthiness.
110        path: ValuesPath,
111    },
112    /// Negated truthy check: `if not .Values.X`
113    Not {
114        /// Values path tested for falsiness.
115        path: ValuesPath,
116    },
117    /// Equality check: `if eq .Values.X "value"` / `if eq .Values.X false`.
118    Eq {
119        /// Values path compared with the literal.
120        path: ValuesPath,
121        /// Literal required at the path.
122        value: GuardValue,
123    },
124    /// Inequality check: `if ne .Values.X "value"` / `if ne .Values.X false`.
125    NotEq {
126        /// Values path compared with the literal.
127        path: ValuesPath,
128        /// Literal excluded at the path.
129        value: GuardValue,
130    },
131    /// Path absence check, used for structural rules where missing values are
132    /// semantically distinct from false values.
133    Absent {
134        /// Values path whose absence selects the branch.
135        path: ValuesPath,
136    },
137    /// The path's string value matches a literal regular expression:
138    /// `if regexMatch "…" .Values.X`. `regexMatch` type-asserts a string
139    /// subject, so the guard holding implies string-ness as well. When
140    /// `templated` is set the subject reached the match through `tpl`, so
141    /// the pattern constrains the rendered OUTPUT: a raw value carrying a
142    /// template action is admitted regardless (its render may match).
143    MatchesPattern {
144        /// Values path subjected to the pattern test.
145        path: ValuesPath,
146        /// Literal regular expression required by the branch.
147        pattern: String,
148        /// Whether matching occurs after rendering the value through `tpl`.
149        templated: bool,
150    },
151    /// The path is a string that does not match a literal regular expression.
152    ///
153    /// This is narrower than the logical complement of
154    /// [`Guard::MatchesPattern`], which also includes every non-string.
155    /// Stringified predicate results use this guard for their sound
156    /// raw-string mismatch subset.
157    NotMatchesPattern {
158        /// Values path subjected to the pattern test.
159        path: ValuesPath,
160        /// Literal regular expression excluded by the branch.
161        pattern: String,
162    },
163    /// A destructured range key starts with a literal prefix. The path names
164    /// the ranged collection; the predicate applies to its matching entries,
165    /// not to the collection value itself.
166    RangeKeyPrefix {
167        /// Values path of the ranged collection.
168        path: ValuesPath,
169        /// Literal prefix required of the current key.
170        prefix: String,
171    },
172    /// A destructured range key equals a literal (`if eq $key "name"`). The
173    /// path names the ranged collection; the predicate selects exactly the
174    /// entry with that key. Document-level lowering may only use the
175    /// POSITIVE form (the key exists in the collection); the negation runs
176    /// for every OTHER member and has no key-presence encoding.
177    RangeKeyEquals {
178        /// Values path of the ranged collection.
179        path: ValuesPath,
180        /// Literal key selected by the branch.
181        key: String,
182    },
183    /// A destructured range key matches a literal regular expression
184    /// (`if regexMatch "[A-Z]" $name`). The path names the ranged
185    /// collection; the predicate applies per key, so lowering targets the
186    /// collection's key domain (traefik's uppercase `ingressRoute` gate).
187    RangeKeyMatches {
188        /// Values path of the ranged collection.
189        path: ValuesPath,
190        /// Regular expression required of the current key.
191        pattern: String,
192    },
193    /// Disjunction: `if or .Values.A .Values.B`
194    Or {
195        /// Values paths whose truthiness forms the disjunction.
196        paths: Vec<ValuesPath>,
197    },
198    /// Disjunction whose arms may each contain a conjunction of typed guards.
199    ///
200    /// This preserves structural forms such as
201    /// `or (and .Values.A .Values.B) (eq .Values.mode "prod")` without
202    /// degrading them into truthiness checks for every mentioned path.
203    AnyOf {
204        /// Guard conjunctions that form the disjunction's alternatives.
205        alternatives: Vec<Vec<Guard>>,
206    },
207    /// Body of `range .Values.X` / `range .foo` block. The referenced path is
208    /// being iterated as a collection, not interpreted as a boolean-valued
209    /// scalar. This should not contribute a boolean type hint downstream.
210    Range {
211        /// Values path used as the range source.
212        path: ValuesPath,
213    },
214    /// Body of `with .Values.X` block. This distinguishes header binding from
215    /// `if`-style truthy checks. The bound path is null-tolerant by
216    /// construction because `with nil` skips the body.
217    With {
218        /// Values path selected as the branch context.
219        path: ValuesPath,
220    },
221    /// Rendered via a `default ... <path>` fallback, either in prefix form
222    /// (`default "x" .Values.X`) or pipeline form (`.Values.X | default "x"`).
223    ///
224    /// This is stronger than a plain truthy guard: the template explicitly
225    /// substitutes a fallback when the path is empty/nil, so `null` is an
226    /// accepted chart input for that render site even when `values.yaml` ships
227    /// a non-null default.
228    Default {
229        /// Values path protected by a fallback.
230        path: ValuesPath,
231    },
232    /// A `typeIs "<json type>" <path>` check in template logic.
233    ///
234    /// This is not a truthiness guard. It is a structural type declaration:
235    /// helpers such as Bitnami's `common.tplvalues.render` explicitly branch on
236    /// `typeIs "string" .value`, so callers may supply that values path as a
237    /// string even when another branch renders it as a YAML object fragment.
238    TypeIs {
239        /// Values path subjected to the type test.
240        path: ValuesPath,
241        /// JSON Schema type name selected by the branch.
242        schema_type: String,
243    },
244    /// The complement of [`Guard::TypeIs`]: the `else` arm of a type
245    /// dispatch (`if typeIs "string" x … else …`).
246    ///
247    /// Rows need this as a first-class variant because dropping the
248    /// complement collapses a type-switch partition: member reads and
249    /// structural placements under the `else` would otherwise apply to
250    /// EVERY type of the dispatched path.
251    NotTypeIs {
252        /// Values path subjected to the type test.
253        path: ValuesPath,
254        /// JSON Schema type name excluded by the branch.
255        schema_type: String,
256    },
257    /// The path's RAW value is a JSON integer strictly greater than `bound`.
258    ///
259    /// This deliberately claims less than the Sprig coercion it stands in
260    /// for: `gt (int64 .Values.x) N` also holds for numeric strings and
261    /// `true`, so this guard is a SOUND SUBSET usable only where firing
262    /// less often is safe (a fail-arm condition), never as an exact branch
263    /// condition whose negation must also hold.
264    IntGt {
265        /// Values path subjected to the integer comparison.
266        path: ValuesPath,
267        /// Exclusive lower bound.
268        bound: i64,
269    },
270    /// The path's RAW value is a JSON integer strictly less than `bound`.
271    ///
272    /// The mirror of [`Guard::IntGt`], with the same sound-subset contract:
273    /// `lt (int .Values.x) N` also holds for coercible non-integers, so
274    /// this guard may only strengthen positive-polarity consumers.
275    IntLt {
276        /// Values path subjected to the integer comparison.
277        path: ValuesPath,
278        /// Exclusive upper bound.
279        bound: i64,
280    },
281    /// The collection at `path` has at most one entry.
282    ///
283    /// A sound SUBSET stand-in for loop-carried conditions that provably
284    /// hold on a range's FIRST iteration (an empty-initialized dedup
285    /// accumulator cannot shadow anything yet): with at most one member,
286    /// every iteration is the first. Like [`Guard::IntGt`], it may only
287    /// strengthen positive-polarity consumers.
288    AtMostOneMember {
289        /// Values path expected to hold the bounded collection.
290        path: ValuesPath,
291    },
292    /// The value at `path` is a mapping with at least `bound` members —
293    /// the exact meaning of `gt (keys X | len) N` (`keys` aborts on
294    /// non-maps, so the render reaches the body only for maps).
295    MinMembers {
296        /// Values path expected to hold the mapping.
297        path: ValuesPath,
298        /// Inclusive minimum number of mapping members.
299        bound: i64,
300    },
301    /// The mapping at `path` contains `key` as a literal member — Sprig
302    /// `hasKey`/`dig` observability, where a present nil member IS present
303    /// (cilium's removed-option guards abort on the truthy `"<nil>"`
304    /// rendering of an explicit null). Contrast [`Guard::Absent`], which
305    /// counts explicit null as absent for the nil-safe selector lanes.
306    HasKey {
307        /// Values path expected to hold a mapping.
308        path: ValuesPath,
309        /// Literal mapping key whose presence selects the branch.
310        key: String,
311    },
312    /// The mapping at `path` does not contain `key`.
313    ///
314    /// This is the exact logical complement of [`Guard::HasKey`].
315    NotHasKey {
316        /// Values path expected to hold a mapping.
317        path: ValuesPath,
318        /// Literal mapping key whose absence selects the branch.
319        key: String,
320    },
321    /// SOME item of the list at `path` deep-equals the scalar literal —
322    /// Sprig `has LITERAL .Values.list`, the dual of the literal-list
323    /// membership (`has .Values.x (list …)`). `has` returns false on a
324    /// nil haystack and aborts rendering on non-lists, so the guard holds
325    /// exactly for arrays carrying the literal (oauth2-proxy gates its
326    /// secret keys on `has "cookie-secret" .Values.config.requiredSecretKeys`).
327    ContainsEquals {
328        /// Values path expected to hold a list.
329        path: ValuesPath,
330        /// Literal that at least one list item must equal.
331        value: GuardValue,
332    },
333    /// Some iterated item of the collection at `path` has `member` equal to
334    /// `value`. This is the quantified result of a monotone Boolean local
335    /// set inside a range under an equality test.
336    ContainsMemberEquals {
337        /// Values path expected to hold the iterated collection.
338        path: ValuesPath,
339        /// Member name compared within each collection item.
340        member: String,
341        /// Literal that at least one member must equal.
342        value: GuardValue,
343    },
344    /// Some iterated item of the collection at `path` has a Helm-truthy
345    /// `member`. This is the quantified result of a monotone Boolean local
346    /// set inside a range under a truthiness test.
347    ContainsTruthyMember {
348        /// Values path expected to hold the iterated collection.
349        path: ValuesPath,
350        /// Member whose truthiness selects the sentinel state.
351        member: String,
352    },
353}
354
355impl Guard {
356    pub(crate) fn canonicalize_all(guards: &mut Vec<Self>) {
357        for guard in guards.iter_mut() {
358            guard.canonicalize();
359        }
360        guards.sort();
361        guards.dedup();
362    }
363
364    fn canonicalize(&mut self) {
365        match self {
366            Self::Or { paths } => {
367                paths.sort();
368                paths.dedup();
369            }
370            Self::AnyOf { alternatives } => {
371                for guards in alternatives.iter_mut() {
372                    Self::canonicalize_all(guards);
373                }
374                alternatives.sort();
375                alternatives.dedup();
376            }
377            Self::Truthy { .. }
378            | Self::Not { .. }
379            | Self::Eq { .. }
380            | Self::NotEq { .. }
381            | Self::Absent { .. }
382            | Self::MatchesPattern { .. }
383            | Self::NotMatchesPattern { .. }
384            | Self::RangeKeyPrefix { .. }
385            | Self::RangeKeyEquals { .. }
386            | Self::RangeKeyMatches { .. }
387            | Self::Range { .. }
388            | Self::With { .. }
389            | Self::Default { .. }
390            | Self::TypeIs { .. }
391            | Self::NotTypeIs { .. }
392            | Self::IntGt { .. }
393            | Self::IntLt { .. }
394            | Self::AtMostOneMember { .. }
395            | Self::MinMembers { .. }
396            | Self::HasKey { .. }
397            | Self::NotHasKey { .. }
398            | Self::ContainsEquals { .. }
399            | Self::ContainsMemberEquals { .. }
400            | Self::ContainsTruthyMember { .. } => {}
401        }
402    }
403
404    /// Return all `.Values.*` paths referenced by this guard.
405    #[must_use]
406    pub fn value_paths(&self) -> Vec<ValuesPath> {
407        match self {
408            Guard::Truthy { path }
409            | Guard::Not { path }
410            | Guard::Eq { path, .. }
411            | Guard::NotEq { path, .. }
412            | Guard::Absent { path }
413            | Guard::MatchesPattern { path, .. }
414            | Guard::NotMatchesPattern { path, .. }
415            | Guard::RangeKeyPrefix { path, .. }
416            | Guard::RangeKeyEquals { path, .. }
417            | Guard::RangeKeyMatches { path, .. }
418            | Guard::Range { path }
419            | Guard::With { path }
420            | Guard::Default { path }
421            | Guard::TypeIs { path, .. }
422            | Guard::NotTypeIs { path, .. }
423            | Guard::IntGt { path, .. }
424            | Guard::IntLt { path, .. }
425            | Guard::AtMostOneMember { path }
426            | Guard::MinMembers { path, .. }
427            | Guard::HasKey { path, .. }
428            | Guard::NotHasKey { path, .. }
429            | Guard::ContainsEquals { path, .. }
430            | Guard::ContainsMemberEquals { path, .. }
431            | Guard::ContainsTruthyMember { path, .. } => {
432                vec![path.clone()]
433            }
434            Guard::Or { paths } => paths.clone(),
435            Guard::AnyOf { alternatives } => alternatives
436                .iter()
437                .flat_map(|alternative| alternative.iter().flat_map(Guard::value_paths))
438                .collect(),
439        }
440    }
441
442    /// Rewrite value paths carried by this guard.
443    #[must_use]
444    #[expect(
445        clippy::too_many_lines,
446        reason = "keeping the exhaustive path rewrite in one match makes variant coverage auditable"
447    )]
448    pub fn map_value_paths<F>(self, map: &mut F) -> Self
449    where
450        F: FnMut(ValuesPath) -> ValuesPath,
451    {
452        match self {
453            Guard::Truthy { path } => Guard::Truthy {
454                path: map_values_path(&path, map),
455            },
456            Guard::Not { path } => Guard::Not {
457                path: map_values_path(&path, map),
458            },
459            Guard::Eq { path, value } => Guard::Eq {
460                path: map_values_path(&path, map),
461                value,
462            },
463            Guard::NotEq { path, value } => Guard::NotEq {
464                path: map_values_path(&path, map),
465                value,
466            },
467            Guard::Absent { path } => Guard::Absent {
468                path: map_values_path(&path, map),
469            },
470            Guard::MatchesPattern {
471                path,
472                pattern,
473                templated,
474            } => Guard::MatchesPattern {
475                path: map_values_path(&path, map),
476                pattern,
477                templated,
478            },
479            Guard::NotMatchesPattern { path, pattern } => Guard::NotMatchesPattern {
480                path: map_values_path(&path, map),
481                pattern,
482            },
483            Guard::RangeKeyEquals { path, key } => Guard::RangeKeyEquals {
484                path: map_values_path(&path, map),
485                key,
486            },
487            Guard::RangeKeyPrefix { path, prefix } => Guard::RangeKeyPrefix {
488                path: map_values_path(&path, map),
489                prefix,
490            },
491            Guard::RangeKeyMatches { path, pattern } => Guard::RangeKeyMatches {
492                path: map_values_path(&path, map),
493                pattern,
494            },
495            Guard::Or { paths } => Guard::Or {
496                paths: paths
497                    .into_iter()
498                    .map(|path| map_values_path(&path, map))
499                    .collect(),
500            },
501            Guard::AnyOf { alternatives } => Guard::AnyOf {
502                alternatives: map_guard_alternatives(alternatives, map),
503            },
504            Guard::Range { path } => Guard::Range {
505                path: map_values_path(&path, map),
506            },
507            Guard::With { path } => Guard::With {
508                path: map_values_path(&path, map),
509            },
510            Guard::Default { path } => Guard::Default {
511                path: map_values_path(&path, map),
512            },
513            Guard::TypeIs { path, schema_type } => Guard::TypeIs {
514                path: map_values_path(&path, map),
515                schema_type,
516            },
517            Guard::NotTypeIs { path, schema_type } => Guard::NotTypeIs {
518                path: map_values_path(&path, map),
519                schema_type,
520            },
521            Guard::IntGt { path, bound } => Guard::IntGt {
522                path: map_values_path(&path, map),
523                bound,
524            },
525            Guard::IntLt { path, bound } => Guard::IntLt {
526                path: map_values_path(&path, map),
527                bound,
528            },
529            Guard::AtMostOneMember { path } => Guard::AtMostOneMember {
530                path: map_values_path(&path, map),
531            },
532            Guard::MinMembers { path, bound } => Guard::MinMembers {
533                path: map_values_path(&path, map),
534                bound,
535            },
536            Guard::HasKey { path, key } => Guard::HasKey {
537                path: map_values_path(&path, map),
538                key,
539            },
540            Guard::NotHasKey { path, key } => Guard::NotHasKey {
541                path: map_values_path(&path, map),
542                key,
543            },
544            Guard::ContainsEquals { path, value } => Guard::ContainsEquals {
545                path: map_values_path(&path, map),
546                value,
547            },
548            Guard::ContainsMemberEquals {
549                path,
550                member,
551                value,
552            } => Guard::ContainsMemberEquals {
553                path: map_values_path(&path, map),
554                member,
555                value,
556            },
557            Guard::ContainsTruthyMember { path, member } => Guard::ContainsTruthyMember {
558                path: map_values_path(&path, map),
559                member,
560            },
561        }
562    }
563}
564
565fn map_values_path<F>(path: &ValuesPath, map: &mut F) -> ValuesPath
566where
567    F: FnMut(ValuesPath) -> ValuesPath,
568{
569    map(path.clone())
570}
571
572fn map_guard_alternatives<F>(alternatives: Vec<Vec<Guard>>, map: &mut F) -> Vec<Vec<Guard>>
573where
574    F: FnMut(ValuesPath) -> ValuesPath,
575{
576    alternatives
577        .into_iter()
578        .map(|alternative| {
579            alternative
580                .into_iter()
581                .map(|guard| guard.map_value_paths(map))
582                .collect()
583        })
584        .collect()
585}