Skip to main content

helm_schema_core/
capability.rs

1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4
5/// One branch of an if/elif/else chain in a helper or manifest header.
6#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
7pub struct HelperBranch {
8    /// `None` = unguarded trailing `else`; `Some` = structurally decoded guard.
9    pub guard: Option<CapabilityGuard>,
10    /// The apiVersion literals or nested branch chain produced by this branch.
11    pub body: HelperBranchBody,
12}
13
14/// What a `HelperBranch` produces when its guard is live.
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "snake_case")]
17pub enum HelperBranchBody {
18    /// Flat list of literal alternatives. Empty means the branch resolves to
19    /// no statically-known literal.
20    Literals {
21        /// Literal strings emitted by the branch body.
22        values: Vec<String>,
23    },
24    /// Branch body is itself a typed if/else chain.
25    Nested {
26        /// Nested control-flow branches in source order.
27        branches: Vec<HelperBranch>,
28    },
29}
30
31impl HelperBranchBody {
32    /// Build a literal-bodied branch payload.
33    #[must_use]
34    pub fn literals(values: Vec<String>) -> Self {
35        Self::Literals { values }
36    }
37
38    /// True when the body carries no statically-reachable literal.
39    #[must_use]
40    pub fn is_empty(&self) -> bool {
41        match self {
42            Self::Literals { values } => values.is_empty(),
43            Self::Nested { branches } => branches.iter().all(|branch| branch.body.is_empty()),
44        }
45    }
46
47    /// Returns every distinct literal reachable from this branch body.
48    #[must_use]
49    pub fn all_literals(&self) -> Vec<String> {
50        let mut out = Vec::new();
51        let mut seen = HashSet::new();
52        self.append_all_literals(&mut out, &mut seen);
53        out
54    }
55
56    /// Appends distinct reachable literals while preserving branch order.
57    pub fn append_all_literals(&self, out: &mut Vec<String>, seen: &mut HashSet<String>) {
58        match self {
59            Self::Literals { values } => {
60                for value in values {
61                    if seen.insert(value.clone()) {
62                        out.push(value.clone());
63                    }
64                }
65            }
66            Self::Nested { branches } => {
67                for branch in branches {
68                    branch.body.append_all_literals(out, seen);
69                }
70            }
71        }
72    }
73}
74
75/// Structurally-decoded capability guard for an `if` action.
76#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
77#[serde(tag = "type", rename_all = "snake_case")]
78pub enum CapabilityGuard {
79    /// `.Capabilities.APIVersions.Has "X"`.
80    Has {
81        /// Helm API or resource literal whose presence selects the branch.
82        api: String,
83    },
84    /// `not .Capabilities.APIVersions.Has "X"`.
85    NotHas {
86        /// Helm API or resource literal whose absence selects the branch.
87        api: String,
88    },
89    /// Any guard the static decoder cannot structurally evaluate.
90    Opaque {
91        /// Original guard text retained for diagnostics.
92        text: String,
93    },
94}
95
96/// A typed `.Capabilities.APIVersions.Has ...` query.
97#[derive(Clone, Debug, Eq, PartialEq)]
98pub enum ApiPresenceQuery {
99    /// Presence of one concrete resource kind at an API version.
100    Resource {
101        /// Kubernetes API version.
102        api_version: String,
103        /// Kubernetes resource kind.
104        kind: String,
105    },
106    /// Presence of any resource in an API group/version.
107    GroupVersion {
108        /// Kubernetes API group/version literal.
109        api_version: String,
110    },
111}
112
113impl ApiPresenceQuery {
114    /// Parses either Helm's `group/version[/Kind]` or core `version/Kind` spelling.
115    #[must_use]
116    pub fn parse_helm_literal(api: &str) -> Option<Self> {
117        let parts: Vec<&str> = api.split('/').collect();
118        match parts.as_slice() {
119            [group, version, kind]
120                if !group.is_empty() && !version.is_empty() && !kind.is_empty() =>
121            {
122                Some(Self::Resource {
123                    api_version: format!("{group}/{version}"),
124                    kind: (*kind).to_string(),
125                })
126            }
127            [version, kind] if is_k8s_api_version_segment(version) && !kind.is_empty() => {
128                Some(Self::Resource {
129                    api_version: (*version).to_string(),
130                    kind: (*kind).to_string(),
131                })
132            }
133            [api_version] if !api_version.is_empty() => Some(Self::GroupVersion {
134                api_version: (*api_version).to_string(),
135            }),
136            [group, version] if !group.is_empty() && !version.is_empty() => {
137                Some(Self::GroupVersion {
138                    api_version: format!("{group}/{version}"),
139                })
140            }
141            _ => None,
142        }
143    }
144
145    /// Canonical Helm literal for this query.
146    #[must_use]
147    pub fn canonical_helm_literal(&self) -> String {
148        match self {
149            Self::Resource { api_version, kind } => format!("{api_version}/{kind}"),
150            Self::GroupVersion { api_version } => api_version.clone(),
151        }
152    }
153}
154
155fn is_k8s_api_version_segment(segment: &str) -> bool {
156    let Some(rest) = segment.strip_prefix('v') else {
157        return false;
158    };
159    let digit_count = rest.chars().take_while(char::is_ascii_digit).count();
160    if digit_count == 0 {
161        return false;
162    }
163    let suffix = &rest[digit_count..];
164    if suffix.is_empty() {
165        return true;
166    }
167    for qualifier in ["alpha", "beta"] {
168        if let Some(number) = suffix.strip_prefix(qualifier) {
169            return !number.is_empty()
170                && number.chars().all(|character| character.is_ascii_digit());
171        }
172    }
173    false
174}