sherpack_core/
pack.rs

1//! Pack definition and loading
2
3use semver::Version;
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use crate::error::{CoreError, Result};
9
10/// A Sherpack Pack - equivalent to a Helm Chart
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct Pack {
14    /// API version (sherpack/v1)
15    pub api_version: String,
16
17    /// Pack type
18    #[serde(default)]
19    pub kind: PackKind,
20
21    /// Pack metadata
22    pub metadata: PackMetadata,
23
24    /// Dependencies
25    #[serde(default)]
26    pub dependencies: Vec<Dependency>,
27
28    /// Engine configuration
29    #[serde(default)]
30    pub engine: EngineConfig,
31
32    /// CRD handling configuration
33    #[serde(default)]
34    pub crds: CrdConfig,
35}
36
37/// CRD handling configuration
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct CrdConfig {
41    /// Install CRDs from crds/ directory (default: true)
42    #[serde(default = "default_true")]
43    pub install: bool,
44
45    /// CRD upgrade behavior
46    #[serde(default)]
47    pub upgrade: CrdUpgradeConfig,
48
49    /// CRD uninstall behavior
50    #[serde(default)]
51    pub uninstall: CrdUninstallConfig,
52
53    /// Wait for CRDs to be Established before continuing (default: true)
54    #[serde(default = "default_true")]
55    pub wait_ready: bool,
56
57    /// Timeout for CRD readiness (default: 60s)
58    #[serde(default = "default_wait_timeout", with = "humantime_serde")]
59    pub wait_timeout: Duration,
60}
61
62impl Default for CrdConfig {
63    fn default() -> Self {
64        Self {
65            install: true,
66            upgrade: CrdUpgradeConfig::default(),
67            uninstall: CrdUninstallConfig::default(),
68            wait_ready: true,
69            wait_timeout: default_wait_timeout(),
70        }
71    }
72}
73
74fn default_wait_timeout() -> Duration {
75    Duration::from_secs(60)
76}
77
78/// CRD upgrade strategy
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum CrdUpgradeStrategy {
82    /// Only allow safe, additive changes (default)
83    #[default]
84    Safe,
85    /// Apply all changes (may break existing CRs)
86    Force,
87    /// Never update CRDs
88    Skip,
89}
90
91/// CRD upgrade configuration
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct CrdUpgradeConfig {
95    /// Allow CRD updates (default: true)
96    #[serde(default = "default_true")]
97    pub enabled: bool,
98
99    /// Upgrade strategy (default: safe)
100    #[serde(default)]
101    pub strategy: CrdUpgradeStrategy,
102}
103
104impl Default for CrdUpgradeConfig {
105    fn default() -> Self {
106        Self {
107            enabled: true,
108            strategy: CrdUpgradeStrategy::Safe,
109        }
110    }
111}
112
113/// CRD uninstall configuration
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct CrdUninstallConfig {
117    /// Keep CRDs on uninstall (default: true)
118    /// If false, requires --confirm-crd-deletion flag
119    #[serde(default = "default_true")]
120    pub keep: bool,
121}
122
123impl Default for CrdUninstallConfig {
124    fn default() -> Self {
125        Self { keep: true }
126    }
127}
128
129/// Pack type
130#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
131#[serde(rename_all = "lowercase")]
132pub enum PackKind {
133    #[default]
134    Application,
135    Library,
136}
137
138/// Pack metadata
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct PackMetadata {
142    /// Pack name (required)
143    pub name: String,
144
145    /// Pack version (required, SemVer)
146    #[serde(with = "version_serde")]
147    pub version: Version,
148
149    /// Description
150    #[serde(default)]
151    pub description: Option<String>,
152
153    /// Application version
154    #[serde(default)]
155    pub app_version: Option<String>,
156
157    /// Kubernetes version constraint
158    #[serde(default)]
159    pub kube_version: Option<String>,
160
161    /// Home URL
162    #[serde(default)]
163    pub home: Option<String>,
164
165    /// Icon URL
166    #[serde(default)]
167    pub icon: Option<String>,
168
169    /// Source URLs
170    #[serde(default)]
171    pub sources: Vec<String>,
172
173    /// Keywords
174    #[serde(default)]
175    pub keywords: Vec<String>,
176
177    /// Maintainers
178    #[serde(default)]
179    pub maintainers: Vec<Maintainer>,
180
181    /// Annotations
182    #[serde(default)]
183    pub annotations: std::collections::HashMap<String, String>,
184}
185
186/// Maintainer information
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct Maintainer {
189    pub name: String,
190    #[serde(default)]
191    pub email: Option<String>,
192    #[serde(default)]
193    pub url: Option<String>,
194}
195
196/// When to resolve a dependency
197///
198/// Controls whether a dependency is resolved/downloaded based on conditions.
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "kebab-case")]
201pub enum ResolvePolicy {
202    /// Always resolve, regardless of condition (useful for vendoring/caching)
203    Always,
204
205    /// Only resolve if condition evaluates to true (default)
206    ///
207    /// If no condition is set, behaves like `Always`.
208    /// Evaluated against values.yaml at resolution time.
209    #[default]
210    WhenEnabled,
211
212    /// Never resolve - dependency must already exist locally
213    ///
214    /// Useful for air-gapped environments where dependencies are pre-vendored.
215    Never,
216}
217
218/// Pack dependency
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct Dependency {
222    /// Dependency name
223    pub name: String,
224
225    /// Version constraint (semver)
226    pub version: String,
227
228    /// Repository URL
229    pub repository: String,
230
231    /// Static enable/disable flag
232    ///
233    /// When `false`, this dependency is completely ignored during resolution.
234    /// Unlike `condition`, this is evaluated at parse time, not against values.
235    /// Defaults to `true`.
236    #[serde(default = "default_true")]
237    pub enabled: bool,
238
239    /// Runtime condition expression
240    ///
241    /// A dot-separated path evaluated against values.yaml.
242    /// Example: `redis.enabled` checks `values.redis.enabled`.
243    ///
244    /// When combined with `resolve: when-enabled`, the condition is evaluated
245    /// at resolution time to skip downloading disabled dependencies.
246    #[serde(default)]
247    pub condition: Option<String>,
248
249    /// Resolution policy
250    ///
251    /// Controls when this dependency is resolved/downloaded.
252    /// Defaults to `when-enabled`.
253    #[serde(default)]
254    pub resolve: ResolvePolicy,
255
256    /// Tags for conditional inclusion
257    #[serde(default)]
258    pub tags: Vec<String>,
259
260    /// Alias name (overrides dependency name in templates)
261    #[serde(default)]
262    pub alias: Option<String>,
263}
264
265impl Dependency {
266    /// Get the effective name (alias if set, otherwise name)
267    #[inline]
268    pub fn effective_name(&self) -> &str {
269        self.alias.as_deref().unwrap_or(&self.name)
270    }
271
272    /// Check if this dependency should be resolved given the current values
273    ///
274    /// Returns `false` if:
275    /// - `enabled` is `false`
276    /// - `resolve` is `Never`
277    /// - `resolve` is `WhenEnabled` and condition evaluates to `false`
278    pub fn should_resolve(&self, values: &serde_json::Value) -> bool {
279        // Static disable always wins
280        if !self.enabled {
281            return false;
282        }
283
284        match self.resolve {
285            ResolvePolicy::Always => true,
286            ResolvePolicy::Never => false,
287            ResolvePolicy::WhenEnabled => {
288                // If no condition, treat as enabled
289                let Some(condition) = &self.condition else {
290                    return true;
291                };
292
293                evaluate_condition(condition, values)
294            }
295        }
296    }
297}
298
299/// Evaluate a simple dot-path condition against values
300///
301/// Supports paths like `redis.enabled`, `features.cache.memory`.
302/// Returns `true` if the path exists and is truthy.
303fn evaluate_condition(condition: &str, values: &serde_json::Value) -> bool {
304    let path: Vec<&str> = condition.split('.').collect();
305
306    let mut current = values;
307    for part in &path {
308        match current.get(*part) {
309            Some(v) => current = v,
310            None => return false, // Path doesn't exist → falsy
311        }
312    }
313
314    // Coerce to boolean
315    match current {
316        serde_json::Value::Bool(b) => *b,
317        serde_json::Value::Null => false,
318        serde_json::Value::String(s) => !s.is_empty() && s != "false" && s != "0",
319        serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
320        serde_json::Value::Array(a) => !a.is_empty(),
321        serde_json::Value::Object(o) => !o.is_empty(),
322    }
323}
324
325/// Engine configuration
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct EngineConfig {
328    /// Fail on undefined variables
329    #[serde(default = "default_true")]
330    pub strict: bool,
331}
332
333impl Default for EngineConfig {
334    fn default() -> Self {
335        Self { strict: true }
336    }
337}
338
339fn default_true() -> bool {
340    true
341}
342
343/// Loaded pack with resolved paths
344#[derive(Debug, Clone)]
345pub struct LoadedPack {
346    /// Pack definition
347    pub pack: Pack,
348
349    /// Root directory of the pack
350    pub root: PathBuf,
351
352    /// Templates directory
353    pub templates_dir: PathBuf,
354
355    /// CRDs directory (if present)
356    pub crds_dir: Option<PathBuf>,
357
358    /// Values file path
359    pub values_path: PathBuf,
360
361    /// Schema file path (if present)
362    pub schema_path: Option<PathBuf>,
363}
364
365impl LoadedPack {
366    /// Load a pack from a directory
367    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
368        let root = path.as_ref().to_path_buf();
369
370        if !root.exists() {
371            return Err(CoreError::PackNotFound {
372                path: root.display().to_string(),
373            });
374        }
375
376        // Load Pack.yaml
377        let pack_file = root.join("Pack.yaml");
378        if !pack_file.exists() {
379            return Err(CoreError::InvalidPack {
380                message: format!("Pack.yaml not found in {}", root.display()),
381            });
382        }
383
384        let pack_content = std::fs::read_to_string(&pack_file)?;
385        let pack: Pack = serde_yaml::from_str(&pack_content)?;
386
387        // Validate
388        if pack.api_version != "sherpack/v1" {
389            return Err(CoreError::InvalidPack {
390                message: format!(
391                    "Unsupported API version: {}. Expected: sherpack/v1",
392                    pack.api_version
393                ),
394            });
395        }
396
397        let templates_dir = root.join("templates");
398        let values_path = root.join("values.yaml");
399        let schema_path = Self::find_schema_file(&root);
400
401        // Detect crds/ directory
402        let crds_dir = {
403            let dir = root.join("crds");
404            if dir.exists() && dir.is_dir() {
405                Some(dir)
406            } else {
407                None
408            }
409        };
410
411        Ok(Self {
412            pack,
413            root,
414            templates_dir,
415            crds_dir,
416            values_path,
417            schema_path,
418        })
419    }
420
421    /// Find schema file, checking multiple standard locations
422    fn find_schema_file(root: &Path) -> Option<PathBuf> {
423        let candidates = [
424            "values.schema.yaml", // Sherpack default
425            "values.schema.json", // JSON Schema (Helm compatible)
426            "schema.yaml",
427            "schema.json",
428        ];
429
430        for candidate in candidates {
431            let path = root.join(candidate);
432            if path.exists() {
433                return Some(path);
434            }
435        }
436        None
437    }
438
439    /// Load the schema if present
440    pub fn load_schema(&self) -> Result<Option<crate::schema::Schema>> {
441        match &self.schema_path {
442            Some(path) => Ok(Some(crate::schema::Schema::from_file(path)?)),
443            None => Ok(None),
444        }
445    }
446
447    /// Get list of template files
448    pub fn template_files(&self) -> Result<Vec<PathBuf>> {
449        let mut files = Vec::new();
450
451        if !self.templates_dir.exists() {
452            return Ok(files);
453        }
454
455        for entry in walkdir::WalkDir::new(&self.templates_dir)
456            .into_iter()
457            .filter_map(|e| e.ok())
458        {
459            let path = entry.path();
460            if path.is_file() {
461                // Include .yaml, .yml, .j2, .jinja2, .txt files
462                if let Some(ext) = path.extension() {
463                    let ext = ext.to_string_lossy().to_lowercase();
464                    if matches!(
465                        ext.as_str(),
466                        "yaml" | "yml" | "j2" | "jinja2" | "txt" | "json"
467                    ) {
468                        files.push(path.to_path_buf());
469                    }
470                }
471            }
472        }
473
474        files.sort();
475        Ok(files)
476    }
477
478    /// Get list of CRD files from crds/ directory
479    ///
480    /// CRD files are not templated and are applied before regular templates.
481    /// Files are sorted alphabetically for deterministic ordering.
482    pub fn crd_files(&self) -> Result<Vec<PathBuf>> {
483        let Some(crds_dir) = &self.crds_dir else {
484            return Ok(Vec::new());
485        };
486
487        let mut files = Vec::new();
488
489        for entry in walkdir::WalkDir::new(crds_dir)
490            .into_iter()
491            .filter_map(|e| e.ok())
492        {
493            let path = entry.path();
494            if path.is_file() {
495                // Only include YAML files (CRDs should be YAML)
496                if let Some(ext) = path.extension() {
497                    let ext = ext.to_string_lossy().to_lowercase();
498                    if matches!(ext.as_str(), "yaml" | "yml") {
499                        files.push(path.to_path_buf());
500                    }
501                }
502            }
503        }
504
505        // Sort for deterministic ordering
506        files.sort();
507        Ok(files)
508    }
509
510    /// Check if this pack has CRDs
511    pub fn has_crds(&self) -> bool {
512        self.crds_dir.is_some()
513    }
514
515    /// Load all CRD manifests from crds/ directory
516    ///
517    /// CRD files may contain Jinja templating syntax. Files with templating
518    /// are flagged with `is_templated: true` and should be rendered before use.
519    pub fn load_crds(&self) -> Result<Vec<CrdManifest>> {
520        let files = self.crd_files()?;
521        let mut crds = Vec::new();
522
523        for file_path in files {
524            let content = std::fs::read_to_string(&file_path)?;
525            let relative_path = file_path
526                .strip_prefix(&self.root)
527                .unwrap_or(&file_path)
528                .to_path_buf();
529
530            // Check if file contains Jinja syntax (applies to whole file)
531            let file_is_templated = contains_jinja_syntax(&content);
532
533            // Parse multi-document YAML
534            for (idx, doc) in content.split("---").enumerate() {
535                let doc = doc.trim();
536                if doc.is_empty()
537                    || doc
538                        .lines()
539                        .all(|l| l.trim().is_empty() || l.trim().starts_with('#'))
540                {
541                    continue;
542                }
543
544                // For templated files, we can't validate kind until after rendering
545                // But we should still try to parse to catch obvious errors
546                let is_templated = file_is_templated || contains_jinja_syntax(doc);
547
548                if !is_templated {
549                    // Validate it's a CRD (only for non-templated files)
550                    let parsed: serde_yaml::Value = serde_yaml::from_str(doc)?;
551                    let kind = parsed.get("kind").and_then(|k| k.as_str());
552
553                    if kind != Some("CustomResourceDefinition") {
554                        return Err(CoreError::InvalidPack {
555                            message: format!(
556                                "File {} contains non-CRD resource (kind: {}). Only CustomResourceDefinition is allowed in crds/ directory",
557                                relative_path.display(),
558                                kind.unwrap_or("unknown")
559                            ),
560                        });
561                    }
562
563                    let name = parsed
564                        .get("metadata")
565                        .and_then(|m| m.get("name"))
566                        .and_then(|n| n.as_str())
567                        .unwrap_or("unknown")
568                        .to_string();
569
570                    crds.push(CrdManifest {
571                        name,
572                        source_file: relative_path.clone(),
573                        document_index: idx,
574                        content: doc.to_string(),
575                        is_templated: false,
576                    });
577                } else {
578                    // For templated CRDs, use a placeholder name
579                    // The real name will be extracted after rendering
580                    crds.push(CrdManifest {
581                        name: format!("templated-{}-{}", relative_path.display(), idx),
582                        source_file: relative_path.clone(),
583                        document_index: idx,
584                        content: doc.to_string(),
585                        is_templated: true,
586                    });
587                }
588            }
589        }
590
591        Ok(crds)
592    }
593
594    /// Get only static (non-templated) CRDs
595    pub fn static_crds(&self) -> Result<Vec<CrdManifest>> {
596        Ok(self
597            .load_crds()?
598            .into_iter()
599            .filter(|c| !c.is_templated)
600            .collect())
601    }
602
603    /// Get only templated CRDs (need rendering before use)
604    pub fn templated_crds(&self) -> Result<Vec<CrdManifest>> {
605        Ok(self
606            .load_crds()?
607            .into_iter()
608            .filter(|c| c.is_templated)
609            .collect())
610    }
611
612    /// Check if this pack has templated CRDs
613    pub fn has_templated_crds(&self) -> Result<bool> {
614        Ok(self.load_crds()?.iter().any(|c| c.is_templated))
615    }
616}
617
618/// A CRD manifest loaded from crds/ directory
619#[derive(Debug, Clone)]
620pub struct CrdManifest {
621    /// CRD name (metadata.name)
622    pub name: String,
623    /// Source file path (relative to pack root)
624    pub source_file: PathBuf,
625    /// Document index within the file (for multi-document YAML)
626    pub document_index: usize,
627    /// Raw YAML content
628    pub content: String,
629    /// Whether this CRD contains Jinja templating syntax
630    pub is_templated: bool,
631}
632
633/// Check if content contains Jinja templating syntax
634fn contains_jinja_syntax(content: &str) -> bool {
635    content.contains("{{") || content.contains("{%") || content.contains("{#")
636}
637
638/// Custom serde for semver::Version
639mod version_serde {
640    use semver::Version;
641    use serde::{Deserialize, Deserializer, Serializer};
642
643    pub fn serialize<S>(version: &Version, serializer: S) -> Result<S::Ok, S::Error>
644    where
645        S: Serializer,
646    {
647        serializer.serialize_str(&version.to_string())
648    }
649
650    pub fn deserialize<'de, D>(deserializer: D) -> Result<Version, D::Error>
651    where
652        D: Deserializer<'de>,
653    {
654        let s = String::deserialize(deserializer)?;
655        Version::parse(&s).map_err(serde::de::Error::custom)
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use serde_json::json;
663
664    #[test]
665    fn test_pack_deserialize() {
666        let yaml = r#"
667apiVersion: sherpack/v1
668kind: application
669metadata:
670  name: myapp
671  version: 1.0.0
672  description: My application
673"#;
674        let pack: Pack = serde_yaml::from_str(yaml).unwrap();
675        assert_eq!(pack.metadata.name, "myapp");
676        assert_eq!(pack.metadata.version.to_string(), "1.0.0");
677        assert_eq!(pack.kind, PackKind::Application);
678    }
679
680    #[test]
681    fn test_dependency_defaults() {
682        let yaml = r#"
683name: redis
684version: "^7.0"
685repository: https://repo.example.com
686"#;
687        let dep: Dependency = serde_yaml::from_str(yaml).unwrap();
688
689        assert_eq!(dep.name, "redis");
690        assert!(dep.enabled); // default: true
691        assert_eq!(dep.resolve, ResolvePolicy::WhenEnabled); // default
692        assert!(dep.condition.is_none());
693        assert!(dep.alias.is_none());
694    }
695
696    #[test]
697    fn test_dependency_with_all_fields() {
698        let yaml = r#"
699name: postgresql
700version: "^12.0"
701repository: https://charts.bitnami.com
702enabled: false
703condition: database.postgresql.enabled
704resolve: always
705alias: db
706tags:
707  - database
708  - backend
709"#;
710        let dep: Dependency = serde_yaml::from_str(yaml).unwrap();
711
712        assert_eq!(dep.name, "postgresql");
713        assert!(!dep.enabled);
714        assert_eq!(dep.resolve, ResolvePolicy::Always);
715        assert_eq!(
716            dep.condition.as_deref(),
717            Some("database.postgresql.enabled")
718        );
719        assert_eq!(dep.alias.as_deref(), Some("db"));
720        assert_eq!(dep.effective_name(), "db");
721        assert_eq!(dep.tags, vec!["database", "backend"]);
722    }
723
724    #[test]
725    fn test_resolve_policy_serialization() {
726        assert_eq!(
727            serde_yaml::to_string(&ResolvePolicy::Always)
728                .unwrap()
729                .trim(),
730            "always"
731        );
732        assert_eq!(
733            serde_yaml::to_string(&ResolvePolicy::WhenEnabled)
734                .unwrap()
735                .trim(),
736            "when-enabled"
737        );
738        assert_eq!(
739            serde_yaml::to_string(&ResolvePolicy::Never).unwrap().trim(),
740            "never"
741        );
742    }
743
744    #[test]
745    fn test_evaluate_condition_simple_bool() {
746        let values = json!({
747            "redis": {
748                "enabled": true
749            },
750            "postgresql": {
751                "enabled": false
752            }
753        });
754
755        assert!(evaluate_condition("redis.enabled", &values));
756        assert!(!evaluate_condition("postgresql.enabled", &values));
757    }
758
759    #[test]
760    fn test_evaluate_condition_nested_path() {
761        let values = json!({
762            "features": {
763                "cache": {
764                    "redis": {
765                        "enabled": true
766                    }
767                }
768            }
769        });
770
771        assert!(evaluate_condition("features.cache.redis.enabled", &values));
772        assert!(!evaluate_condition(
773            "features.cache.memcached.enabled",
774            &values
775        ));
776    }
777
778    #[test]
779    fn test_evaluate_condition_missing_path() {
780        let values = json!({
781            "redis": {}
782        });
783
784        assert!(!evaluate_condition("redis.enabled", &values));
785        assert!(!evaluate_condition("nonexistent.path", &values));
786    }
787
788    #[test]
789    fn test_evaluate_condition_truthy_values() {
790        let values = json!({
791            "string_true": "yes",
792            "string_false": "false",
793            "string_zero": "0",
794            "string_empty": "",
795            "number_one": 1,
796            "number_zero": 0,
797            "array_empty": [],
798            "array_full": [1, 2],
799            "object_empty": {},
800            "object_full": {"key": "value"},
801            "null_val": null
802        });
803
804        assert!(evaluate_condition("string_true", &values));
805        assert!(!evaluate_condition("string_false", &values));
806        assert!(!evaluate_condition("string_zero", &values));
807        assert!(!evaluate_condition("string_empty", &values));
808        assert!(evaluate_condition("number_one", &values));
809        assert!(!evaluate_condition("number_zero", &values));
810        assert!(!evaluate_condition("array_empty", &values));
811        assert!(evaluate_condition("array_full", &values));
812        assert!(!evaluate_condition("object_empty", &values));
813        assert!(evaluate_condition("object_full", &values));
814        assert!(!evaluate_condition("null_val", &values));
815    }
816
817    #[test]
818    fn test_should_resolve_disabled() {
819        let dep = Dependency {
820            name: "redis".to_string(),
821            version: "^7.0".to_string(),
822            repository: "https://repo.example.com".to_string(),
823            enabled: false,
824            condition: None,
825            resolve: ResolvePolicy::Always,
826            tags: vec![],
827            alias: None,
828        };
829
830        // enabled: false always wins, even with resolve: always
831        assert!(!dep.should_resolve(&json!({})));
832    }
833
834    #[test]
835    fn test_should_resolve_never() {
836        let dep = Dependency {
837            name: "redis".to_string(),
838            version: "^7.0".to_string(),
839            repository: "https://repo.example.com".to_string(),
840            enabled: true,
841            condition: None,
842            resolve: ResolvePolicy::Never,
843            tags: vec![],
844            alias: None,
845        };
846
847        assert!(!dep.should_resolve(&json!({})));
848    }
849
850    #[test]
851    fn test_should_resolve_always() {
852        let dep = Dependency {
853            name: "redis".to_string(),
854            version: "^7.0".to_string(),
855            repository: "https://repo.example.com".to_string(),
856            enabled: true,
857            condition: Some("redis.enabled".to_string()),
858            resolve: ResolvePolicy::Always,
859            tags: vec![],
860            alias: None,
861        };
862
863        // resolve: always ignores condition
864        assert!(dep.should_resolve(&json!({"redis": {"enabled": false}})));
865    }
866
867    #[test]
868    fn test_should_resolve_when_enabled_no_condition() {
869        let dep = Dependency {
870            name: "redis".to_string(),
871            version: "^7.0".to_string(),
872            repository: "https://repo.example.com".to_string(),
873            enabled: true,
874            condition: None,
875            resolve: ResolvePolicy::WhenEnabled,
876            tags: vec![],
877            alias: None,
878        };
879
880        // No condition = always resolve
881        assert!(dep.should_resolve(&json!({})));
882    }
883
884    #[test]
885    fn test_should_resolve_when_enabled_with_condition() {
886        let dep = Dependency {
887            name: "redis".to_string(),
888            version: "^7.0".to_string(),
889            repository: "https://repo.example.com".to_string(),
890            enabled: true,
891            condition: Some("redis.enabled".to_string()),
892            resolve: ResolvePolicy::WhenEnabled,
893            tags: vec![],
894            alias: None,
895        };
896
897        assert!(dep.should_resolve(&json!({"redis": {"enabled": true}})));
898        assert!(!dep.should_resolve(&json!({"redis": {"enabled": false}})));
899        assert!(!dep.should_resolve(&json!({}))); // Missing = false
900    }
901
902    // ==========================================
903    // CRD Configuration Tests
904    // ==========================================
905
906    #[test]
907    fn test_crd_config_defaults() {
908        let config = CrdConfig::default();
909
910        assert!(config.install);
911        assert!(config.upgrade.enabled);
912        assert_eq!(config.upgrade.strategy, CrdUpgradeStrategy::Safe);
913        assert!(config.uninstall.keep);
914        assert!(config.wait_ready);
915        assert_eq!(config.wait_timeout, Duration::from_secs(60));
916    }
917
918    #[test]
919    fn test_crd_config_deserialize_defaults() {
920        let yaml = r#"
921apiVersion: sherpack/v1
922kind: application
923metadata:
924  name: test
925  version: 1.0.0
926"#;
927        let pack: Pack = serde_yaml::from_str(yaml).unwrap();
928
929        // All CRD config should use defaults
930        assert!(pack.crds.install);
931        assert!(pack.crds.wait_ready);
932    }
933
934    #[test]
935    fn test_crd_config_deserialize_custom() {
936        let yaml = r#"
937apiVersion: sherpack/v1
938kind: application
939metadata:
940  name: test
941  version: 1.0.0
942crds:
943  install: false
944  upgrade:
945    enabled: true
946    strategy: force
947  uninstall:
948    keep: false
949  waitReady: true
950  waitTimeout: 120s
951"#;
952        let pack: Pack = serde_yaml::from_str(yaml).unwrap();
953
954        assert!(!pack.crds.install);
955        assert!(pack.crds.upgrade.enabled);
956        assert_eq!(pack.crds.upgrade.strategy, CrdUpgradeStrategy::Force);
957        assert!(!pack.crds.uninstall.keep);
958        assert!(pack.crds.wait_ready);
959        assert_eq!(pack.crds.wait_timeout, Duration::from_secs(120));
960    }
961
962    #[test]
963    fn test_crd_upgrade_strategy_serialization() {
964        assert_eq!(
965            serde_yaml::to_string(&CrdUpgradeStrategy::Safe)
966                .unwrap()
967                .trim(),
968            "safe"
969        );
970        assert_eq!(
971            serde_yaml::to_string(&CrdUpgradeStrategy::Force)
972                .unwrap()
973                .trim(),
974            "force"
975        );
976        assert_eq!(
977            serde_yaml::to_string(&CrdUpgradeStrategy::Skip)
978                .unwrap()
979                .trim(),
980            "skip"
981        );
982    }
983
984    #[test]
985    fn test_crd_manifest() {
986        let manifest = CrdManifest {
987            name: "myresources.example.com".to_string(),
988            source_file: PathBuf::from("crds/myresource.yaml"),
989            document_index: 0,
990            content: "apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition"
991                .to_string(),
992            is_templated: false,
993        };
994
995        assert_eq!(manifest.name, "myresources.example.com");
996        assert_eq!(manifest.source_file, PathBuf::from("crds/myresource.yaml"));
997        assert!(!manifest.is_templated);
998    }
999
1000    #[test]
1001    fn test_contains_jinja_syntax() {
1002        assert!(contains_jinja_syntax("{{ values.name }}"));
1003        assert!(contains_jinja_syntax("{% if condition %}"));
1004        assert!(contains_jinja_syntax("{# comment #}"));
1005        assert!(!contains_jinja_syntax("plain: yaml"));
1006        assert!(!contains_jinja_syntax("name: test"));
1007    }
1008
1009    #[test]
1010    fn test_crd_manifest_templated() {
1011        let manifest = CrdManifest {
1012            name: "templated-crd-0".to_string(),
1013            source_file: PathBuf::from("crds/dynamic-crd.yaml"),
1014            document_index: 0,
1015            content: "name: {{ values.crdName }}".to_string(),
1016            is_templated: true,
1017        };
1018
1019        assert!(manifest.is_templated);
1020    }
1021}