1use semver::Version;
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use std::time::Duration;
7
8use crate::error::{CoreError, Result};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct Pack {
14 pub api_version: String,
16
17 #[serde(default)]
19 pub kind: PackKind,
20
21 pub metadata: PackMetadata,
23
24 #[serde(default)]
26 pub dependencies: Vec<Dependency>,
27
28 #[serde(default)]
30 pub engine: EngineConfig,
31
32 #[serde(default)]
34 pub crds: CrdConfig,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "camelCase")]
40pub struct CrdConfig {
41 #[serde(default = "default_true")]
43 pub install: bool,
44
45 #[serde(default)]
47 pub upgrade: CrdUpgradeConfig,
48
49 #[serde(default)]
51 pub uninstall: CrdUninstallConfig,
52
53 #[serde(default = "default_true")]
55 pub wait_ready: bool,
56
57 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum CrdUpgradeStrategy {
82 #[default]
84 Safe,
85 Force,
87 Skip,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct CrdUpgradeConfig {
95 #[serde(default = "default_true")]
97 pub enabled: bool,
98
99 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct CrdUninstallConfig {
117 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
131#[serde(rename_all = "lowercase")]
132pub enum PackKind {
133 #[default]
134 Application,
135 Library,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct PackMetadata {
142 pub name: String,
144
145 #[serde(with = "version_serde")]
147 pub version: Version,
148
149 #[serde(default)]
151 pub description: Option<String>,
152
153 #[serde(default)]
155 pub app_version: Option<String>,
156
157 #[serde(default)]
159 pub kube_version: Option<String>,
160
161 #[serde(default)]
163 pub home: Option<String>,
164
165 #[serde(default)]
167 pub icon: Option<String>,
168
169 #[serde(default)]
171 pub sources: Vec<String>,
172
173 #[serde(default)]
175 pub keywords: Vec<String>,
176
177 #[serde(default)]
179 pub maintainers: Vec<Maintainer>,
180
181 #[serde(default)]
183 pub annotations: std::collections::HashMap<String, String>,
184}
185
186#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "kebab-case")]
201pub enum ResolvePolicy {
202 Always,
204
205 #[default]
210 WhenEnabled,
211
212 Never,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct Dependency {
222 pub name: String,
224
225 pub version: String,
227
228 pub repository: String,
230
231 #[serde(default = "default_true")]
237 pub enabled: bool,
238
239 #[serde(default)]
247 pub condition: Option<String>,
248
249 #[serde(default)]
254 pub resolve: ResolvePolicy,
255
256 #[serde(default)]
258 pub tags: Vec<String>,
259
260 #[serde(default)]
262 pub alias: Option<String>,
263}
264
265impl Dependency {
266 #[inline]
268 pub fn effective_name(&self) -> &str {
269 self.alias.as_deref().unwrap_or(&self.name)
270 }
271
272 pub fn should_resolve(&self, values: &serde_json::Value) -> bool {
279 if !self.enabled {
281 return false;
282 }
283
284 match self.resolve {
285 ResolvePolicy::Always => true,
286 ResolvePolicy::Never => false,
287 ResolvePolicy::WhenEnabled => {
288 let Some(condition) = &self.condition else {
290 return true;
291 };
292
293 evaluate_condition(condition, values)
294 }
295 }
296 }
297}
298
299fn 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, }
312 }
313
314 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#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct EngineConfig {
328 #[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#[derive(Debug, Clone)]
345pub struct LoadedPack {
346 pub pack: Pack,
348
349 pub root: PathBuf,
351
352 pub templates_dir: PathBuf,
354
355 pub crds_dir: Option<PathBuf>,
357
358 pub values_path: PathBuf,
360
361 pub schema_path: Option<PathBuf>,
363}
364
365impl LoadedPack {
366 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 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 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 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 fn find_schema_file(root: &Path) -> Option<PathBuf> {
423 let candidates = [
424 "values.schema.yaml", "values.schema.json", "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 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 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 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 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 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 files.sort();
507 Ok(files)
508 }
509
510 pub fn has_crds(&self) -> bool {
512 self.crds_dir.is_some()
513 }
514
515 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 let file_is_templated = contains_jinja_syntax(&content);
532
533 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 let is_templated = file_is_templated || contains_jinja_syntax(doc);
547
548 if !is_templated {
549 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 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 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 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 pub fn has_templated_crds(&self) -> Result<bool> {
614 Ok(self.load_crds()?.iter().any(|c| c.is_templated))
615 }
616}
617
618#[derive(Debug, Clone)]
620pub struct CrdManifest {
621 pub name: String,
623 pub source_file: PathBuf,
625 pub document_index: usize,
627 pub content: String,
629 pub is_templated: bool,
631}
632
633fn contains_jinja_syntax(content: &str) -> bool {
635 content.contains("{{") || content.contains("{%") || content.contains("{#")
636}
637
638mod 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); assert_eq!(dep.resolve, ResolvePolicy::WhenEnabled); 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 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 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 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!({}))); }
901
902 #[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 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}