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::parser;
21use crate::transformer::Transformer;
22
23/// Options for the converter
24#[derive(Debug, Clone, Default)]
25pub struct ConvertOptions {
26    /// Overwrite existing output directory
27    pub force: bool,
28    /// Only show what would be converted
29    pub dry_run: bool,
30    /// Verbose output
31    pub verbose: bool,
32}
33
34/// Result of a conversion
35#[derive(Debug)]
36pub struct ConversionResult {
37    /// Files that were converted
38    pub converted_files: Vec<PathBuf>,
39    /// Files that were copied as-is
40    pub copied_files: Vec<PathBuf>,
41    /// Files that were skipped
42    pub skipped_files: Vec<PathBuf>,
43    /// Warnings generated during conversion
44    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
58/// Convert a Helm chart to a Sherpack pack
59pub struct Converter {
60    options: ConvertOptions,
61}
62
63impl Converter {
64    pub fn new(options: ConvertOptions) -> Self {
65        Self { options }
66    }
67
68    /// Convert a Helm chart directory to a Sherpack pack
69    pub fn convert(&self, chart_path: &Path, output_path: &Path) -> Result<ConversionResult> {
70        let mut result = ConversionResult::new();
71
72        // Validate input
73        if !chart_path.exists() {
74            return Err(ConvertError::DirectoryNotFound(chart_path.to_path_buf()));
75        }
76
77        // Check for Chart.yaml
78        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        // Check output directory
84        if output_path.exists() && !self.options.force {
85            return Err(ConvertError::OutputExists(output_path.to_path_buf()));
86        }
87
88        // Parse Chart.yaml
89        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            // Create output directory
95            fs::create_dir_all(output_path)?;
96        }
97
98        // Convert Chart.yaml -> Pack.yaml
99        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        // Copy values.yaml
111        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        // Copy values.schema.json -> values.schema.yaml (or keep as JSON)
123        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        // Convert templates directory
137        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        // Convert charts/ -> packs/ (subcharts)
148        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        // Copy other files (README, LICENSE, etc.)
154        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        // Three-pass conversion for proper macro import handling
171        // Pass 1: Convert helpers files and collect macro definitions per file
172        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(); // (dest_path, dest_name, converted_content)
176
177        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                        // Extract macro names from converted content and track their source file
201                        let macros = extract_macro_definitions(&converted);
202                        for macro_name in &macros {
203                            macro_sources.insert(macro_name.clone(), dest_name.clone());
204                        }
205                        defined_macros.extend(macros);
206
207                        // Store for pass 2 processing
208                        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        // Pass 2: Add cross-imports to helper files and write them
230        for (dest_path, this_file, converted) in &helper_files {
231            // Find macros used in this helper that are defined in OTHER helper files
232            let used_macros = find_used_macros(converted, &defined_macros);
233
234            // Group by source file, excluding macros from this file
235            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            // Generate import statements
249            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        // Pass 3: Convert regular templates with macro awareness
275        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            // Skip helpers (already processed)
296            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            // NOTES.txt needs conversion too - it often contains Go templates
319            // Don't skip it anymore
320
321            // Convert template files
322            if content.contains("{{") {
323                match self.convert_template_with_macros(
324                    &content,
325                    chart_name,
326                    &dest_path,
327                    &defined_macros,
328                    &macro_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    /// Convert a template with awareness of defined macros
366    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        // Find macros used in the converted template
379        let used_macros = find_used_macros(&converted, defined_macros);
380
381        // Generate import statements grouped by source file
382        let final_content = if !used_macros.is_empty() && !macro_sources.is_empty() {
383            // Group macros by their source file
384            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            // Generate import statements for each file
396            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        // Convert transformer warnings
415        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        // For backwards compatibility, use empty macro set
427        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    /// Collect and convert transformer warnings
437    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        // Check for __UNSUPPORTED_ markers
475        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        // Helpers files are just templates with define blocks
502        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        // Rename _helpers.tpl -> _macros.j2
509        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                // Recursively convert subchart
554                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                // Packaged subchart - just copy for now
577                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
618// =============================================================================
619// Macro Detection Helpers
620// =============================================================================
621
622/// Extract macro definitions from Jinja2 content
623///
624/// Finds all `{%- macro name() %}` patterns and returns the macro names.
625/// This is the Jinja2 way - explicit definitions enable explicit imports.
626fn 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
634/// Find which macros from `defined` are used in the content
635///
636/// Scans for `macroName()` patterns that match defined macros.
637/// Returns only the macros that are actually used.
638fn find_used_macros(content: &str, defined: &HashSet<String>) -> HashSet<String> {
639    let mut used = HashSet::new();
640
641    for macro_name in defined {
642        // Look for macro calls: macroName() with possible whitespace
643        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
654// =============================================================================
655// Public API
656// =============================================================================
657
658/// Quick convert function
659pub 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
664/// Convert with options
665pub 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        // Should report files but not create them
801        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        // First conversion
814        convert(chart_dir.path(), &output_dir).unwrap();
815
816        // Second conversion without force should fail
817        let err = convert(chart_dir.path(), &output_dir);
818        assert!(err.is_err());
819
820        // With force should succeed
821        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}