1use regex::Regex;
13use std::collections::HashSet;
14use std::fs;
15use std::path::{Path, PathBuf};
16use walkdir::WalkDir;
17
18use crate::chart::HelmChart;
19use crate::error::{ConversionWarning, ConvertError, Result, WarningCategory, WarningSeverity};
20use crate::parser;
21use crate::transformer::Transformer;
22
23#[derive(Debug, Clone, Default)]
25pub struct ConvertOptions {
26 pub force: bool,
28 pub dry_run: bool,
30 pub verbose: bool,
32}
33
34#[derive(Debug)]
36pub struct ConversionResult {
37 pub converted_files: Vec<PathBuf>,
39 pub copied_files: Vec<PathBuf>,
41 pub skipped_files: Vec<PathBuf>,
43 pub warnings: Vec<ConversionWarning>,
45}
46
47impl ConversionResult {
48 fn new() -> Self {
49 Self {
50 converted_files: Vec::new(),
51 copied_files: Vec::new(),
52 skipped_files: Vec::new(),
53 warnings: Vec::new(),
54 }
55 }
56}
57
58pub struct Converter {
60 options: ConvertOptions,
61}
62
63impl Converter {
64 pub fn new(options: ConvertOptions) -> Self {
65 Self { options }
66 }
67
68 pub fn convert(&self, chart_path: &Path, output_path: &Path) -> Result<ConversionResult> {
70 let mut result = ConversionResult::new();
71
72 if !chart_path.exists() {
74 return Err(ConvertError::DirectoryNotFound(chart_path.to_path_buf()));
75 }
76
77 let chart_yaml_path = chart_path.join("Chart.yaml");
79 if !chart_yaml_path.exists() {
80 return Err(ConvertError::NotAChart("Chart.yaml".to_string()));
81 }
82
83 if output_path.exists() && !self.options.force {
85 return Err(ConvertError::OutputExists(output_path.to_path_buf()));
86 }
87
88 let chart_content = fs::read_to_string(&chart_yaml_path)?;
90 let chart = HelmChart::parse(&chart_content)?;
91 let chart_name = chart.name.clone();
92
93 if !self.options.dry_run {
94 fs::create_dir_all(output_path)?;
96 }
97
98 let pack = chart.to_sherpack();
100 let pack_yaml = pack.to_yaml()?;
101
102 if !self.options.dry_run {
103 let pack_path = output_path.join("Pack.yaml");
104 fs::write(&pack_path, &pack_yaml)?;
105 result.converted_files.push(pack_path);
106 } else {
107 result.converted_files.push(output_path.join("Pack.yaml"));
108 }
109
110 let values_path = chart_path.join("values.yaml");
112 if values_path.exists() {
113 if !self.options.dry_run {
114 let dest = output_path.join("values.yaml");
115 fs::copy(&values_path, &dest)?;
116 result.copied_files.push(dest);
117 } else {
118 result.copied_files.push(output_path.join("values.yaml"));
119 }
120 }
121
122 let schema_json = chart_path.join("values.schema.json");
124 if schema_json.exists() {
125 if !self.options.dry_run {
126 let dest = output_path.join("values.schema.json");
127 fs::copy(&schema_json, &dest)?;
128 result.copied_files.push(dest);
129 } else {
130 result
131 .copied_files
132 .push(output_path.join("values.schema.json"));
133 }
134 }
135
136 let templates_dir = chart_path.join("templates");
138 if templates_dir.exists() {
139 self.convert_templates_dir(
140 &templates_dir,
141 &output_path.join("templates"),
142 &chart_name,
143 &mut result,
144 )?;
145 }
146
147 let charts_dir = chart_path.join("charts");
149 if charts_dir.exists() {
150 self.convert_subcharts(&charts_dir, &output_path.join("packs"), &mut result)?;
151 }
152
153 self.copy_extra_files(chart_path, output_path, &mut result)?;
155
156 Ok(result)
157 }
158
159 fn convert_templates_dir(
160 &self,
161 src_dir: &Path,
162 dest_dir: &Path,
163 chart_name: &str,
164 result: &mut ConversionResult,
165 ) -> Result<()> {
166 if !self.options.dry_run {
167 fs::create_dir_all(dest_dir)?;
168 }
169
170 let mut macro_sources: std::collections::HashMap<String, String> =
173 std::collections::HashMap::new();
174 let mut defined_macros: HashSet<String> = HashSet::new();
175 let mut helper_files: Vec<(PathBuf, String, String)> = Vec::new(); for entry in WalkDir::new(src_dir)
178 .follow_links(true)
179 .into_iter()
180 .filter_map(|e| e.ok())
181 {
182 let path = entry.path();
183 if path.is_dir() {
184 continue;
185 }
186
187 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
188 if file_name.starts_with('_') && file_name.ends_with(".tpl") {
189 let content = fs::read_to_string(path)?;
190 let rel_path = path.strip_prefix(src_dir).unwrap_or(path);
191 let dest_path = self.get_dest_path(dest_dir, rel_path);
192 let dest_name = dest_path
193 .file_name()
194 .and_then(|n| n.to_str())
195 .unwrap_or("_helpers.j2")
196 .to_string();
197
198 match self.convert_helpers(&content, chart_name, &dest_path) {
199 Ok((converted, warnings)) => {
200 let macros = extract_macro_definitions(&converted);
202 for macro_name in ¯os {
203 macro_sources.insert(macro_name.clone(), dest_name.clone());
204 }
205 defined_macros.extend(macros);
206
207 helper_files.push((dest_path.clone(), dest_name, converted));
209 result.converted_files.push(dest_path);
210 result.warnings.extend(warnings);
211 }
212 Err(e) => {
213 result.warnings.push(ConversionWarning {
214 severity: WarningSeverity::Error,
215 category: WarningCategory::Syntax,
216 file: path.to_path_buf(),
217 line: None,
218 pattern: "template parse".to_string(),
219 message: format!("Failed to convert: {}", e),
220 suggestion: Some("Manual conversion may be required".to_string()),
221 doc_link: None,
222 });
223 result.skipped_files.push(path.to_path_buf());
224 }
225 }
226 }
227 }
228
229 for (dest_path, this_file, converted) in &helper_files {
231 let used_macros = find_used_macros(converted, &defined_macros);
233
234 let mut imports_by_file: std::collections::HashMap<&str, Vec<&str>> =
236 std::collections::HashMap::new();
237 for macro_name in &used_macros {
238 if let Some(source_file) = macro_sources.get(macro_name) {
239 if source_file != this_file {
240 imports_by_file
241 .entry(source_file.as_str())
242 .or_default()
243 .push(macro_name.as_str());
244 }
245 }
246 }
247
248 let final_content = if !imports_by_file.is_empty() {
250 let mut import_statements = String::new();
251 let mut sorted_files: Vec<&&str> = imports_by_file.keys().collect();
252 sorted_files.sort();
253
254 for file in sorted_files {
255 let mut macro_list: Vec<&str> = imports_by_file[*file].clone();
256 macro_list.sort();
257 import_statements.push_str(&format!(
258 "{{%- from \"{}\" import {} -%}}\n",
259 file,
260 macro_list.join(", ")
261 ));
262 }
263 format!("{}{}", import_statements, converted)
264 } else {
265 converted.clone()
266 };
267
268 if !self.options.dry_run {
269 fs::create_dir_all(dest_path.parent().unwrap_or(dest_dir))?;
270 fs::write(dest_path, &final_content)?;
271 }
272 }
273
274 for entry in WalkDir::new(src_dir)
276 .follow_links(true)
277 .into_iter()
278 .filter_map(|e| e.ok())
279 {
280 let path = entry.path();
281
282 if path.is_dir() {
283 let rel_path = path.strip_prefix(src_dir).unwrap_or(path);
284 let dest = dest_dir.join(rel_path);
285 if !self.options.dry_run {
286 fs::create_dir_all(&dest)?;
287 }
288 continue;
289 }
290
291 let rel_path = path.strip_prefix(src_dir).unwrap_or(path);
292 let dest_path = self.get_dest_path(dest_dir, rel_path);
293 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
294
295 if file_name.starts_with('_') && file_name.ends_with(".tpl") {
297 continue;
298 }
299
300 let content = match fs::read_to_string(path) {
301 Ok(c) => c,
302 Err(e) => {
303 result.warnings.push(ConversionWarning {
304 severity: WarningSeverity::Error,
305 category: WarningCategory::Syntax,
306 file: path.to_path_buf(),
307 line: None,
308 pattern: "file read".to_string(),
309 message: format!("Failed to read file: {}", e),
310 suggestion: None,
311 doc_link: None,
312 });
313 result.skipped_files.push(path.to_path_buf());
314 continue;
315 }
316 };
317
318 if content.contains("{{") {
323 match self.convert_template_with_macros(
324 &content,
325 chart_name,
326 &dest_path,
327 &defined_macros,
328 ¯o_sources,
329 ) {
330 Ok((converted, warnings)) => {
331 if !self.options.dry_run {
332 fs::write(&dest_path, &converted)?;
333 }
334 result.converted_files.push(dest_path.clone());
335 result.warnings.extend(warnings);
336 }
337 Err(e) => {
338 result.warnings.push(ConversionWarning {
339 severity: WarningSeverity::Error,
340 category: WarningCategory::Syntax,
341 file: path.to_path_buf(),
342 line: None,
343 pattern: "template parse".to_string(),
344 message: format!("Failed to convert: {}", e),
345 suggestion: Some("Manual conversion may be required".to_string()),
346 doc_link: None,
347 });
348 if !self.options.dry_run {
349 fs::write(&dest_path, &content)?;
350 }
351 result.skipped_files.push(path.to_path_buf());
352 }
353 }
354 } else {
355 if !self.options.dry_run {
356 fs::write(&dest_path, &content)?;
357 }
358 result.copied_files.push(dest_path);
359 }
360 }
361
362 Ok(())
363 }
364
365 fn convert_template_with_macros(
367 &self,
368 content: &str,
369 chart_name: &str,
370 dest_path: &Path,
371 defined_macros: &HashSet<String>,
372 macro_sources: &std::collections::HashMap<String, String>,
373 ) -> Result<(String, Vec<ConversionWarning>)> {
374 let ast = parser::parse(content)?;
375 let mut transformer = Transformer::new().with_chart_prefix(chart_name);
376 let converted = transformer.transform(&ast);
377
378 let used_macros = find_used_macros(&converted, defined_macros);
380
381 let final_content = if !used_macros.is_empty() && !macro_sources.is_empty() {
383 let mut imports_by_file: std::collections::HashMap<&str, Vec<&str>> =
385 std::collections::HashMap::new();
386 for macro_name in &used_macros {
387 if let Some(source_file) = macro_sources.get(macro_name) {
388 imports_by_file
389 .entry(source_file.as_str())
390 .or_default()
391 .push(macro_name.as_str());
392 }
393 }
394
395 let mut import_statements = String::new();
397 let mut sorted_files: Vec<&&str> = imports_by_file.keys().collect();
398 sorted_files.sort();
399
400 for file in sorted_files {
401 let mut macro_list: Vec<&str> = imports_by_file[*file].clone();
402 macro_list.sort();
403 import_statements.push_str(&format!(
404 "{{%- from \"{}\" import {} -%}}\n",
405 file,
406 macro_list.join(", ")
407 ));
408 }
409 format!("{}{}", import_statements, converted)
410 } else {
411 converted
412 };
413
414 let warnings = self.collect_warnings(&transformer, dest_path, &final_content);
416
417 Ok((final_content, warnings))
418 }
419
420 fn convert_template(
421 &self,
422 content: &str,
423 chart_name: &str,
424 dest_path: &Path,
425 ) -> Result<(String, Vec<ConversionWarning>)> {
426 self.convert_template_with_macros(
428 content,
429 chart_name,
430 dest_path,
431 &HashSet::new(),
432 &std::collections::HashMap::new(),
433 )
434 }
435
436 fn collect_warnings(
438 &self,
439 transformer: &Transformer,
440 dest_path: &Path,
441 final_content: &str,
442 ) -> Vec<ConversionWarning> {
443 let mut warnings: Vec<ConversionWarning> = transformer
444 .warnings()
445 .iter()
446 .map(|w| {
447 let category = match w.severity {
448 crate::transformer::WarningSeverity::Info => WarningCategory::Syntax,
449 crate::transformer::WarningSeverity::Warning => WarningCategory::Syntax,
450 crate::transformer::WarningSeverity::Unsupported => {
451 WarningCategory::UnsupportedFeature
452 }
453 };
454 let severity = match w.severity {
455 crate::transformer::WarningSeverity::Info => WarningSeverity::Info,
456 crate::transformer::WarningSeverity::Warning => WarningSeverity::Warning,
457 crate::transformer::WarningSeverity::Unsupported => {
458 WarningSeverity::Unsupported
459 }
460 };
461 ConversionWarning {
462 severity,
463 category,
464 file: dest_path.to_path_buf(),
465 line: None,
466 pattern: w.pattern.clone(),
467 message: w.message.clone(),
468 suggestion: w.suggestion.clone(),
469 doc_link: w.doc_link.clone(),
470 }
471 })
472 .collect();
473
474 if final_content.contains("__UNSUPPORTED_FILES__") {
476 warnings.push(ConversionWarning::unsupported(
477 dest_path.to_path_buf(),
478 ".Files.*",
479 "Embed file content in values.yaml or use ConfigMap/Secret resources",
480 ));
481 }
482
483 if final_content.contains("__UNSUPPORTED_GENCA__") {
484 warnings.push(ConversionWarning::security(
485 dest_path.to_path_buf(),
486 "genCA",
487 "'genCA' generates certificates in templates - this is insecure",
488 "Use cert-manager for certificate management",
489 ));
490 }
491
492 warnings
493 }
494
495 fn convert_helpers(
496 &self,
497 content: &str,
498 chart_name: &str,
499 dest_path: &Path,
500 ) -> Result<(String, Vec<ConversionWarning>)> {
501 self.convert_template(content, chart_name, dest_path)
503 }
504
505 fn get_dest_path(&self, dest_dir: &Path, rel_path: &Path) -> PathBuf {
506 let file_name = rel_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
507
508 let new_name = if file_name.starts_with('_') && file_name.ends_with(".tpl") {
510 let base = file_name
511 .strip_prefix('_')
512 .unwrap_or(file_name)
513 .strip_suffix(".tpl")
514 .unwrap_or(file_name);
515 format!("_{}.j2", base)
516 } else {
517 file_name.to_string()
518 };
519
520 if let Some(parent) = rel_path.parent() {
521 dest_dir.join(parent).join(new_name)
522 } else {
523 dest_dir.join(new_name)
524 }
525 }
526
527 fn convert_subcharts(
528 &self,
529 charts_dir: &Path,
530 packs_dir: &Path,
531 result: &mut ConversionResult,
532 ) -> Result<()> {
533 if !charts_dir.exists() {
534 return Ok(());
535 }
536
537 if !self.options.dry_run {
538 fs::create_dir_all(packs_dir)?;
539 }
540
541 for entry in fs::read_dir(charts_dir)? {
542 let entry = entry?;
543 let path = entry.path();
544
545 if path.is_dir() {
546 let subchart_name = path
547 .file_name()
548 .and_then(|n| n.to_str())
549 .unwrap_or("unknown");
550
551 let dest = packs_dir.join(subchart_name);
552
553 match self.convert(&path, &dest) {
555 Ok(sub_result) => {
556 result.converted_files.extend(sub_result.converted_files);
557 result.copied_files.extend(sub_result.copied_files);
558 result.skipped_files.extend(sub_result.skipped_files);
559 result.warnings.extend(sub_result.warnings);
560 }
561 Err(e) => {
562 result.warnings.push(ConversionWarning {
563 severity: WarningSeverity::Error,
564 category: WarningCategory::Syntax,
565 file: path.clone(),
566 line: None,
567 pattern: "subchart".to_string(),
568 message: format!("Failed to convert subchart: {}", e),
569 suggestion: None,
570 doc_link: None,
571 });
572 result.skipped_files.push(path);
573 }
574 }
575 } else if path.extension().map(|e| e == "tgz").unwrap_or(false) {
576 if !self.options.dry_run {
578 let dest = packs_dir.join(path.file_name().unwrap());
579 fs::copy(&path, &dest)?;
580 result.copied_files.push(dest);
581 } else {
582 result.copied_files.push(path);
583 }
584 }
585 }
586
587 Ok(())
588 }
589
590 fn copy_extra_files(
591 &self,
592 src_dir: &Path,
593 dest_dir: &Path,
594 result: &mut ConversionResult,
595 ) -> Result<()> {
596 let extra_files = ["README.md", "LICENSE", "CHANGELOG.md", ".helmignore"];
597
598 for file in &extra_files {
599 let src = src_dir.join(file);
600 if src.exists() {
601 let dest = if *file == ".helmignore" {
602 dest_dir.join(".sherpackignore")
603 } else {
604 dest_dir.join(file)
605 };
606
607 if !self.options.dry_run {
608 fs::copy(&src, &dest)?;
609 }
610 result.copied_files.push(dest);
611 }
612 }
613
614 Ok(())
615 }
616}
617
618fn extract_macro_definitions(content: &str) -> HashSet<String> {
627 let re = Regex::new(r"\{%-?\s*macro\s+(\w+)\s*\(").expect("valid regex");
628 re.captures_iter(content)
629 .filter_map(|cap| cap.get(1))
630 .map(|m| m.as_str().to_string())
631 .collect()
632}
633
634fn find_used_macros(content: &str, defined: &HashSet<String>) -> HashSet<String> {
639 let mut used = HashSet::new();
640
641 for macro_name in defined {
642 let pattern = format!(r"\b{}\s*\(\s*\)", regex::escape(macro_name));
644 if let Ok(re) = Regex::new(&pattern) {
645 if re.is_match(content) {
646 used.insert(macro_name.clone());
647 }
648 }
649 }
650
651 used
652}
653
654pub fn convert(chart_path: &Path, output_path: &Path) -> Result<ConversionResult> {
660 let converter = Converter::new(ConvertOptions::default());
661 converter.convert(chart_path, output_path)
662}
663
664pub fn convert_with_options(
666 chart_path: &Path,
667 output_path: &Path,
668 options: ConvertOptions,
669) -> Result<ConversionResult> {
670 let converter = Converter::new(options);
671 converter.convert(chart_path, output_path)
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677 use tempfile::TempDir;
678
679 fn create_test_chart(dir: &Path) {
680 fs::create_dir_all(dir.join("templates")).unwrap();
681
682 fs::write(
683 dir.join("Chart.yaml"),
684 r#"
685apiVersion: v2
686name: test-app
687version: 1.0.0
688description: A test application
689"#,
690 )
691 .unwrap();
692
693 fs::write(
694 dir.join("values.yaml"),
695 r#"
696replicaCount: 1
697image:
698 repository: nginx
699 tag: latest
700"#,
701 )
702 .unwrap();
703
704 fs::write(
705 dir.join("templates/deployment.yaml"),
706 r#"
707apiVersion: apps/v1
708kind: Deployment
709metadata:
710 name: {{ .Release.Name }}
711spec:
712 replicas: {{ .Values.replicaCount }}
713 template:
714 spec:
715 containers:
716 - name: {{ .Chart.Name }}
717 image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
718"#,
719 )
720 .unwrap();
721
722 fs::write(
723 dir.join("templates/_helpers.tpl"),
724 r#"
725{{- define "test-app.name" -}}
726{{- .Chart.Name | trunc 63 | trimSuffix "-" }}
727{{- end }}
728"#,
729 )
730 .unwrap();
731 }
732
733 #[test]
734 fn test_convert_simple_chart() {
735 let chart_dir = TempDir::new().unwrap();
736 let output_base = TempDir::new().unwrap();
737 let output_dir = output_base.path().join("output");
738
739 create_test_chart(chart_dir.path());
740
741 let result = convert(chart_dir.path(), &output_dir).unwrap();
742
743 assert!(!result.converted_files.is_empty());
744 assert!(output_dir.join("Pack.yaml").exists());
745 assert!(output_dir.join("values.yaml").exists());
746 assert!(output_dir.join("templates").exists());
747 }
748
749 #[test]
750 fn test_convert_deployment() {
751 let chart_dir = TempDir::new().unwrap();
752 let output_base = TempDir::new().unwrap();
753 let output_dir = output_base.path().join("output");
754
755 create_test_chart(chart_dir.path());
756
757 convert(chart_dir.path(), &output_dir).unwrap();
758
759 let deployment = fs::read_to_string(output_dir.join("templates/deployment.yaml")).unwrap();
760
761 assert!(deployment.contains("release.name"));
762 assert!(deployment.contains("values.replicaCount"));
763 assert!(deployment.contains("pack.name"));
764 assert!(deployment.contains("values.image.repository"));
765 }
766
767 #[test]
768 fn test_convert_helpers() {
769 let chart_dir = TempDir::new().unwrap();
770 let output_base = TempDir::new().unwrap();
771 let output_dir = output_base.path().join("output");
772
773 create_test_chart(chart_dir.path());
774
775 convert(chart_dir.path(), &output_dir).unwrap();
776
777 let helpers_path = output_dir.join("templates/_helpers.j2");
778 assert!(helpers_path.exists());
779
780 let helpers = fs::read_to_string(&helpers_path).unwrap();
781 assert!(helpers.contains("macro"));
782 assert!(helpers.contains("endmacro"));
783 }
784
785 #[test]
786 fn test_dry_run() {
787 let chart_dir = TempDir::new().unwrap();
788 let output_base = TempDir::new().unwrap();
789 let output_dir = output_base.path().join("output");
790
791 create_test_chart(chart_dir.path());
792
793 let options = ConvertOptions {
794 dry_run: true,
795 ..Default::default()
796 };
797
798 let result = convert_with_options(chart_dir.path(), &output_dir, options).unwrap();
799
800 assert!(!result.converted_files.is_empty());
802 assert!(!output_dir.join("Pack.yaml").exists());
803 }
804
805 #[test]
806 fn test_force_overwrite() {
807 let chart_dir = TempDir::new().unwrap();
808 let output_base = TempDir::new().unwrap();
809 let output_dir = output_base.path().join("output");
810
811 create_test_chart(chart_dir.path());
812
813 convert(chart_dir.path(), &output_dir).unwrap();
815
816 let err = convert(chart_dir.path(), &output_dir);
818 assert!(err.is_err());
819
820 let options = ConvertOptions {
822 force: true,
823 ..Default::default()
824 };
825
826 let result = convert_with_options(chart_dir.path(), &output_dir, options);
827 assert!(result.is_ok());
828 }
829}