1use 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#[derive(Debug, Clone, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct HelmChart {
25 pub api_version: String,
27
28 pub name: String,
30
31 pub version: String,
33
34 #[serde(default)]
36 pub kube_version: Option<String>,
37
38 #[serde(default)]
40 pub description: Option<String>,
41
42 #[serde(default, rename = "type")]
44 pub chart_type: Option<String>,
45
46 #[serde(default)]
48 pub keywords: Vec<String>,
49
50 #[serde(default)]
52 pub home: Option<String>,
53
54 #[serde(default)]
56 pub sources: Vec<String>,
57
58 #[serde(default)]
60 pub dependencies: Vec<HelmDependency>,
61
62 #[serde(default)]
64 pub maintainers: Vec<HelmMaintainer>,
65
66 #[serde(default)]
68 pub icon: Option<String>,
69
70 #[serde(default)]
72 pub app_version: Option<String>,
73
74 #[serde(default)]
76 pub deprecated: bool,
77
78 #[serde(default)]
80 pub annotations: BTreeMap<String, String>,
81}
82
83#[derive(Debug, Clone, Deserialize, Serialize)]
85#[serde(rename_all = "camelCase")]
86pub struct HelmDependency {
87 pub name: String,
89
90 pub version: String,
92
93 #[serde(default)]
95 pub repository: Option<String>,
96
97 #[serde(default)]
99 pub condition: Option<String>,
100
101 #[serde(default)]
103 pub tags: Vec<String>,
104
105 #[serde(default)]
107 pub import_values: Vec<serde_yaml::Value>,
108
109 #[serde(default)]
111 pub alias: Option<String>,
112}
113
114#[derive(Debug, Clone, Deserialize, Serialize)]
116pub struct HelmMaintainer {
117 pub name: String,
119
120 #[serde(default)]
122 pub email: Option<String>,
123
124 #[serde(default)]
126 pub url: Option<String>,
127}
128
129#[derive(Debug, Clone, Serialize)]
131#[serde(rename_all = "camelCase")]
132pub struct SherpackPack {
133 pub api_version: String,
135
136 pub kind: String,
138
139 pub name: String,
141
142 pub version: String,
144
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub kube_version: Option<String>,
148
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub description: Option<String>,
152
153 #[serde(skip_serializing_if = "Vec::is_empty")]
155 pub keywords: Vec<String>,
156
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub home: Option<String>,
160
161 #[serde(skip_serializing_if = "Vec::is_empty")]
163 pub sources: Vec<String>,
164
165 #[serde(skip_serializing_if = "Vec::is_empty")]
167 pub dependencies: Vec<SherpackDependency>,
168
169 #[serde(skip_serializing_if = "Vec::is_empty")]
171 pub maintainers: Vec<HelmMaintainer>,
172
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub icon: Option<String>,
176
177 #[serde(skip_serializing_if = "Option::is_none")]
179 pub app_version: Option<String>,
180
181 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
183 pub annotations: BTreeMap<String, String>,
184}
185
186#[derive(Debug, Clone, Serialize)]
188#[serde(rename_all = "camelCase")]
189pub struct SherpackDependency {
190 pub name: String,
192
193 pub version: String,
195
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub repository: Option<String>,
199
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub condition: Option<String>,
203
204 #[serde(skip_serializing_if = "Vec::is_empty")]
206 pub tags: Vec<String>,
207
208 #[serde(skip_serializing_if = "Option::is_none")]
210 pub alias: Option<String>,
211}
212
213impl HelmChart {
214 pub fn parse(content: &str) -> Result<Self, ChartError> {
216 let chart: HelmChart = serde_yaml::from_str(content)?;
217
218 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 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 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 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
277 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 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 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 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}