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