sherpack_convert/
converter.rs

1//! Main converter logic
2//!
3//! Orchestrates the conversion of a Helm chart to a Sherpack pack.
4//!
5//! # Design Philosophy
6//!
7//! This converter follows Jinja2's explicit import philosophy:
8//! - Macros must be imported before use
9//! - Auto-detects all macros defined in helpers files
10//! - Generates minimal import statements with only used macros
11
12use 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/// Options for the converter
26#[derive(Debug, Clone, Default)]
27pub struct ConvertOptions {
28    /// Overwrite existing output directory
29    pub force: bool,
30    /// Only show what would be converted
31    pub dry_run: bool,
32    /// Verbose output
33    pub verbose: bool,
34}
35
36/// Result of a conversion
37#[derive(Debug)]
38pub struct ConversionResult {
39    /// Files that were converted
40    pub converted_files: Vec<PathBuf>,
41    /// Files that were copied as-is
42    pub copied_files: Vec<PathBuf>,
43    /// Files that were skipped
44    pub skipped_files: Vec<PathBuf>,
45    /// Warnings generated during conversion
46    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
60/// Convert a Helm chart to a Sherpack pack
61pub struct Converter {
62    options: ConvertOptions,
63}
64
65impl Converter {
66    pub fn new(options: ConvertOptions) -> Self {
67        Self { options }
68    }
69
70    /// Load type context from values.yaml
71    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    /// Load macro post-processor from values.yaml
82    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    /// Convert a Helm chart directory to a Sherpack pack
93    pub fn convert(&self, chart_path: &Path, output_path: &Path) -> Result<ConversionResult> {
94        let mut result = ConversionResult::new();
95
96        // Load type context for smarter dict/list detection
97        let type_context = Self::load_type_context(chart_path);
98
99        // Load macro post-processor for variable scoping in macros
100        let macro_processor = Self::load_macro_processor(chart_path);
101
102        // Validate input
103        if !chart_path.exists() {
104            return Err(ConvertError::DirectoryNotFound(chart_path.to_path_buf()));
105        }
106
107        // Check for Chart.yaml
108        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        // Check output directory
114        if output_path.exists() && !self.options.force {
115            return Err(ConvertError::OutputExists(output_path.to_path_buf()));
116        }
117
118        // Parse Chart.yaml
119        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            // Create output directory
125            fs::create_dir_all(output_path)?;
126        }
127
128        // Convert Chart.yaml -> Pack.yaml
129        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        // Copy values.yaml
141        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        // Copy values.schema.json -> values.schema.yaml (or keep as JSON)
153        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        // Convert templates directory
167        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        // Convert charts/ -> packs/ (subcharts)
180        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        // Copy other files (README, LICENSE, etc.)
186        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        // Three-pass conversion for proper macro import handling
205        // Pass 1: Convert helpers files and collect macro definitions per file
206        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(); // (dest_path, dest_name, converted_content)
210
211        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                        // Extract macro names from converted content and track their source file
235                        let macros = extract_macro_definitions(&converted);
236                        for macro_name in &macros {
237                            macro_sources.insert(macro_name.clone(), dest_name.clone());
238                        }
239                        defined_macros.extend(macros);
240
241                        // Store for pass 2 processing
242                        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        // Pass 2: Add cross-imports to helper files and write them
264        for (dest_path, this_file, converted) in &helper_files {
265            // Find macros used in this helper that are defined in OTHER helper files
266            let used_macros = find_used_macros(converted, &defined_macros);
267
268            // Group by source file, excluding macros from this file
269            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            // Generate import statements
283            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            // Apply macro post-processing to resolve bare variable references
303            let final_content = if let Some(processor) = macro_processor {
304                let (processed, unresolved) = processor.process(&with_imports);
305
306                // Add warnings for unresolved variables
307                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        // Pass 3: Convert regular templates with macro awareness
335        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            // Skip helpers (already processed)
356            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            // NOTES.txt needs conversion too - it often contains Go templates
379            // Don't skip it anymore
380
381            // Convert template files
382            if content.contains("{{") {
383                match self.convert_template_with_macros(
384                    &content,
385                    chart_name,
386                    &dest_path,
387                    &defined_macros,
388                    &macro_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    /// Convert a template with awareness of defined macros
427    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        // Add type context for smarter dict/list detection
440        if let Some(ctx) = type_context {
441            transformer = transformer.with_type_context(ctx.clone());
442        }
443
444        let converted = transformer.transform(&ast);
445
446        // Find macros used in the converted template
447        let used_macros = find_used_macros(&converted, defined_macros);
448
449        // Generate import statements grouped by source file
450        let final_content = if !used_macros.is_empty() && !macro_sources.is_empty() {
451            // Group macros by their source file
452            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            // Generate import statements for each file
464            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        // Convert transformer warnings
483        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        // For backwards compatibility, use empty macro set and no type context
495        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    /// Collect and convert transformer warnings
506    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        // Check for __UNSUPPORTED_ markers
544        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        // Helpers files are just templates with define blocks
571        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        // Rename _helpers.tpl -> _macros.j2
578        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                // Recursively convert subchart
623                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                // Packaged subchart - just copy for now
646                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
687// =============================================================================
688// Macro Detection Helpers
689// =============================================================================
690
691/// Extract macro definitions from Jinja2 content
692///
693/// Finds all `{%- macro name() %}` patterns and returns the macro names.
694/// This is the Jinja2 way - explicit definitions enable explicit imports.
695fn 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
703/// Find which macros from `defined` are used in the content
704///
705/// Scans for `macroName()` patterns that match defined macros.
706/// Returns only the macros that are actually used.
707fn find_used_macros(content: &str, defined: &HashSet<String>) -> HashSet<String> {
708    let mut used = HashSet::new();
709
710    for macro_name in defined {
711        // Look for macro calls: macroName() with possible whitespace
712        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
723// =============================================================================
724// Public API
725// =============================================================================
726
727/// Quick convert function
728pub 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
733/// Convert with options
734pub 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        // Should report files but not create them
870        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        // First conversion
883        convert(chart_dir.path(), &output_dir).unwrap();
884
885        // Second conversion without force should fail
886        let err = convert(chart_dir.path(), &output_dir);
887        assert!(err.is_err());
888
889        // With force should succeed
890        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}