syncable_cli/analyzer/helmlint/parser/
chart.rs

1//! Chart.yaml parser.
2//!
3//! Parses Helm chart metadata from Chart.yaml files.
4
5use std::collections::HashMap;
6use std::path::Path;
7
8use serde::{Deserialize, Serialize};
9
10/// Helm Chart API version.
11#[derive(Debug, Clone, PartialEq, Eq, Default)]
12pub enum ApiVersion {
13    /// Helm 2 style charts
14    V1,
15    /// Helm 3 style charts
16    #[default]
17    V2,
18    /// Unknown/invalid version
19    Unknown(String),
20}
21
22impl<'de> Deserialize<'de> for ApiVersion {
23    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
24    where
25        D: serde::Deserializer<'de>,
26    {
27        let s = String::deserialize(deserializer)?;
28        Ok(match s.as_str() {
29            "v1" => ApiVersion::V1,
30            "v2" => ApiVersion::V2,
31            other => ApiVersion::Unknown(other.to_string()),
32        })
33    }
34}
35
36impl Serialize for ApiVersion {
37    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
38    where
39        S: serde::Serializer,
40    {
41        match self {
42            ApiVersion::V1 => serializer.serialize_str("v1"),
43            ApiVersion::V2 => serializer.serialize_str("v2"),
44            ApiVersion::Unknown(s) => serializer.serialize_str(s),
45        }
46    }
47}
48
49/// Chart type.
50#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
51#[serde(rename_all = "lowercase")]
52pub enum ChartType {
53    /// Standard application chart
54    #[default]
55    Application,
56    /// Library chart (no templates rendered directly)
57    Library,
58}
59
60/// Chart maintainer information.
61#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
62pub struct Maintainer {
63    /// Maintainer name
64    pub name: String,
65    /// Maintainer email
66    pub email: Option<String>,
67    /// Maintainer URL
68    pub url: Option<String>,
69}
70
71/// Chart dependency.
72#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
73pub struct Dependency {
74    /// Dependency chart name
75    pub name: String,
76    /// Version constraint (SemVer)
77    pub version: Option<String>,
78    /// Repository URL
79    pub repository: Option<String>,
80    /// Condition for enabling
81    pub condition: Option<String>,
82    /// Tags for enabling
83    pub tags: Option<Vec<String>>,
84    /// Import values configuration
85    #[serde(rename = "import-values")]
86    pub import_values: Option<Vec<serde_yaml::Value>>,
87    /// Alias for the dependency
88    pub alias: Option<String>,
89}
90
91/// Parsed Chart.yaml metadata.
92#[derive(Debug, Clone, Deserialize, Serialize)]
93pub struct ChartMetadata {
94    /// The chart API version (v1 or v2)
95    #[serde(rename = "apiVersion")]
96    pub api_version: ApiVersion,
97
98    /// The name of the chart
99    pub name: String,
100
101    /// A SemVer 2 version
102    pub version: String,
103
104    /// Kubernetes version constraint
105    #[serde(rename = "kubeVersion")]
106    pub kube_version: Option<String>,
107
108    /// A single-sentence description of this project
109    pub description: Option<String>,
110
111    /// The type of the chart (application or library)
112    #[serde(rename = "type")]
113    pub chart_type: Option<ChartType>,
114
115    /// A list of keywords about this project
116    #[serde(default)]
117    pub keywords: Vec<String>,
118
119    /// The URL of this projects home page
120    pub home: Option<String>,
121
122    /// A list of URLs to source code for this project
123    #[serde(default)]
124    pub sources: Vec<String>,
125
126    /// A list of chart dependencies
127    #[serde(default)]
128    pub dependencies: Vec<Dependency>,
129
130    /// A list of maintainers
131    #[serde(default)]
132    pub maintainers: Vec<Maintainer>,
133
134    /// A URL to an SVG or PNG image to be used as an icon
135    pub icon: Option<String>,
136
137    /// The version of the app that this contains
138    #[serde(rename = "appVersion")]
139    pub app_version: Option<String>,
140
141    /// Whether this chart is deprecated
142    pub deprecated: Option<bool>,
143
144    /// Annotations
145    #[serde(default)]
146    pub annotations: HashMap<String, String>,
147}
148
149impl ChartMetadata {
150    /// Check if the chart has valid API version.
151    pub fn has_valid_api_version(&self) -> bool {
152        matches!(self.api_version, ApiVersion::V1 | ApiVersion::V2)
153    }
154
155    /// Check if this is a v2 (Helm 3) chart.
156    pub fn is_v2(&self) -> bool {
157        matches!(self.api_version, ApiVersion::V2)
158    }
159
160    /// Check if this is a library chart.
161    pub fn is_library(&self) -> bool {
162        matches!(self.chart_type, Some(ChartType::Library))
163    }
164
165    /// Check if the chart is marked as deprecated.
166    pub fn is_deprecated(&self) -> bool {
167        self.deprecated.unwrap_or(false)
168    }
169
170    /// Get dependency names.
171    pub fn dependency_names(&self) -> Vec<&str> {
172        self.dependencies.iter().map(|d| d.name.as_str()).collect()
173    }
174
175    /// Check for duplicate dependency names.
176    pub fn has_duplicate_dependencies(&self) -> Vec<&str> {
177        let mut seen = std::collections::HashSet::new();
178        let mut duplicates = Vec::new();
179        for dep in &self.dependencies {
180            let name = dep.alias.as_ref().unwrap_or(&dep.name);
181            if !seen.insert(name.as_str()) {
182                duplicates.push(name.as_str());
183            }
184        }
185        duplicates
186    }
187}
188
189/// Parse error for Chart.yaml.
190#[derive(Debug)]
191pub struct ChartParseError {
192    pub message: String,
193    pub line: Option<u32>,
194}
195
196impl std::fmt::Display for ChartParseError {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        if let Some(line) = self.line {
199            write!(f, "line {}: {}", line, self.message)
200        } else {
201            write!(f, "{}", self.message)
202        }
203    }
204}
205
206impl std::error::Error for ChartParseError {}
207
208/// Parse Chart.yaml content.
209pub fn parse_chart_yaml(content: &str) -> Result<ChartMetadata, ChartParseError> {
210    serde_yaml::from_str(content).map_err(|e| {
211        let line = e.location().map(|l| l.line() as u32);
212        ChartParseError {
213            message: e.to_string(),
214            line,
215        }
216    })
217}
218
219/// Parse Chart.yaml from a file path.
220pub fn parse_chart_yaml_file(path: &Path) -> Result<ChartMetadata, ChartParseError> {
221    let content = std::fs::read_to_string(path).map_err(|e| ChartParseError {
222        message: format!("Failed to read file: {}", e),
223        line: None,
224    })?;
225    parse_chart_yaml(&content)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn test_parse_minimal_chart() {
234        let yaml = r#"
235apiVersion: v2
236name: test-chart
237version: 0.1.0
238"#;
239        let chart = parse_chart_yaml(yaml).unwrap();
240        assert_eq!(chart.name, "test-chart");
241        assert_eq!(chart.version, "0.1.0");
242        assert!(chart.is_v2());
243    }
244
245    #[test]
246    fn test_parse_full_chart() {
247        let yaml = r#"
248apiVersion: v2
249name: my-app
250version: 1.2.3
251kubeVersion: ">=1.19.0"
252description: A sample application
253type: application
254keywords:
255  - app
256  - example
257home: https://example.com
258sources:
259  - https://github.com/example/my-app
260maintainers:
261  - name: John Doe
262    email: john@example.com
263icon: https://example.com/icon.png
264appVersion: "2.0.0"
265dependencies:
266  - name: postgresql
267    version: "~11.0"
268    repository: https://charts.bitnami.com/bitnami
269annotations:
270  category: backend
271"#;
272        let chart = parse_chart_yaml(yaml).unwrap();
273        assert_eq!(chart.name, "my-app");
274        assert_eq!(chart.version, "1.2.3");
275        assert_eq!(chart.kube_version, Some(">=1.19.0".to_string()));
276        assert_eq!(chart.description, Some("A sample application".to_string()));
277        assert!(!chart.is_library());
278        assert_eq!(chart.keywords.len(), 2);
279        assert_eq!(chart.maintainers.len(), 1);
280        assert_eq!(chart.dependencies.len(), 1);
281    }
282
283    #[test]
284    fn test_parse_library_chart() {
285        let yaml = r#"
286apiVersion: v2
287name: common
288version: 1.0.0
289type: library
290"#;
291        let chart = parse_chart_yaml(yaml).unwrap();
292        assert!(chart.is_library());
293    }
294
295    #[test]
296    fn test_parse_v1_chart() {
297        let yaml = r#"
298apiVersion: v1
299name: legacy-chart
300version: 1.0.0
301"#;
302        let chart = parse_chart_yaml(yaml).unwrap();
303        assert!(!chart.is_v2());
304        assert!(chart.has_valid_api_version());
305    }
306
307    #[test]
308    fn test_deprecated_chart() {
309        let yaml = r#"
310apiVersion: v2
311name: old-chart
312version: 1.0.0
313deprecated: true
314"#;
315        let chart = parse_chart_yaml(yaml).unwrap();
316        assert!(chart.is_deprecated());
317    }
318
319    #[test]
320    fn test_duplicate_dependencies() {
321        let yaml = r#"
322apiVersion: v2
323name: test
324version: 1.0.0
325dependencies:
326  - name: redis
327    version: "1.0.0"
328    repository: https://charts.bitnami.com/bitnami
329  - name: redis
330    version: "2.0.0"
331    repository: https://charts.bitnami.com/bitnami
332"#;
333        let chart = parse_chart_yaml(yaml).unwrap();
334        let duplicates = chart.has_duplicate_dependencies();
335        assert_eq!(duplicates.len(), 1);
336        assert_eq!(duplicates[0], "redis");
337    }
338
339    #[test]
340    fn test_parse_error() {
341        let yaml = "invalid: [yaml";
342        let result = parse_chart_yaml(yaml);
343        assert!(result.is_err());
344    }
345}