sherpack_convert/
chart.rs

1//! Chart.yaml to Pack.yaml converter
2//!
3//! Converts Helm Chart.yaml metadata to Sherpack Pack.yaml format.
4
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use thiserror::Error;
8
9#[derive(Debug, Error)]
10pub enum ChartError {
11    #[error("Failed to parse Chart.yaml: {0}")]
12    Parse(#[from] serde_yaml::Error),
13
14    #[error("Missing required field: {0}")]
15    MissingField(String),
16
17    #[error("Invalid version: {0}")]
18    InvalidVersion(String),
19}
20
21/// Helm Chart.yaml structure
22#[derive(Debug, Clone, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct HelmChart {
25    /// API version (v1 or v2)
26    pub api_version: String,
27
28    /// Chart name
29    pub name: String,
30
31    /// Chart version (SemVer)
32    pub version: String,
33
34    /// Kubernetes version constraint
35    #[serde(default)]
36    pub kube_version: Option<String>,
37
38    /// Chart description
39    #[serde(default)]
40    pub description: Option<String>,
41
42    /// Chart type (application or library)
43    #[serde(default, rename = "type")]
44    pub chart_type: Option<String>,
45
46    /// Keywords for searching
47    #[serde(default)]
48    pub keywords: Vec<String>,
49
50    /// Project home page
51    #[serde(default)]
52    pub home: Option<String>,
53
54    /// Source code URLs
55    #[serde(default)]
56    pub sources: Vec<String>,
57
58    /// Chart dependencies
59    #[serde(default)]
60    pub dependencies: Vec<HelmDependency>,
61
62    /// Maintainers
63    #[serde(default)]
64    pub maintainers: Vec<HelmMaintainer>,
65
66    /// Icon URL
67    #[serde(default)]
68    pub icon: Option<String>,
69
70    /// App version
71    #[serde(default)]
72    pub app_version: Option<String>,
73
74    /// Whether chart is deprecated
75    #[serde(default)]
76    pub deprecated: bool,
77
78    /// Annotations
79    #[serde(default)]
80    pub annotations: BTreeMap<String, String>,
81}
82
83/// Helm dependency
84#[derive(Debug, Clone, Deserialize, Serialize)]
85#[serde(rename_all = "camelCase")]
86pub struct HelmDependency {
87    /// Dependency name
88    pub name: String,
89
90    /// Version constraint
91    pub version: String,
92
93    /// Repository URL
94    #[serde(default)]
95    pub repository: Option<String>,
96
97    /// Condition to enable
98    #[serde(default)]
99    pub condition: Option<String>,
100
101    /// Tags for grouping
102    #[serde(default)]
103    pub tags: Vec<String>,
104
105    /// Import values
106    #[serde(default)]
107    pub import_values: Vec<serde_yaml::Value>,
108
109    /// Alias name
110    #[serde(default)]
111    pub alias: Option<String>,
112}
113
114/// Helm maintainer
115#[derive(Debug, Clone, Deserialize, Serialize)]
116pub struct HelmMaintainer {
117    /// Maintainer name
118    pub name: String,
119
120    /// Email address
121    #[serde(default)]
122    pub email: Option<String>,
123
124    /// URL
125    #[serde(default)]
126    pub url: Option<String>,
127}
128
129/// Sherpack Pack.yaml structure
130#[derive(Debug, Clone, Serialize)]
131#[serde(rename_all = "camelCase")]
132pub struct SherpackPack {
133    /// API version
134    pub api_version: String,
135
136    /// Pack kind
137    pub kind: String,
138
139    /// Pack name
140    pub name: String,
141
142    /// Pack version
143    pub version: String,
144
145    /// Kubernetes version constraint
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub kube_version: Option<String>,
148
149    /// Description
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub description: Option<String>,
152
153    /// Keywords
154    #[serde(skip_serializing_if = "Vec::is_empty")]
155    pub keywords: Vec<String>,
156
157    /// Home page
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub home: Option<String>,
160
161    /// Source URLs
162    #[serde(skip_serializing_if = "Vec::is_empty")]
163    pub sources: Vec<String>,
164
165    /// Dependencies
166    #[serde(skip_serializing_if = "Vec::is_empty")]
167    pub dependencies: Vec<SherpackDependency>,
168
169    /// Maintainers
170    #[serde(skip_serializing_if = "Vec::is_empty")]
171    pub maintainers: Vec<HelmMaintainer>,
172
173    /// Icon URL
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub icon: Option<String>,
176
177    /// App version
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub app_version: Option<String>,
180
181    /// Annotations
182    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
183    pub annotations: BTreeMap<String, String>,
184}
185
186/// Sherpack dependency
187#[derive(Debug, Clone, Serialize)]
188#[serde(rename_all = "camelCase")]
189pub struct SherpackDependency {
190    /// Dependency name
191    pub name: String,
192
193    /// Version constraint
194    pub version: String,
195
196    /// Repository URL
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub repository: Option<String>,
199
200    /// Condition to enable
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub condition: Option<String>,
203
204    /// Tags for grouping
205    #[serde(skip_serializing_if = "Vec::is_empty")]
206    pub tags: Vec<String>,
207
208    /// Alias name
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub alias: Option<String>,
211}
212
213impl HelmChart {
214    /// Parse a Chart.yaml string
215    pub fn parse(content: &str) -> Result<Self, ChartError> {
216        let chart: HelmChart = serde_yaml::from_str(content)?;
217
218        // Validate required fields
219        if chart.name.is_empty() {
220            return Err(ChartError::MissingField("name".to_string()));
221        }
222        if chart.version.is_empty() {
223            return Err(ChartError::MissingField("version".to_string()));
224        }
225
226        Ok(chart)
227    }
228
229    /// Convert to Sherpack Pack
230    pub fn to_sherpack(&self) -> SherpackPack {
231        let kind = match self.chart_type.as_deref() {
232            Some("library") => "library".to_string(),
233            _ => "application".to_string(),
234        };
235
236        let dependencies: Vec<SherpackDependency> = self
237            .dependencies
238            .iter()
239            .map(|d| SherpackDependency {
240                name: d.name.clone(),
241                version: d.version.clone(),
242                repository: d.repository.clone(),
243                condition: d.condition.clone(),
244                tags: d.tags.clone(),
245                alias: d.alias.clone(),
246            })
247            .collect();
248
249        // Handle deprecated annotation
250        let mut annotations = self.annotations.clone();
251        if self.deprecated {
252            annotations.insert("sherpack.io/deprecated".to_string(), "true".to_string());
253        }
254
255        SherpackPack {
256            api_version: "sherpack/v1".to_string(),
257            kind,
258            name: self.name.clone(),
259            version: self.version.clone(),
260            kube_version: self.kube_version.clone(),
261            description: self.description.clone(),
262            keywords: self.keywords.clone(),
263            home: self.home.clone(),
264            sources: self.sources.clone(),
265            dependencies,
266            maintainers: self.maintainers.clone(),
267            icon: self.icon.clone(),
268            app_version: self.app_version.clone(),
269            annotations,
270        }
271    }
272}
273
274impl SherpackPack {
275    /// Serialize to YAML string
276    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
277        // Create a custom serialization with proper ordering
278        // Sherpack expects: apiVersion, kind, metadata: { name, version, ... }, dependencies, engine
279        let mut yaml = String::new();
280
281        yaml.push_str(&format!("apiVersion: {}\n", self.api_version));
282        yaml.push_str(&format!("kind: {}\n", self.kind));
283        yaml.push_str("\nmetadata:\n");
284        yaml.push_str(&format!("  name: {}\n", self.name));
285        yaml.push_str(&format!("  version: {}\n", self.version));
286
287        if let Some(ref app_version) = self.app_version {
288            yaml.push_str(&format!("  appVersion: \"{}\"\n", app_version));
289        }
290
291        if let Some(ref description) = self.description {
292            yaml.push_str(&format!("  description: {}\n", description));
293        }
294
295        if let Some(ref kube_version) = self.kube_version {
296            // Quote kubeVersion if it contains special characters
297            if kube_version.starts_with('>')
298                || kube_version.starts_with('<')
299                || kube_version.starts_with('=')
300            {
301                yaml.push_str(&format!("  kubeVersion: \"{}\"\n", kube_version));
302            } else {
303                yaml.push_str(&format!("  kubeVersion: {}\n", kube_version));
304            }
305        }
306
307        if let Some(ref home) = self.home {
308            yaml.push_str(&format!("  home: {}\n", home));
309        }
310
311        if let Some(ref icon) = self.icon {
312            yaml.push_str(&format!("  icon: {}\n", icon));
313        }
314
315        if !self.sources.is_empty() {
316            yaml.push_str("  sources:\n");
317            for source in &self.sources {
318                yaml.push_str(&format!("    - {}\n", source));
319            }
320        }
321
322        if !self.keywords.is_empty() {
323            yaml.push_str("  keywords:\n");
324            for keyword in &self.keywords {
325                yaml.push_str(&format!("    - {}\n", keyword));
326            }
327        }
328
329        if !self.maintainers.is_empty() {
330            yaml.push_str("  maintainers:\n");
331            for maintainer in &self.maintainers {
332                yaml.push_str(&format!("    - name: {}\n", maintainer.name));
333                if let Some(ref email) = maintainer.email {
334                    yaml.push_str(&format!("      email: {}\n", email));
335                }
336                if let Some(ref url) = maintainer.url {
337                    yaml.push_str(&format!("      url: {}\n", url));
338                }
339            }
340        }
341
342        if !self.annotations.is_empty() {
343            yaml.push_str("  annotations:\n");
344            for (key, value) in &self.annotations {
345                // Handle multiline values with YAML block scalar
346                if value.contains('\n') {
347                    yaml.push_str(&format!("    {}: |\n", key));
348                    for line in value.lines() {
349                        yaml.push_str(&format!("      {}\n", line));
350                    }
351                } else {
352                    // Escape the value properly
353                    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
354                    yaml.push_str(&format!("    {}: \"{}\"\n", key, escaped));
355                }
356            }
357        }
358
359        if !self.dependencies.is_empty() {
360            yaml.push('\n');
361            yaml.push_str("dependencies:\n");
362            for dep in &self.dependencies {
363                yaml.push_str(&format!("  - name: {}\n", dep.name));
364                yaml.push_str(&format!("    version: \"{}\"\n", dep.version));
365                if let Some(ref repo) = dep.repository {
366                    yaml.push_str(&format!("    repository: {}\n", repo));
367                }
368                if let Some(ref condition) = dep.condition {
369                    yaml.push_str(&format!("    condition: {}\n", condition));
370                }
371                if let Some(ref alias) = dep.alias {
372                    yaml.push_str(&format!("    alias: {}\n", alias));
373                }
374                if !dep.tags.is_empty() {
375                    yaml.push_str("    tags:\n");
376                    for tag in &dep.tags {
377                        yaml.push_str(&format!("      - {}\n", tag));
378                    }
379                }
380            }
381        }
382
383        Ok(yaml)
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_parse_simple_chart() {
393        let content = r#"
394apiVersion: v2
395name: my-app
396version: 1.0.0
397description: My application
398"#;
399        let chart = HelmChart::parse(content).unwrap();
400        assert_eq!(chart.name, "my-app");
401        assert_eq!(chart.version, "1.0.0");
402        assert_eq!(chart.description, Some("My application".to_string()));
403    }
404
405    #[test]
406    fn test_convert_to_sherpack() {
407        let content = r#"
408apiVersion: v2
409name: my-app
410version: 1.0.0
411type: application
412"#;
413        let chart = HelmChart::parse(content).unwrap();
414        let pack = chart.to_sherpack();
415
416        assert_eq!(pack.api_version, "sherpack/v1");
417        assert_eq!(pack.kind, "application");
418        assert_eq!(pack.name, "my-app");
419    }
420
421    #[test]
422    fn test_convert_library() {
423        let content = r#"
424apiVersion: v2
425name: my-lib
426version: 1.0.0
427type: library
428"#;
429        let chart = HelmChart::parse(content).unwrap();
430        let pack = chart.to_sherpack();
431
432        assert_eq!(pack.kind, "library");
433    }
434
435    #[test]
436    fn test_convert_with_dependencies() {
437        let content = r#"
438apiVersion: v2
439name: my-app
440version: 1.0.0
441dependencies:
442  - name: postgresql
443    version: "12.x"
444    repository: https://charts.bitnami.com/bitnami
445    condition: postgresql.enabled
446"#;
447        let chart = HelmChart::parse(content).unwrap();
448        let pack = chart.to_sherpack();
449
450        assert_eq!(pack.dependencies.len(), 1);
451        assert_eq!(pack.dependencies[0].name, "postgresql");
452        assert_eq!(pack.dependencies[0].version, "12.x");
453    }
454
455    #[test]
456    fn test_yaml_output() {
457        let content = r#"
458apiVersion: v2
459name: my-app
460version: 1.0.0
461appVersion: "2.0.0"
462description: My application
463"#;
464        let chart = HelmChart::parse(content).unwrap();
465        let pack = chart.to_sherpack();
466        let yaml = pack.to_yaml().unwrap();
467
468        assert!(yaml.contains("apiVersion: sherpack/v1"));
469        assert!(yaml.contains("kind: application"));
470        assert!(yaml.contains("metadata:"));
471        assert!(yaml.contains("  name: my-app"));
472        assert!(yaml.contains("appVersion: \"2.0.0\""));
473    }
474
475    #[test]
476    fn test_deprecated_annotation() {
477        let content = r#"
478apiVersion: v2
479name: old-app
480version: 1.0.0
481deprecated: true
482"#;
483        let chart = HelmChart::parse(content).unwrap();
484        let pack = chart.to_sherpack();
485
486        assert!(pack.annotations.contains_key("sherpack.io/deprecated"));
487    }
488}