1use super::manifest::AppInstallStrategy;
2use crate::config::Config;
3use crate::env::EnvVarSpec;
4use crate::platform::{OperatingSystem, current_platform};
5use crate::presets;
6use anyhow::{Context, Result, bail};
7use serde::Deserialize;
8#[cfg(test)]
9use std::collections::BTreeMap;
10use std::collections::BTreeSet;
11use std::path::{Component, Path, PathBuf};
12use tokio::fs;
13
14use crate::preset_validation::PresetValidationFailure;
15
16#[derive(Debug, Clone)]
17pub struct AppCategory {
18 pub name: String,
19 pub description: Option<String>,
20 pub destination_root: Option<String>,
21 pub files: Vec<AppFile>,
22 pub list_mode: AppListMode,
23 pub post_upgrade: Vec<AppHook>,
24 pub post_install: Vec<AppHook>,
28 #[allow(dead_code)]
31 pub uses_metadata: bool,
32 pub has_explicit_files: bool,
35 pub artifact: Option<AppArtifact>,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum AppListMode {
40 Category,
41 Files,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct AppHook {
46 pub command: String,
47 pub args: Vec<String>,
48 pub show_output: bool,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum ArtifactRuntime {
56 #[default]
59 Native,
60 Bun,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct AppArtifact {
67 pub script: String,
68 pub teardown: Option<String>,
72 pub runtime: ArtifactRuntime,
75}
76
77#[derive(Debug, Clone)]
78pub struct AppFile {
79 pub source_rel: PathBuf,
80 pub target_rel: PathBuf,
81 pub destination_root: Option<AppDestinationRoot>,
84 pub description: Option<String>,
85 pub display_name: Option<String>,
86 pub legacy_dest_annotation: Option<String>,
87 pub transforms: Vec<String>,
88 pub install_strategy: AppInstallStrategy,
89 pub requires_admin: bool,
90 pub restart_hint: Option<String>,
91 pub generator: Option<AppGenerator>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum AppDestinationRoot {
96 Path(String),
97 DataDir(PathBuf),
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct AppGenerator {
102 pub script: PathBuf,
103 pub runtime: ArtifactRuntime,
104 pub env: Vec<EnvVarSpec>,
105 pub when_env: String,
106 pub auto: bool,
110}
111
112#[derive(Debug, Deserialize)]
113struct CategoryToml {
114 description: Option<String>,
115 dest: DestToml,
116 list_mode: Option<ListModeToml>,
117 post_upgrade: Option<HookSpecToml>,
118 post_install: Option<HookSpecToml>,
119 artifact: Option<ArtifactToml>,
120 files: Option<Vec<FileToml>>,
121}
122
123#[derive(Debug, Clone, Deserialize)]
124struct ArtifactToml {
125 script: String,
126 #[serde(default)]
127 teardown: Option<String>,
128 #[serde(default)]
129 runtime: Option<ArtifactRuntimeToml>,
130}
131
132#[derive(Debug, Clone, Copy, Deserialize)]
133#[serde(rename_all = "kebab-case")]
134enum ArtifactRuntimeToml {
135 Native,
136 Bun,
137}
138
139#[derive(Debug, Clone, Deserialize)]
140#[serde(untagged)]
141enum HookSpecToml {
142 Single(HookToml),
143 Multiple(Vec<HookToml>),
144}
145
146#[derive(Debug, Clone, Deserialize)]
147struct HookToml {
148 command: String,
149 #[serde(default)]
150 args: Vec<String>,
151 #[serde(default)]
152 show_output: bool,
153}
154
155#[derive(Debug, Deserialize)]
156#[serde(untagged)]
157enum DestToml {
158 Single(String),
159 Rooted(RootedDestToml),
160 Platforms(PlatformDestToml),
161}
162
163#[derive(Debug, Deserialize)]
164struct RootedDestToml {
165 base: DestBaseToml,
166 path: String,
167}
168
169#[derive(Debug, Clone, Copy, Deserialize)]
170#[serde(rename_all = "kebab-case")]
171enum DestBaseToml {
172 DataDir,
173}
174
175#[derive(Debug, Deserialize)]
176#[serde(deny_unknown_fields)]
177struct PlatformDestToml {
178 macos: Option<String>,
179 linux: Option<String>,
180 windows: Option<String>,
181 unix: Option<String>,
182}
183
184#[derive(Debug, Clone, Copy, Deserialize)]
185#[serde(rename_all = "kebab-case")]
186enum ListModeToml {
187 Category,
188 Files,
189}
190
191#[derive(Debug, Clone, Copy, Deserialize)]
192#[serde(rename_all = "kebab-case")]
193enum InstallModeToml {
194 Copy,
195 JsonMerge,
196}
197
198impl From<ListModeToml> for AppListMode {
199 fn from(value: ListModeToml) -> Self {
200 match value {
201 ListModeToml::Category => Self::Category,
202 ListModeToml::Files => Self::Files,
203 }
204 }
205}
206
207#[derive(Debug, Deserialize)]
208struct FileToml {
209 source: String,
210 target: Option<String>,
211 dest: Option<DestToml>,
212 description: Option<String>,
213 display_name: Option<String>,
214 #[serde(default)]
215 platforms: Option<Vec<String>>,
216 #[serde(default)]
217 transform: Option<String>,
218 #[serde(default)]
219 transforms: Option<Vec<String>>,
220 #[serde(default)]
221 install_mode: Option<InstallModeToml>,
222 #[serde(default)]
223 managed_keys: Option<Vec<String>>,
224 #[serde(default)]
225 requires_admin: bool,
226 restart_hint: Option<String>,
227 generator: Option<GeneratorToml>,
228}
229
230#[derive(Debug, Clone, Deserialize)]
231struct GeneratorToml {
232 script: String,
233 #[serde(default)]
234 runtime: Option<ArtifactRuntimeToml>,
235 #[serde(default)]
236 env: Vec<String>,
237 when_env: String,
238 #[serde(default = "default_true")]
239 auto: bool,
240}
241
242fn default_true() -> bool {
243 true
244}
245
246fn resolve_transforms(file: &FileToml, context: &str) -> Result<Vec<String>> {
247 let specs = match (&file.transform, &file.transforms) {
248 (Some(_), Some(_)) => {
249 bail!("{context}: use 'transform' or 'transforms', not both")
250 }
251 (Some(t), None) => vec![t.clone()],
252 (None, Some(ts)) => ts.clone(),
253 (None, None) => vec![],
254 };
255 super::transforms::validate(&specs).with_context(|| format!("{context}: invalid transform"))?;
256 Ok(specs)
257}
258
259fn resolve_install_strategy(file: &FileToml, context: &str) -> Result<AppInstallStrategy> {
260 match file.install_mode.unwrap_or(InstallModeToml::Copy) {
261 InstallModeToml::Copy => {
262 if file.managed_keys.is_some() {
263 bail!("{context}: 'managed_keys' requires install_mode = \"json-merge\"");
264 }
265 Ok(AppInstallStrategy::Copy)
266 }
267 InstallModeToml::JsonMerge => {
268 let managed_keys = file
269 .managed_keys
270 .clone()
271 .ok_or_else(|| anyhow::anyhow!("{context}: json-merge requires 'managed_keys'"))?;
272 if managed_keys.is_empty() {
273 bail!("{context}: managed_keys must not be empty");
274 }
275 for key in &managed_keys {
276 if key.trim().is_empty() {
277 bail!("{context}: managed_keys must not contain empty keys");
278 }
279 if key.contains('.') {
280 bail!("{context}: managed_keys must be top-level JSON keys");
281 }
282 }
283 Ok(AppInstallStrategy::JsonMerge { managed_keys })
284 }
285 }
286}
287
288fn resolve_hooks(hook: Option<HookSpecToml>, field: &str, context: &str) -> Result<Vec<AppHook>> {
289 let Some(hook) = hook else {
290 return Ok(Vec::new());
291 };
292 let hooks = match hook {
293 HookSpecToml::Single(hook) => vec![hook],
294 HookSpecToml::Multiple(hooks) => hooks,
295 };
296 if hooks.is_empty() {
297 bail!("{context}: {field} must not be empty");
298 }
299 let mut resolved = Vec::with_capacity(hooks.len());
300 for hook in hooks {
301 if hook.command.trim().is_empty() {
302 bail!("{context}: {field}.command must not be empty");
303 }
304 resolved.push(AppHook {
305 command: hook.command,
306 args: hook.args,
307 show_output: hook.show_output,
308 });
309 }
310 Ok(resolved)
311}
312
313fn resolve_artifact(artifact: Option<ArtifactToml>, context: &str) -> Result<Option<AppArtifact>> {
314 let Some(artifact) = artifact else {
315 return Ok(None);
316 };
317 if artifact.script.trim().is_empty() {
318 bail!("{context}: artifact.script must not be empty");
319 }
320 if let Some(teardown) = &artifact.teardown
321 && teardown.trim().is_empty()
322 {
323 bail!("{context}: artifact.teardown must not be empty");
324 }
325 let runtime = match artifact.runtime.unwrap_or(ArtifactRuntimeToml::Native) {
326 ArtifactRuntimeToml::Native => ArtifactRuntime::Native,
327 ArtifactRuntimeToml::Bun => {
328 for name in
331 std::iter::once(artifact.script.as_str()).chain(artifact.teardown.as_deref())
332 {
333 if !has_bun_extension(name) {
334 bail!(
335 "{context}: artifact runtime = \"bun\" requires a .ts/.js/.mts/.mjs script, got '{name}'"
336 );
337 }
338 }
339 ArtifactRuntime::Bun
340 }
341 };
342 Ok(Some(AppArtifact {
343 script: artifact.script,
344 teardown: artifact.teardown,
345 runtime,
346 }))
347}
348
349fn resolve_generator(
350 generator: Option<GeneratorToml>,
351 context: &str,
352) -> Result<Option<AppGenerator>> {
353 let Some(generator) = generator else {
354 return Ok(None);
355 };
356 let script = normalize_relative(&generator.script)
357 .with_context(|| format!("{context}: invalid generator.script"))?;
358 let runtime = match generator.runtime.unwrap_or(ArtifactRuntimeToml::Native) {
359 ArtifactRuntimeToml::Native => ArtifactRuntime::Native,
360 ArtifactRuntimeToml::Bun => {
361 if !has_bun_extension(&generator.script) {
362 bail!(
363 "{context}: generator runtime = \"bun\" requires a .ts/.js/.mts/.mjs script, got '{}'",
364 generator.script
365 );
366 }
367 ArtifactRuntime::Bun
368 }
369 };
370 let env = crate::env::parse_env_specs(&generator.env)
371 .with_context(|| format!("{context}: invalid generator.env"))?;
372 crate::env::validate_env_key(&generator.when_env)
373 .with_context(|| format!("{context}: invalid generator.when_env"))?;
374 if !env.iter().any(|spec| spec.source == generator.when_env) {
375 bail!(
376 "{context}: generator.when_env '{}' must be declared in generator.env",
377 generator.when_env
378 );
379 }
380 Ok(Some(AppGenerator {
381 script,
382 runtime,
383 env,
384 when_env: generator.when_env,
385 auto: generator.auto,
386 }))
387}
388
389fn has_bun_extension(name: &str) -> bool {
390 matches!(
391 Path::new(name).extension().and_then(|e| e.to_str()),
392 Some("ts" | "js" | "mts" | "mjs")
393 )
394}
395
396fn default_list_mode(has_explicit_files: bool) -> AppListMode {
397 if has_explicit_files {
398 AppListMode::Files
399 } else {
400 AppListMode::Category
401 }
402}
403
404pub fn load_embedded_categories(filter: Option<&str>) -> Result<Vec<AppCategory>> {
405 let filter = filter.map(str::to_string);
406 let names = collect_embedded_category_names(filter.as_deref());
407 let mut categories = Vec::new();
408
409 for name in names {
410 if let Some(category) = load_embedded_category(&name)? {
411 categories.push(category);
412 }
413 }
414
415 Ok(categories)
416}
417
418pub async fn load_installed_categories(
419 config: &Config,
420 filter: Option<&str>,
421) -> Result<Vec<AppCategory>> {
422 let app_root = config.presets_dir().join("app");
423 let mut category_names: BTreeSet<String> = collect_fs_category_names(&app_root, filter)
424 .await?
425 .into_iter()
426 .collect();
427 if let Some(overlay) = config.active_presets_overlay_dir() {
428 category_names.extend(collect_fs_category_names(&overlay.join("app"), filter).await?);
429 }
430 if let Some(filter) = filter
431 && category_names.is_empty()
432 {
433 bail!("app preset category not found: {filter}");
434 }
435 let mut categories = Vec::new();
436
437 for name in category_names {
438 if let Some(category) = load_installed_category(config, &name).await? {
439 categories.push(category);
440 }
441 }
442
443 Ok(categories)
444}
445
446pub async fn load_active_categories(
451 config: &Config,
452 filter: Option<&str>,
453) -> Result<Vec<AppCategory>> {
454 if config.is_external_presets {
455 load_installed_categories(config, filter).await
456 } else {
457 load_embedded_categories(filter)
458 }
459}
460
461fn load_embedded_category(name: &str) -> Result<Option<AppCategory>> {
462 let metadata_path = format!("app/{name}/shine.toml");
463 if let Some(bytes) = presets::read_asset_bytes(&metadata_path) {
464 let parsed = parse_category_toml(name, &bytes)?;
465 let has_explicit_files = parsed.files.is_some();
466 let post_upgrade = resolve_hooks(parsed.post_upgrade, "post_upgrade", &metadata_path)?;
467 let post_install = resolve_hooks(parsed.post_install, "post_install", &metadata_path)?;
468 let artifact = resolve_artifact(parsed.artifact, &metadata_path)?;
469 let Some(dest_root) = parsed.dest.select_for_current_platform(name)? else {
470 return Ok(None);
471 };
472 let files = match parsed.files {
473 Some(files) => {
474 let mut filtered = Vec::new();
475 for file in files {
476 if file_matches_current_platform(name, &file)?
477 && file_destination_matches_current_platform(name, &file)?
478 {
479 filtered.push(file);
480 }
481 }
482 filtered
483 .into_iter()
484 .map(|file| {
485 let context = format!("app/{name}/shine.toml");
486 let source_rel = normalize_relative(&file.source)
487 .with_context(|| format!("invalid source for {context}"))?;
488 let target_rel =
489 normalize_relative(file.target.as_deref().unwrap_or(&file.source))
490 .with_context(|| format!("invalid target for {context}"))?;
491 let transforms = resolve_transforms(&file, &context)?;
492 let install_strategy = resolve_install_strategy(&file, &context)?;
493 let generator = resolve_generator(file.generator.clone(), &context)?;
494 let destination_root = selected_file_destination(name, &file)?;
495 Ok(AppFile {
496 source_rel,
497 target_rel,
498 destination_root,
499 description: file.description,
500 display_name: file.display_name,
501 legacy_dest_annotation: None,
502 transforms,
503 install_strategy,
504 requires_admin: file.requires_admin,
505 restart_hint: file.restart_hint,
506 generator,
507 })
508 })
509 .collect::<Result<Vec<_>>>()?
510 }
511 None => collect_embedded_files(name)?
512 .into_iter()
513 .map(|rel| AppFile {
514 source_rel: rel.clone(),
515 target_rel: rel,
516 destination_root: None,
517 description: None,
518 display_name: None,
519 legacy_dest_annotation: None,
520 transforms: vec![],
521 install_strategy: AppInstallStrategy::Copy,
522 requires_admin: false,
523 restart_hint: None,
524 generator: None,
525 })
526 .collect(),
527 };
528 if files.is_empty() {
529 return Ok(None);
530 }
531
532 return Ok(Some(AppCategory {
533 name: name.to_string(),
534 description: parsed.description,
535 destination_root: Some(dest_root),
536 files,
537 list_mode: parsed
538 .list_mode
539 .map(Into::into)
540 .unwrap_or_else(|| default_list_mode(has_explicit_files)),
541 post_upgrade,
542 post_install,
543 uses_metadata: true,
544 has_explicit_files,
545 artifact,
546 }));
547 }
548
549 Ok(Some(AppCategory {
550 name: name.to_string(),
551 description: None,
552 destination_root: None,
553 files: collect_embedded_files(name)?
554 .into_iter()
555 .map(|rel| {
556 let asset_path = format!("app/{name}/{}", rel.to_string_lossy());
557 let bytes = presets::read_asset_bytes(&asset_path).unwrap_or_default();
558 AppFile {
559 source_rel: rel.clone(),
560 target_rel: rel,
561 destination_root: None,
562 description: parse_legacy_description(&bytes),
563 display_name: None,
564 legacy_dest_annotation: presets::parse_dest_annotation(&bytes),
565 transforms: vec![],
566 install_strategy: AppInstallStrategy::Copy,
567 requires_admin: false,
568 restart_hint: None,
569 generator: None,
570 }
571 })
572 .collect(),
573 list_mode: AppListMode::Category,
574 post_upgrade: Vec::new(),
575 post_install: Vec::new(),
576 uses_metadata: false,
577 has_explicit_files: false,
578 artifact: None,
579 }))
580}
581
582async fn load_installed_category(config: &Config, name: &str) -> Result<Option<AppCategory>> {
583 let category_rel = Path::new("app").join(name);
584 let metadata_path = config.preset_path(category_rel.join("shine.toml"));
585
586 if metadata_path.exists() {
587 let bytes = fs::read(&metadata_path)
588 .await
589 .with_context(|| format!("reading metadata: {}", metadata_path.display()))?;
590 let parsed = parse_category_toml(name, &bytes)?;
591 let has_explicit_files = parsed.files.is_some();
592 let post_upgrade = resolve_hooks(
593 parsed.post_upgrade,
594 "post_upgrade",
595 &metadata_path.display().to_string(),
596 )?;
597 let post_install = resolve_hooks(
598 parsed.post_install,
599 "post_install",
600 &metadata_path.display().to_string(),
601 )?;
602 let artifact = resolve_artifact(parsed.artifact, &metadata_path.display().to_string())?;
603 let Some(dest_root) = parsed.dest.select_for_current_platform(name)? else {
604 return Ok(None);
605 };
606 let files = match parsed.files {
607 Some(files) => {
608 let mut filtered = Vec::new();
609 for file in files {
610 if file_matches_current_platform(name, &file)?
611 && file_destination_matches_current_platform(name, &file)?
612 {
613 filtered.push(file);
614 }
615 }
616 filtered
617 .into_iter()
618 .map(|file| {
619 let context = metadata_path.display().to_string();
620 let source_rel = normalize_relative(&file.source)
621 .with_context(|| format!("invalid source for {context}"))?;
622 let target_rel =
623 normalize_relative(file.target.as_deref().unwrap_or(&file.source))
624 .with_context(|| format!("invalid target for {context}"))?;
625 let transforms = resolve_transforms(&file, &context)?;
626 let install_strategy = resolve_install_strategy(&file, &context)?;
627 let generator = resolve_generator(file.generator.clone(), &context)?;
628 let destination_root = selected_file_destination(name, &file)?;
629 Ok(AppFile {
630 source_rel,
631 target_rel,
632 destination_root,
633 description: file.description,
634 display_name: file.display_name,
635 legacy_dest_annotation: None,
636 transforms,
637 install_strategy,
638 requires_admin: file.requires_admin,
639 restart_hint: file.restart_hint,
640 generator,
641 })
642 })
643 .collect::<Result<Vec<_>>>()?
644 }
645 None => collect_merged_fs_files(config, &category_rel)
646 .await?
647 .into_iter()
648 .map(|rel| AppFile {
649 source_rel: rel.clone(),
650 target_rel: rel,
651 destination_root: None,
652 description: None,
653 display_name: None,
654 legacy_dest_annotation: None,
655 transforms: vec![],
656 install_strategy: AppInstallStrategy::Copy,
657 requires_admin: false,
658 restart_hint: None,
659 generator: None,
660 })
661 .collect(),
662 };
663 if files.is_empty() {
664 return Ok(None);
665 }
666
667 for file in &files {
668 let source_path = config.preset_path(category_rel.join(&file.source_rel));
669 if !source_path.exists() {
670 bail!(
671 "app/{name}/shine.toml references missing file: {}",
672 file.source_rel.display()
673 );
674 }
675 if let Some(generator) = &file.generator {
676 let script_path = config.preset_path(category_rel.join(&generator.script));
677 if !script_path.exists() {
678 bail!(
679 "app/{name}/shine.toml references missing generator script: {}",
680 generator.script.display()
681 );
682 }
683 }
684 }
685
686 return Ok(Some(AppCategory {
687 name: name.to_string(),
688 description: parsed.description,
689 destination_root: Some(dest_root),
690 files,
691 list_mode: parsed
692 .list_mode
693 .map(Into::into)
694 .unwrap_or_else(|| default_list_mode(has_explicit_files)),
695 post_upgrade,
696 post_install,
697 uses_metadata: true,
698 has_explicit_files,
699 artifact,
700 }));
701 }
702
703 let mut files = Vec::new();
704 for rel in collect_merged_fs_files(config, &category_rel).await? {
705 let source_path = config.preset_path(category_rel.join(&rel));
706 let bytes = fs::read(&source_path)
707 .await
708 .with_context(|| format!("reading preset file: {}", source_path.display()))?;
709 files.push(AppFile {
710 source_rel: rel.clone(),
711 target_rel: rel,
712 destination_root: None,
713 description: parse_legacy_description(&bytes),
714 display_name: None,
715 legacy_dest_annotation: presets::parse_dest_annotation(&bytes),
716 transforms: vec![],
717 install_strategy: AppInstallStrategy::Copy,
718 requires_admin: false,
719 restart_hint: None,
720 generator: None,
721 });
722 }
723
724 Ok(Some(AppCategory {
725 name: name.to_string(),
726 description: None,
727 destination_root: None,
728 files,
729 list_mode: AppListMode::Category,
730 post_upgrade: Vec::new(),
731 post_install: Vec::new(),
732 uses_metadata: false,
733 has_explicit_files: false,
734 artifact: None,
735 }))
736}
737
738async fn collect_merged_fs_files(config: &Config, category_rel: &Path) -> Result<Vec<PathBuf>> {
739 crate::preset_meta::merge_fs_tree(config, category_rel, "preset category", |rel| {
740 if rel == Path::new("shine.toml") {
741 return Ok(None);
742 }
743 Ok(Some(normalize_relative(&rel.to_string_lossy())?))
744 })
745 .await
746}
747
748fn collect_embedded_category_names(filter: Option<&str>) -> Vec<String> {
749 crate::preset_meta::collect_embedded_category_names("app", filter)
750}
751
752async fn collect_fs_category_names(app_root: &Path, filter: Option<&str>) -> Result<Vec<String>> {
753 crate::preset_meta::collect_fs_category_names(app_root, filter, "app presets dir").await
754}
755
756fn collect_embedded_files(category: &str) -> Result<Vec<PathBuf>> {
757 let prefix = format!("app/{category}/");
758 let mut files = Vec::new();
759
760 for asset_path in presets::asset_paths(&prefix) {
761 let Some(rel) = asset_path.strip_prefix(&prefix) else {
762 continue;
763 };
764 if rel.is_empty() || rel == "shine.toml" {
765 continue;
766 }
767 files.push(normalize_relative(rel)?);
768 }
769
770 files.sort();
771 Ok(files)
772}
773
774fn parse_category_toml(name: &str, bytes: &[u8]) -> Result<CategoryToml> {
775 let parsed: CategoryToml = toml::from_slice(bytes)
776 .with_context(|| format!("failed to parse app/{name}/shine.toml"))?;
777
778 parsed.dest.validate_category(name)?;
779 if let Some(files) = &parsed.files {
780 for file in files {
781 file_matches_current_platform(name, file)?;
782 if let Some(dest) = &file.dest {
783 dest.validate_file(name)?;
784 }
785 if let Some(AppDestinationRoot::Path(dest)) = selected_file_destination(name, file)? {
786 validate_dest(name, &dest)?;
787 }
788 let context = format!("app/{name}/shine.toml");
789 resolve_transforms(file, &context)?;
790 resolve_install_strategy(file, &context)?;
791 resolve_generator(file.generator.clone(), &context)?;
792 }
793 }
794 resolve_hooks(
795 parsed.post_upgrade.clone(),
796 "post_upgrade",
797 &format!("app/{name}/shine.toml"),
798 )?;
799 resolve_hooks(
800 parsed.post_install.clone(),
801 "post_install",
802 &format!("app/{name}/shine.toml"),
803 )?;
804 resolve_artifact(parsed.artifact.clone(), &format!("app/{name}/shine.toml"))?;
805 Ok(parsed)
806}
807
808fn validate_dest(name: &str, dest: &str) -> Result<()> {
809 validate_dest_for_platform(name, dest, None)
810}
811
812fn validate_dest_for_platform(
813 name: &str,
814 dest: &str,
815 platform: Option<OperatingSystem>,
816) -> Result<()> {
817 let expanded = crate::config::full_expand(dest)
818 .with_context(|| format!("failed to expand dest in app/{name}/shine.toml"))?;
819 let home_relative = dest == "~" || dest.starts_with("~/") || dest.starts_with("~\\");
820 let unix_absolute = expanded.starts_with('/');
821 let bytes = expanded.as_bytes();
822 let windows_drive_absolute = bytes.len() >= 3
823 && bytes[0].is_ascii_alphabetic()
824 && bytes[1] == b':'
825 && matches!(bytes[2], b'/' | b'\\');
826 let windows_unc_absolute = expanded.starts_with("\\\\") || expanded.starts_with("//");
827 let windows_absolute = windows_drive_absolute || windows_unc_absolute;
828 let is_absolute = home_relative
829 || match platform {
830 Some(OperatingSystem::Macos | OperatingSystem::Linux) => unix_absolute,
831 Some(OperatingSystem::Windows) => windows_absolute,
832 None => Path::new(&expanded).is_absolute() || unix_absolute || windows_absolute,
833 };
834 if !is_absolute {
835 bail!("app/{name}/shine.toml dest must be absolute after expansion");
836 }
837 if expanded
838 .split(['/', '\\'])
839 .any(|component| component == "..")
840 {
841 bail!("app/{name}/shine.toml dest must not contain '..'");
842 }
843 Ok(())
844}
845
846impl DestToml {
847 fn validate_category(&self, category: &str) -> Result<()> {
848 match self {
849 Self::Single(dest) => validate_dest(category, dest),
850 Self::Rooted(_) => bail!(
851 "app/{category}/shine.toml rooted destinations are supported only in [[files]]"
852 ),
853 Self::Platforms(dest) => dest.validate(category),
854 }
855 }
856
857 fn validate_file(&self, category: &str) -> Result<()> {
858 match self {
859 Self::Single(dest) => validate_dest(category, dest),
860 Self::Rooted(dest) => dest.resolve(category).map(|_| ()),
861 Self::Platforms(dest) => dest.validate(category),
862 }
863 }
864
865 fn select_for_current_platform(&self, category: &str) -> Result<Option<String>> {
866 self.select_for_platform(category, current_platform())
867 }
868
869 fn select_for_platform(
870 &self,
871 category: &str,
872 current: OperatingSystem,
873 ) -> Result<Option<String>> {
874 match self {
875 Self::Single(dest) => Ok(Some(dest.clone())),
876 Self::Rooted(_) => bail!(
877 "app/{category}/shine.toml rooted destinations are supported only in [[files]]"
878 ),
879 Self::Platforms(dest) => dest.select_for_platform(category, current),
880 }
881 }
882
883 fn select_file_for_current_platform(
884 &self,
885 category: &str,
886 ) -> Result<Option<AppDestinationRoot>> {
887 self.select_file_for_platform(category, current_platform())
888 }
889
890 fn select_file_for_platform(
891 &self,
892 category: &str,
893 platform: OperatingSystem,
894 ) -> Result<Option<AppDestinationRoot>> {
895 match self {
896 Self::Single(dest) => Ok(Some(AppDestinationRoot::Path(dest.clone()))),
897 Self::Rooted(dest) => Ok(Some(dest.resolve(category)?)),
898 Self::Platforms(dest) => Ok(dest
899 .select_for_platform(category, platform)?
900 .map(AppDestinationRoot::Path)),
901 }
902 }
903}
904
905impl RootedDestToml {
906 fn resolve(&self, category: &str) -> Result<AppDestinationRoot> {
907 let relative = normalize_relative(&self.path)
908 .with_context(|| format!("invalid rooted dest path in app/{category}/shine.toml"))?;
909 Ok(match self.base {
910 DestBaseToml::DataDir => AppDestinationRoot::DataDir(relative),
911 })
912 }
913}
914
915fn selected_file_destination(
916 category: &str,
917 file: &FileToml,
918) -> Result<Option<AppDestinationRoot>> {
919 file.dest
920 .as_ref()
921 .map(|dest| dest.select_file_for_current_platform(category))
922 .transpose()
923 .map(Option::flatten)
924}
925
926fn file_destination_matches_current_platform(category: &str, file: &FileToml) -> Result<bool> {
927 match &file.dest {
928 None => Ok(true),
929 Some(dest) => Ok(dest.select_file_for_current_platform(category)?.is_some()),
930 }
931}
932
933impl PlatformDestToml {
934 fn validate(&self, category: &str) -> Result<()> {
935 let destinations = [&self.macos, &self.linux, &self.windows, &self.unix];
936 if destinations.iter().all(|dest| dest.is_none()) {
937 bail!("app/{category}/shine.toml platform destination map must not be empty");
938 }
939 for (dest, platform) in [
940 (&self.macos, OperatingSystem::Macos),
941 (&self.linux, OperatingSystem::Linux),
942 (&self.windows, OperatingSystem::Windows),
943 (&self.unix, OperatingSystem::Linux),
944 ] {
945 if let Some(dest) = dest {
946 validate_dest_for_platform(category, dest, Some(platform))?;
947 }
948 }
949 Ok(())
950 }
951
952 fn select_for_platform(
953 &self,
954 _category: &str,
955 current: OperatingSystem,
956 ) -> Result<Option<String>> {
957 Ok(match current {
958 OperatingSystem::Macos => self.macos.clone().or_else(|| self.unix.clone()),
959 OperatingSystem::Linux => self.linux.clone().or_else(|| self.unix.clone()),
960 OperatingSystem::Windows => self.windows.clone(),
961 })
962 }
963}
964
965fn file_matches_current_platform(category: &str, file: &FileToml) -> Result<bool> {
966 file_matches_platform(category, file, current_platform())
967}
968
969fn file_matches_platform(
970 category: &str,
971 file: &FileToml,
972 current: OperatingSystem,
973) -> Result<bool> {
974 crate::preset_meta::platform_matches(
975 file.platforms.as_deref(),
976 current,
977 &format!("app/{category}/shine.toml"),
978 )
979}
980
981#[cfg(test)]
982pub(crate) fn built_in_platform_availability() -> Result<BTreeMap<String, BTreeSet<OperatingSystem>>>
983{
984 let mut capabilities = BTreeMap::new();
985 for name in crate::preset_meta::collect_pristine_embedded_category_names("app") {
986 let metadata_path = format!("app/{name}/shine.toml");
987 let Some(bytes) = presets::read_embedded_asset_bytes(&metadata_path) else {
988 capabilities.insert(
989 format!("app/{name}"),
990 OperatingSystem::ALL.into_iter().collect(),
991 );
992 continue;
993 };
994 let parsed = parse_category_toml(&name, &bytes)?;
995 let mut platforms = BTreeSet::new();
996 for platform in OperatingSystem::ALL {
997 if parsed.dest.select_for_platform(&name, platform)?.is_none() {
998 continue;
999 }
1000 let has_file = if let Some(files) = &parsed.files {
1001 let mut has_file = false;
1002 for file in files {
1003 if !file_matches_platform(&name, file, platform)? {
1004 continue;
1005 }
1006 if let Some(dest) = &file.dest
1007 && dest.select_file_for_platform(&name, platform)?.is_none()
1008 {
1009 continue;
1010 }
1011 has_file = true;
1012 break;
1013 }
1014 has_file
1015 } else {
1016 true
1017 };
1018 if has_file {
1019 platforms.insert(platform);
1020 }
1021 }
1022 capabilities.insert(format!("app/{name}"), platforms);
1023 }
1024 Ok(capabilities)
1025}
1026
1027fn normalize_relative(path: &str) -> Result<PathBuf> {
1028 let path = Path::new(path);
1029 if path.as_os_str().is_empty() {
1030 bail!("path must not be empty");
1031 }
1032 if path.is_absolute() {
1033 bail!("path must be relative");
1034 }
1035 if path.components().any(|c| matches!(c, Component::ParentDir)) {
1036 bail!("path must not contain '..'");
1037 }
1038 Ok(path.to_path_buf())
1039}
1040
1041pub(crate) fn validate_preset_category(
1045 name: &str,
1046 root: &Path,
1047) -> std::result::Result<bool, PresetValidationFailure> {
1048 let manifest_path = root.join("shine.toml");
1049 if !manifest_path.is_file() {
1050 ensure_category_has_files(root, "app")?;
1051 return Ok(false);
1052 }
1053
1054 let bytes = std::fs::read(&manifest_path).map_err(|error| {
1055 PresetValidationFailure::at(
1056 "read_failed",
1057 format!("cannot read app metadata: {error}"),
1058 &manifest_path,
1059 )
1060 })?;
1061 let parsed: CategoryToml = toml::from_slice(&bytes).map_err(|error| {
1062 PresetValidationFailure::at(
1063 "invalid_metadata",
1064 format!("failed to parse app/{name}/shine.toml: {error}"),
1065 &manifest_path,
1066 )
1067 })?;
1068 let context = format!("app/{name}/shine.toml");
1069
1070 parsed
1071 .dest
1072 .validate_category(name)
1073 .map_err(|error| invalid_metadata(error, &manifest_path))?;
1074
1075 resolve_hooks(parsed.post_upgrade.clone(), "post_upgrade", &context)
1076 .and_then(|_| resolve_hooks(parsed.post_install.clone(), "post_install", &context))
1077 .map_err(|error| invalid_metadata(error, &manifest_path))?;
1078 let artifact = resolve_artifact(parsed.artifact.clone(), &context)
1079 .map_err(|error| invalid_metadata(error, &manifest_path))?;
1080
1081 let files: &[FileToml] = match &parsed.files {
1082 Some(files) if files.is_empty() => {
1083 return Err(PresetValidationFailure::at(
1084 "invalid_metadata",
1085 format!("{context} files must not be empty"),
1086 &manifest_path,
1087 ));
1088 }
1089 Some(files) => files,
1090 None => {
1091 ensure_category_has_files(root, "app")?;
1092 &[]
1093 }
1094 };
1095
1096 let mut uses_bun = artifact
1097 .as_ref()
1098 .is_some_and(|artifact| artifact.runtime == ArtifactRuntime::Bun);
1099 if let Some(artifact) = &artifact {
1100 validate_reference(root, &artifact.script, "artifact script")?;
1101 if let Some(teardown) = &artifact.teardown {
1102 validate_reference(root, teardown, "artifact teardown script")?;
1103 }
1104 }
1105
1106 for file in files {
1107 let source = normalize_relative(&file.source)
1108 .with_context(|| format!("invalid source for {context}"))
1109 .map_err(|error| invalid_metadata(error, &manifest_path))?;
1110 normalize_relative(file.target.as_deref().unwrap_or(&file.source))
1111 .with_context(|| format!("invalid target for {context}"))
1112 .map_err(|error| invalid_metadata(error, &manifest_path))?;
1113 resolve_transforms(file, &context)
1114 .and_then(|_| resolve_install_strategy(file, &context).map(|_| ()))
1115 .map_err(|error| invalid_metadata(error, &manifest_path))?;
1116 let generator = resolve_generator(file.generator.clone(), &context)
1117 .map_err(|error| invalid_metadata(error, &manifest_path))?;
1118 if let Some(generator) = &generator {
1119 uses_bun |= generator.runtime == ArtifactRuntime::Bun;
1120 validate_reference_path(root, &generator.script, "generator script")?;
1121 }
1122 validate_reference_path(root, &source, "source file")?;
1123 if let Some(dest) = &file.dest {
1124 dest.validate_file(name)
1125 .map_err(|error| invalid_metadata(error, &manifest_path))?;
1126 }
1127 for platform in OperatingSystem::ALL {
1129 file_matches_platform(name, file, platform)
1130 .map_err(|error| invalid_metadata(error, &manifest_path))?;
1131 }
1132 }
1133
1134 for platform in OperatingSystem::ALL {
1135 let Some(category_dest) = parsed
1136 .dest
1137 .select_for_platform(name, platform)
1138 .map_err(|error| invalid_metadata(error, &manifest_path))?
1139 else {
1140 continue;
1141 };
1142 validate_dest_for_platform(name, &category_dest, Some(platform))
1143 .map_err(|error| invalid_metadata(error, &manifest_path))?;
1144 let mut targets = BTreeSet::new();
1145 for file in files {
1146 if !file_matches_platform(name, file, platform)
1147 .map_err(|error| invalid_metadata(error, &manifest_path))?
1148 {
1149 continue;
1150 }
1151 let target = normalize_relative(file.target.as_deref().unwrap_or(&file.source))
1152 .map_err(|error| invalid_metadata(error, &manifest_path))?;
1153 let destination = match &file.dest {
1154 Some(dest) => match dest
1155 .select_file_for_platform(name, platform)
1156 .map_err(|error| invalid_metadata(error, &manifest_path))?
1157 {
1158 Some(destination) => destination,
1159 None => continue,
1160 },
1161 None => AppDestinationRoot::Path(category_dest.clone()),
1162 };
1163 if let AppDestinationRoot::Path(path) = &destination {
1164 validate_dest_for_platform(name, path, Some(platform))
1165 .map_err(|error| invalid_metadata(error, &manifest_path))?;
1166 }
1167 let destination_key = match &destination {
1168 AppDestinationRoot::Path(path) => crate::config::full_expand(path)
1169 .map(|path| format!("path:{path}"))
1170 .map_err(|error| invalid_metadata(anyhow::Error::new(error), &manifest_path))?,
1171 AppDestinationRoot::DataDir(path) => {
1172 format!("data-dir:{}", path.display())
1173 }
1174 };
1175 let target_key = format!("{destination_key}/{}", target.display());
1176 if !targets.insert(target_key) {
1177 return Err(PresetValidationFailure::at(
1178 "duplicate_target",
1179 format!(
1180 "app/{name} declares the same effective destination more than once for {}: {}",
1181 platform.as_str(),
1182 target.display()
1183 ),
1184 &manifest_path,
1185 ));
1186 }
1187 }
1188 }
1189
1190 if uses_bun {
1191 crate::bun_runtime::resolve(root, true).map_err(|error| {
1192 PresetValidationFailure::at("bun_dependency_policy", error.to_string(), root)
1193 })?;
1194 }
1195 Ok(true)
1196}
1197
1198fn invalid_metadata(error: anyhow::Error, path: &Path) -> PresetValidationFailure {
1199 PresetValidationFailure::at("invalid_metadata", error.to_string(), path)
1200}
1201
1202fn validate_reference(
1203 root: &Path,
1204 relative: &str,
1205 label: &str,
1206) -> std::result::Result<(), PresetValidationFailure> {
1207 let relative = normalize_relative(relative).map_err(|error| {
1208 PresetValidationFailure::at(
1209 "invalid_metadata",
1210 format!("invalid {label}: {error}"),
1211 root.join(relative),
1212 )
1213 })?;
1214 validate_reference_path(root, &relative, label)
1215}
1216
1217fn validate_reference_path(
1218 root: &Path,
1219 relative: &Path,
1220 label: &str,
1221) -> std::result::Result<(), PresetValidationFailure> {
1222 let path = root.join(relative);
1223 let canonical = std::fs::canonicalize(&path).map_err(|error| {
1224 PresetValidationFailure::at(
1225 "missing_reference",
1226 format!("{label} is missing or unreadable: {error}"),
1227 &path,
1228 )
1229 })?;
1230 if !canonical.starts_with(root) || !canonical.is_file() {
1231 return Err(PresetValidationFailure::at(
1232 "invalid_reference",
1233 format!("{label} must be a file inside the preset category"),
1234 path,
1235 ));
1236 }
1237 Ok(())
1238}
1239
1240fn ensure_category_has_files(
1241 root: &Path,
1242 kind: &str,
1243) -> std::result::Result<(), PresetValidationFailure> {
1244 let mut pending = vec![root.to_path_buf()];
1245 while let Some(directory) = pending.pop() {
1246 let entries = std::fs::read_dir(&directory).map_err(|error| {
1247 PresetValidationFailure::at(
1248 "read_failed",
1249 format!("cannot read {kind} category: {error}"),
1250 &directory,
1251 )
1252 })?;
1253 for entry in entries {
1254 let entry = entry.map_err(|error| {
1255 PresetValidationFailure::at("read_failed", error.to_string(), &directory)
1256 })?;
1257 let file_type = entry.file_type().map_err(|error| {
1258 PresetValidationFailure::at("read_failed", error.to_string(), entry.path())
1259 })?;
1260 if file_type.is_dir() {
1261 pending.push(entry.path());
1262 } else if file_type.is_file()
1263 && entry.file_name().to_string_lossy().as_ref() != "shine.toml"
1264 {
1265 return Ok(());
1266 }
1267 }
1268 }
1269 Err(PresetValidationFailure::at(
1270 "no_files",
1271 format!("{kind} preset category contains no files"),
1272 root,
1273 ))
1274}
1275
1276fn parse_legacy_description(content: &[u8]) -> Option<String> {
1277 presets::parse_script_description(content)
1284 .into_iter()
1285 .find(|line| !line.trim().is_empty())
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290 use super::*;
1291
1292 async fn write_test_category(root: &Path, name: &str) {
1293 let category = root.join("app").join(name);
1294 fs::create_dir_all(&category).await.unwrap();
1295 fs::write(category.join("shine.toml"), "dest = \"~/.config/test\"\n")
1296 .await
1297 .unwrap();
1298 fs::write(category.join("config.toml"), "test = true\n")
1299 .await
1300 .unwrap();
1301 }
1302
1303 #[tokio::test]
1304 async fn filtered_category_may_exist_in_only_one_merged_presets_root() {
1305 let dir = std::env::temp_dir().join(format!(
1306 "shine-app-metadata-merged-filter-{}",
1307 uuid::Uuid::new_v4()
1308 ));
1309 let overlay = dir.join("overlay");
1310 let mut config = Config::new_for_test(&dir);
1311 config.presets_overlay_dir_override = Some(overlay.clone());
1312
1313 write_test_category(config.presets_dir(), "base-only").await;
1314 write_test_category(&overlay, "overlay-only").await;
1315
1316 let base = load_installed_categories(&config, Some("base-only"))
1317 .await
1318 .unwrap();
1319 let overlaid = load_installed_categories(&config, Some("overlay-only"))
1320 .await
1321 .unwrap();
1322
1323 assert_eq!(base.len(), 1);
1324 assert_eq!(base[0].name, "base-only");
1325 assert_eq!(overlaid.len(), 1);
1326 assert_eq!(overlaid[0].name, "overlay-only");
1327
1328 fs::remove_dir_all(&dir).await.unwrap();
1329 }
1330
1331 #[test]
1332 fn embedded_vim_uses_metadata() {
1333 let categories = load_embedded_categories(Some("vim")).unwrap();
1334 let vim = categories.iter().find(|c| c.name == "vim").unwrap();
1335 assert!(vim.uses_metadata);
1336 assert_eq!(vim.destination_root.as_deref(), Some("~/.vim"));
1337 assert!(!vim.files.is_empty());
1338 }
1339
1340 #[cfg(target_os = "macos")]
1341 #[test]
1342 fn embedded_surge_installs_local_profile_resources() {
1343 let categories = load_embedded_categories(Some("surge")).unwrap();
1344 let surge = categories.iter().find(|c| c.name == "surge").unwrap();
1345 assert!(surge.uses_metadata);
1346 assert_eq!(
1347 surge.destination_root.as_deref(),
1348 Some("~/Library/Application Support/Surge/Profiles")
1349 );
1350 let files: Vec<_> = surge
1351 .files
1352 .iter()
1353 .map(|file| {
1354 (
1355 file.source_rel.display().to_string(),
1356 file.target_rel.display().to_string(),
1357 )
1358 })
1359 .collect();
1360 assert_eq!(
1361 files,
1362 vec![
1363 (
1364 "local-proxies.conf".to_string(),
1365 "local-proxies.conf".to_string()
1366 ),
1367 (
1368 "local-rules.conf".to_string(),
1369 "local-rules.conf".to_string()
1370 ),
1371 ("rules/lan.list".to_string(), "rules/lan.list".to_string()),
1372 (
1373 "rules/lan-socks.list".to_string(),
1374 "rules/lan-socks.list".to_string()
1375 ),
1376 (
1377 "rules/other-direct.list".to_string(),
1378 "rules/other-direct.list".to_string()
1379 ),
1380 (
1381 "local-proxy-groups.conf".to_string(),
1382 "local-proxy-groups.conf".to_string()
1383 ),
1384 (
1385 "subscription-proxies.conf".to_string(),
1386 "subscription-proxies.conf".to_string()
1387 ),
1388 ]
1389 );
1390 let subscription = surge
1391 .files
1392 .iter()
1393 .find(|file| file.source_rel == Path::new("subscription-proxies.conf"))
1394 .unwrap();
1395 assert_eq!(
1396 subscription.generator,
1397 Some(AppGenerator {
1398 script: PathBuf::from("generate-subscription.ts"),
1399 runtime: ArtifactRuntime::Bun,
1400 env: vec![EnvVarSpec {
1401 source: "SURGE_SUBSCRIPTION_URL".to_string(),
1402 target: "SURGE_SUBSCRIPTION_URL".to_string(),
1403 }],
1404 when_env: "SURGE_SUBSCRIPTION_URL".to_string(),
1405 auto: false,
1406 })
1407 );
1408 assert_eq!(
1409 surge.post_upgrade,
1410 vec![AppHook {
1411 command: "/Applications/Surge.app/Contents/Applications/surge-cli".to_string(),
1412 args: vec!["reload".to_string()],
1413 show_output: false,
1414 }]
1415 );
1416 assert_eq!(
1417 surge.artifact,
1418 Some(AppArtifact {
1419 script: "build.ts".to_string(),
1420 teardown: Some("unbuild.ts".to_string()),
1421 runtime: ArtifactRuntime::Bun,
1422 })
1423 );
1424 }
1425
1426 #[cfg(not(target_os = "macos"))]
1427 #[test]
1428 fn embedded_surge_is_unavailable_outside_macos() {
1429 assert!(load_embedded_categories(Some("surge")).unwrap().is_empty());
1430 assert!(
1431 load_embedded_categories(None)
1432 .unwrap()
1433 .iter()
1434 .all(|category| category.name != "surge")
1435 );
1436 }
1437
1438 #[test]
1439 fn post_upgrade_hook_parses_command_and_args() {
1440 let parsed = parse_category_toml(
1441 "sample",
1442 br#"
1443dest = "~/.config/sample"
1444post_upgrade = { command = "/bin/echo", args = ["updated"] }
1445
1446[[files]]
1447source = "config.toml"
1448"#,
1449 )
1450 .unwrap();
1451 let hooks = resolve_hooks(parsed.post_upgrade, "post_upgrade", "sample").unwrap();
1452 assert_eq!(hooks.len(), 1);
1453 assert_eq!(hooks[0].command, "/bin/echo");
1454 assert_eq!(hooks[0].args, vec!["updated"]);
1455 assert!(
1456 !hooks[0].show_output,
1457 "show_output must default to false when omitted"
1458 );
1459 }
1460
1461 #[test]
1462 fn post_upgrade_hook_parses_show_output_flag() {
1463 let parsed = parse_category_toml(
1464 "sample",
1465 br#"
1466dest = "~/.config/sample"
1467post_upgrade = { command = "/bin/echo", args = ["updated"], show_output = true }
1468
1469[[files]]
1470source = "config.toml"
1471"#,
1472 )
1473 .unwrap();
1474 let hooks = resolve_hooks(parsed.post_upgrade, "post_upgrade", "sample").unwrap();
1475 assert_eq!(hooks.len(), 1);
1476 assert!(hooks[0].show_output);
1477 }
1478
1479 #[test]
1480 fn post_upgrade_hook_parses_multiple_commands() {
1481 let parsed = parse_category_toml(
1482 "sample",
1483 br#"
1484dest = "~/.config/sample"
1485post_upgrade = [
1486 { command = "/bin/echo", args = ["updated"] },
1487 { command = "/bin/echo", args = ["reloaded"] },
1488]
1489
1490[[files]]
1491source = "config.toml"
1492"#,
1493 )
1494 .unwrap();
1495 let hooks = resolve_hooks(parsed.post_upgrade, "post_upgrade", "sample").unwrap();
1496 assert_eq!(hooks.len(), 2);
1497 assert_eq!(hooks[0].args, vec!["updated"]);
1498 assert_eq!(hooks[1].args, vec!["reloaded"]);
1499 }
1500
1501 #[test]
1502 fn artifact_script_parses() {
1503 let parsed = parse_category_toml(
1504 "sample",
1505 br#"
1506dest = "~/.config/sample"
1507
1508[artifact]
1509script = "build.sh"
1510
1511[[files]]
1512source = "config.toml"
1513"#,
1514 )
1515 .unwrap();
1516 let artifact = resolve_artifact(parsed.artifact, "sample").unwrap();
1517 assert_eq!(
1518 artifact,
1519 Some(AppArtifact {
1520 script: "build.sh".to_string(),
1521 teardown: None,
1522 runtime: ArtifactRuntime::Native,
1523 })
1524 );
1525 }
1526
1527 #[test]
1528 fn artifact_teardown_parses() {
1529 let parsed = parse_category_toml(
1530 "sample",
1531 br#"
1532dest = "~/.config/sample"
1533
1534[artifact]
1535script = "build.sh"
1536teardown = "unbuild.sh"
1537
1538[[files]]
1539source = "config.toml"
1540"#,
1541 )
1542 .unwrap();
1543 let artifact = resolve_artifact(parsed.artifact, "sample").unwrap();
1544 assert_eq!(
1545 artifact,
1546 Some(AppArtifact {
1547 script: "build.sh".to_string(),
1548 teardown: Some("unbuild.sh".to_string()),
1549 runtime: ArtifactRuntime::Native,
1550 })
1551 );
1552 }
1553
1554 #[test]
1555 fn artifact_empty_teardown_is_rejected() {
1556 let parsed = parse_category_toml(
1557 "sample",
1558 br#"
1559dest = "~/.config/sample"
1560
1561[artifact]
1562script = "build.sh"
1563teardown = " "
1564
1565[[files]]
1566source = "config.toml"
1567"#,
1568 );
1569 let err = parsed.unwrap_err();
1570 assert!(
1571 err.to_string()
1572 .contains("artifact.teardown must not be empty")
1573 );
1574 }
1575
1576 #[test]
1577 fn post_install_hook_parses_single_and_array() {
1578 let single = parse_category_toml(
1579 "sample",
1580 br#"
1581dest = "~/.config/sample"
1582post_install = { command = "/bin/echo", args = ["installed"] }
1583
1584[[files]]
1585source = "config.toml"
1586"#,
1587 )
1588 .unwrap();
1589 let hooks = resolve_hooks(single.post_install, "post_install", "sample").unwrap();
1590 assert_eq!(hooks.len(), 1);
1591 assert_eq!(hooks[0].command, "/bin/echo");
1592 assert_eq!(hooks[0].args, vec!["installed"]);
1593
1594 let multiple = parse_category_toml(
1595 "sample",
1596 br#"
1597dest = "~/.config/sample"
1598post_install = [
1599 { command = "/bin/echo", args = ["a"] },
1600 { command = "/bin/echo", args = ["b"] },
1601]
1602
1603[[files]]
1604source = "config.toml"
1605"#,
1606 )
1607 .unwrap();
1608 let hooks = resolve_hooks(multiple.post_install, "post_install", "sample").unwrap();
1609 assert_eq!(hooks.len(), 2);
1610 }
1611
1612 #[test]
1613 fn post_install_empty_command_is_rejected() {
1614 let err = parse_category_toml(
1615 "sample",
1616 br#"
1617dest = "~/.config/sample"
1618post_install = { command = " " }
1619
1620[[files]]
1621source = "config.toml"
1622"#,
1623 )
1624 .unwrap_err();
1625 assert!(
1626 err.to_string()
1627 .contains("post_install.command must not be empty")
1628 );
1629 }
1630
1631 #[test]
1632 fn artifact_section_absent_is_none() {
1633 let parsed = parse_category_toml(
1634 "sample",
1635 br#"
1636dest = "~/.config/sample"
1637
1638[[files]]
1639source = "config.toml"
1640"#,
1641 )
1642 .unwrap();
1643 assert!(
1644 resolve_artifact(parsed.artifact, "sample")
1645 .unwrap()
1646 .is_none()
1647 );
1648 }
1649
1650 #[test]
1651 fn artifact_empty_script_is_rejected() {
1652 let parsed = parse_category_toml(
1653 "sample",
1654 br#"
1655dest = "~/.config/sample"
1656
1657[artifact]
1658script = ""
1659
1660[[files]]
1661source = "config.toml"
1662"#,
1663 );
1664 let err = parsed.unwrap_err();
1665 assert!(err.to_string().contains("artifact.script"));
1666 }
1667
1668 #[test]
1669 fn artifact_runtime_defaults_native_and_bun_requires_bun_extension() {
1670 let parse = |body: &str| -> CategoryToml {
1671 toml::from_str(&format!(
1672 "description = \"S\"\ndest = \"~/x\"\n\n{body}\n\n[[files]]\nsource = \"c\"\n"
1673 ))
1674 .unwrap()
1675 };
1676
1677 let native = resolve_artifact(parse("[artifact]\nscript = \"build.sh\"").artifact, "s")
1679 .unwrap()
1680 .unwrap();
1681 assert_eq!(native.runtime, ArtifactRuntime::Native);
1682
1683 let bun = resolve_artifact(
1685 parse(
1686 "[artifact]\nscript = \"build.ts\"\nteardown = \"unbuild.ts\"\nruntime = \"bun\"",
1687 )
1688 .artifact,
1689 "s",
1690 )
1691 .unwrap()
1692 .unwrap();
1693 assert_eq!(bun.runtime, ArtifactRuntime::Bun);
1694
1695 assert!(
1697 resolve_artifact(
1698 parse("[artifact]\nscript = \"build.sh\"\nruntime = \"bun\"").artifact,
1699 "s"
1700 )
1701 .is_err()
1702 );
1703 assert!(
1704 resolve_artifact(
1705 parse(
1706 "[artifact]\nscript = \"build.ts\"\nteardown = \"unbuild.sh\"\nruntime = \"bun\""
1707 )
1708 .artifact,
1709 "s"
1710 )
1711 .is_err()
1712 );
1713 }
1714
1715 #[test]
1716 fn embedded_surge_declares_artifact_script() {
1717 let bytes = presets::read_asset_bytes("app/surge/shine.toml").unwrap();
1718 let parsed = parse_category_toml("surge", &bytes).unwrap();
1719 assert_eq!(
1720 resolve_artifact(parsed.artifact, "app/surge/shine.toml").unwrap(),
1721 Some(AppArtifact {
1722 script: "build.ts".to_string(),
1723 teardown: Some("unbuild.ts".to_string()),
1724 runtime: ArtifactRuntime::Bun,
1725 })
1726 );
1727 }
1728
1729 #[test]
1730 fn embedded_clash_verge_installs_merge_and_local_rule_references() {
1731 let categories = load_embedded_categories(Some("clash-verge")).unwrap();
1732 let clash = categories.iter().find(|c| c.name == "clash-verge").unwrap();
1733 assert!(clash.uses_metadata);
1734 assert_eq!(
1735 clash.destination_root.as_deref(),
1736 Some("~/.shine/clash-verge")
1737 );
1738
1739 assert_eq!(clash.files.len(), 4);
1740 let file = &clash.files[0];
1741 assert_eq!(file.source_rel, std::path::Path::new("merge.yaml"));
1742 assert_eq!(file.target_rel, std::path::Path::new("merge.yaml"));
1743 assert!(file.transforms.is_empty());
1746 assert_eq!(file.install_strategy, AppInstallStrategy::Copy);
1747
1748 for (source, target) in [
1749 ("rules/lan.list", "ruleset/shine-source/lan.list"),
1750 (
1751 "rules/lan-socks.list",
1752 "ruleset/shine-source/lan-socks.list",
1753 ),
1754 (
1755 "rules/other-direct.list",
1756 "ruleset/shine-source/other-direct.list",
1757 ),
1758 ] {
1759 let rule = clash
1760 .files
1761 .iter()
1762 .find(|candidate| candidate.source_rel == Path::new(source))
1763 .unwrap();
1764 assert_eq!(rule.target_rel, Path::new(target));
1765 assert_eq!(
1766 rule.destination_root,
1767 Some(AppDestinationRoot::DataDir(PathBuf::from(
1768 "io.github.clash-verge-rev.clash-verge-rev"
1769 )))
1770 );
1771 }
1772
1773 let merge = include_str!("../../../presets/app/clash-verge/merge.yaml");
1774 assert!(merge.contains("# proxies:"));
1775 assert!(merge.contains("# proxy-groups:"));
1776 assert!(merge.contains("# prepend-rules:"));
1777 assert!(merge.contains("type: file, behavior: classical, format: text"));
1778 assert!(merge.contains("http://127.0.0.1:8080/rules/lan.list"));
1779 assert!(merge.contains("https://rules.example.com/surge/lan.list"));
1780
1781 let build_hook = vec![AppHook {
1785 command: "shine".to_string(),
1786 args: vec![
1787 "app".to_string(),
1788 "artifact".to_string(),
1789 "apply".to_string(),
1790 "clash-verge".to_string(),
1791 ],
1792 show_output: true,
1793 }];
1794 assert_eq!(clash.post_install, build_hook);
1795 assert_eq!(clash.post_upgrade, build_hook);
1796 assert_eq!(
1797 clash.artifact,
1798 Some(AppArtifact {
1799 script: "build.ts".to_string(),
1800 teardown: Some("unbuild.ts".to_string()),
1801 runtime: ArtifactRuntime::Bun,
1802 })
1803 );
1804 }
1805
1806 #[test]
1807 fn file_dest_supports_absolute_platform_and_data_dir_roots() {
1808 let parsed = parse_category_toml(
1809 "sample",
1810 br#"
1811dest = "~/.config/sample"
1812
1813[[files]]
1814source = "default.toml"
1815
1816[[files]]
1817source = "absolute.toml"
1818dest = "~/.absolute"
1819
1820[[files]]
1821source = "data.toml"
1822dest = { base = "data-dir", path = "sample/files" }
1823"#,
1824 )
1825 .unwrap();
1826 let files = parsed.files.unwrap();
1827 assert_eq!(
1828 selected_file_destination("sample", &files[0]).unwrap(),
1829 None
1830 );
1831 assert_eq!(
1832 selected_file_destination("sample", &files[1]).unwrap(),
1833 Some(AppDestinationRoot::Path("~/.absolute".to_string()))
1834 );
1835 assert_eq!(
1836 selected_file_destination("sample", &files[2]).unwrap(),
1837 Some(AppDestinationRoot::DataDir(PathBuf::from("sample/files")))
1838 );
1839 }
1840
1841 #[test]
1842 fn rooted_file_dest_rejects_parent_traversal() {
1843 let error = parse_category_toml(
1844 "sample",
1845 br#"
1846dest = "~/.config/sample"
1847
1848[[files]]
1849source = "config.toml"
1850dest = { base = "data-dir", path = "../escape" }
1851"#,
1852 )
1853 .unwrap_err();
1854 assert!(error.to_string().contains("invalid rooted dest path"));
1855 }
1856
1857 #[test]
1858 fn legacy_description_is_first_comment_line_only() {
1859 let yaml = b"# Clash Verge Rev merge profile. Summary line.\n#\n# A second paragraph\n# that keeps going and going.\nproxies:\n - name: X\n";
1862 assert_eq!(
1863 parse_legacy_description(yaml).as_deref(),
1864 Some("Clash Verge Rev merge profile. Summary line.")
1865 );
1866 let gitconfig = b"# shine-dest: ~/.gitconfig\n# Personal git configuration.\n\n[pull]\n";
1868 assert_eq!(
1869 parse_legacy_description(gitconfig).as_deref(),
1870 Some("Personal git configuration.")
1871 );
1872 assert_eq!(parse_legacy_description(b"proxies: []\n"), None);
1874 }
1875
1876 #[test]
1877 fn embedded_git_stays_legacy() {
1878 let categories = load_embedded_categories(Some("git")).unwrap();
1879 let git = categories.iter().find(|c| c.name == "git").unwrap();
1880 assert!(!git.uses_metadata);
1881 assert_eq!(git.files.len(), 1);
1882 assert_eq!(
1883 git.files[0].legacy_dest_annotation.as_deref(),
1884 Some("~/.gitconfig")
1885 );
1886 }
1887
1888 #[test]
1889 fn embedded_docker_engine_has_jsonc_transform() {
1890 let categories = load_embedded_categories(Some("docker-engine")).unwrap();
1891 let docker = categories
1892 .iter()
1893 .find(|c| c.name == "docker-engine")
1894 .unwrap();
1895 assert!(docker.uses_metadata);
1896 #[cfg(windows)]
1897 assert_eq!(docker.destination_root.as_deref(), Some("~/.docker"));
1898 #[cfg(not(windows))]
1899 assert_eq!(docker.destination_root.as_deref(), Some("/etc/docker"));
1900 assert_eq!(docker.files.len(), 1);
1901
1902 let file = &docker.files[0];
1903 assert_eq!(file.source_rel, std::path::Path::new("daemon.jsonc"));
1904 assert_eq!(file.target_rel, std::path::Path::new("daemon.json"));
1905 assert_eq!(file.transforms, vec!["template", "jsonc-to-json"]);
1906 assert_eq!(file.install_strategy, AppInstallStrategy::Copy);
1907 assert!(file.requires_admin);
1908 assert!(
1909 file.restart_hint
1910 .as_deref()
1911 .is_some_and(|hint| hint.contains("Restart Docker Engine"))
1912 );
1913 }
1914
1915 #[test]
1916 fn embedded_docker_desktop_uses_json_merge_install_strategy() {
1917 let categories = load_embedded_categories(Some("docker-desktop")).unwrap();
1918 #[cfg(not(windows))]
1919 {
1920 assert!(categories.is_empty());
1921 }
1922
1923 #[cfg(windows)]
1924 let docker = categories
1925 .iter()
1926 .find(|c| c.name == "docker-desktop")
1927 .unwrap();
1928
1929 #[cfg(windows)]
1930 {
1931 assert!(docker.uses_metadata);
1932 assert_eq!(docker.files.len(), 1);
1933 let file = &docker.files[0];
1934 assert_eq!(file.target_rel, std::path::Path::new("settings-store.json"));
1935 assert_eq!(file.transforms, vec!["template", "jsonc-to-json"]);
1936 assert_eq!(
1937 file.install_strategy,
1938 AppInstallStrategy::JsonMerge {
1939 managed_keys: vec!["proxy".to_string(), "containersProxy".to_string()],
1940 }
1941 );
1942 }
1943 }
1944
1945 #[test]
1946 fn embedded_archey4_is_unix_only() {
1947 let categories = load_embedded_categories(Some("archey4")).unwrap();
1948
1949 #[cfg(windows)]
1950 {
1951 assert!(categories.is_empty());
1952 return;
1953 }
1954
1955 #[cfg(not(windows))]
1956 {
1957 let archey4 = categories.iter().find(|c| c.name == "archey4").unwrap();
1958 assert!(archey4.uses_metadata);
1959 assert_eq!(
1960 archey4.destination_root.as_deref(),
1961 Some("~/.config/archey4")
1962 );
1963 }
1964 }
1965
1966 #[test]
1967 fn unix_absolute_dest_is_valid_on_all_platforms() {
1968 parse_category_toml("docker-engine", b"dest = \"/etc/docker\"\n").unwrap();
1969 }
1970
1971 #[test]
1972 fn platform_dest_selects_current_platform() {
1973 let parsed = parse_category_toml(
1974 "docker-engine",
1975 b"[dest]\nwindows = \"~/.docker\"\nunix = \"/etc/docker\"\n",
1976 )
1977 .unwrap();
1978
1979 #[cfg(windows)]
1980 assert_eq!(
1981 parsed
1982 .dest
1983 .select_for_current_platform("docker-engine")
1984 .unwrap(),
1985 Some("~/.docker".to_string())
1986 );
1987 #[cfg(not(windows))]
1988 assert_eq!(
1989 parsed
1990 .dest
1991 .select_for_current_platform("docker-engine")
1992 .unwrap(),
1993 Some("/etc/docker".to_string())
1994 );
1995 }
1996
1997 #[test]
1998 fn exact_platform_destination_precedes_unix_fallback() {
1999 let parsed = parse_category_toml(
2000 "editor",
2001 br#"dest = { macos = "~/Library/Editor", linux = "~/.config/editor", unix = "~/.editor" }"#,
2002 )
2003 .unwrap();
2004
2005 assert_eq!(
2006 parsed
2007 .dest
2008 .select_for_platform("editor", OperatingSystem::Macos)
2009 .unwrap()
2010 .as_deref(),
2011 Some("~/Library/Editor")
2012 );
2013 assert_eq!(
2014 parsed
2015 .dest
2016 .select_for_platform("editor", OperatingSystem::Linux)
2017 .unwrap()
2018 .as_deref(),
2019 Some("~/.config/editor")
2020 );
2021 assert_eq!(
2022 parsed
2023 .dest
2024 .select_for_platform("editor", OperatingSystem::Windows)
2025 .unwrap(),
2026 None
2027 );
2028 }
2029
2030 #[test]
2031 fn platform_dest_validates_paths_for_declared_os() {
2032 parse_category_toml(
2033 "editor",
2034 br#"dest = { macos = "/Library/Editor", linux = "/etc/editor", windows = "C:\\Users\\Public\\Editor", unix = "/opt/editor" }"#,
2035 )
2036 .unwrap();
2037 }
2038
2039 #[test]
2040 fn platform_dest_rejects_path_for_a_different_os() {
2041 let error =
2042 parse_category_toml("editor", br#"dest = { windows = "/etc/editor" }"#).unwrap_err();
2043
2044 assert!(error.to_string().contains("must be absolute"));
2045 }
2046
2047 #[test]
2048 fn empty_platform_destination_map_is_rejected() {
2049 let err = parse_category_toml("editor", b"dest = {}\n")
2050 .unwrap_err()
2051 .to_string();
2052 assert!(err.contains("must not be empty"));
2053 }
2054
2055 #[test]
2056 fn unsupported_file_platform_is_rejected() {
2057 let err = parse_category_toml(
2058 "docker-engine",
2059 br#"
2060dest = "/etc/docker"
2061
2062[[files]]
2063source = "daemon.jsonc"
2064platforms = ["plan9"]
2065"#,
2066 )
2067 .unwrap_err();
2068
2069 assert!(err.to_string().contains("unsupported platform"));
2070 }
2071
2072 #[test]
2073 fn file_platform_filter_matches_expected_platforms() {
2074 let windows_only: FileToml = toml::from_str(
2075 r#"
2076source = "daemon.jsonc"
2077platforms = ["windows"]
2078"#,
2079 )
2080 .unwrap();
2081 let unix_only: FileToml = toml::from_str(
2082 r#"
2083source = "daemon.jsonc"
2084platforms = ["unix"]
2085"#,
2086 )
2087 .unwrap();
2088
2089 assert!(
2090 file_matches_platform("docker-engine", &windows_only, OperatingSystem::Windows)
2091 .unwrap()
2092 );
2093 assert!(
2094 !file_matches_platform("docker-engine", &windows_only, OperatingSystem::Linux).unwrap()
2095 );
2096 assert!(
2097 file_matches_platform("docker-engine", &unix_only, OperatingSystem::Macos).unwrap()
2098 );
2099 assert!(
2100 file_matches_platform("docker-engine", &unix_only, OperatingSystem::Linux).unwrap()
2101 );
2102 assert!(
2103 !file_matches_platform("docker-engine", &unix_only, OperatingSystem::Windows).unwrap()
2104 );
2105 }
2106
2107 #[test]
2108 fn json_merge_requires_managed_keys() {
2109 let err = parse_category_toml(
2110 "docker-desktop",
2111 br#"
2112dest = "~/.docker/desktop"
2113
2114[[files]]
2115source = "settings-store.jsonc"
2116target = "settings-store.json"
2117install_mode = "json-merge"
2118"#,
2119 )
2120 .unwrap_err();
2121
2122 assert!(err.to_string().contains("managed_keys"));
2123 }
2124
2125 #[test]
2126 fn embedded_ghostty_has_theme_files_with_template_transform() {
2127 let categories = load_embedded_categories(Some("ghostty")).unwrap();
2128
2129 #[cfg(windows)]
2130 {
2131 assert!(categories.is_empty());
2132 return;
2133 }
2134
2135 #[cfg(not(windows))]
2136 {
2137 let ghostty = categories.iter().find(|c| c.name == "ghostty").unwrap();
2138 assert!(ghostty.uses_metadata);
2139 assert!(ghostty.has_explicit_files);
2140 assert_eq!(
2141 ghostty.destination_root.as_deref(),
2142 Some("~/.config/ghostty")
2143 );
2144 assert_eq!(ghostty.list_mode, AppListMode::Category);
2145 assert_eq!(ghostty.files.len(), 6);
2146
2147 let shine_light = ghostty
2148 .files
2149 .iter()
2150 .find(|f| f.source_rel == std::path::Path::new("themes/Shine Light"))
2151 .unwrap();
2152 assert_eq!(
2153 shine_light.target_rel,
2154 std::path::Path::new("themes/Shine Light")
2155 );
2156 assert_eq!(shine_light.transforms, vec!["template"]);
2157
2158 let light = ghostty
2159 .files
2160 .iter()
2161 .find(|f| f.source_rel == std::path::Path::new("themes/iTerm2 Solarized Light"))
2162 .unwrap();
2163 assert_eq!(
2164 light.target_rel,
2165 std::path::Path::new("themes/light_iTerm2 Solarized Light")
2166 );
2167 assert_eq!(light.transforms, vec!["template"]);
2168
2169 let dark = ghostty
2170 .files
2171 .iter()
2172 .find(|f| f.source_rel == std::path::Path::new("themes/Alien Blood"))
2173 .unwrap();
2174 assert_eq!(
2175 dark.target_rel,
2176 std::path::Path::new("themes/dark_Alien Blood")
2177 );
2178 assert_eq!(dark.transforms, vec!["template"]);
2179
2180 let atom = ghostty
2181 .files
2182 .iter()
2183 .find(|f| f.source_rel == std::path::Path::new("themes/Atom One Light"))
2184 .unwrap();
2185 assert_eq!(
2186 atom.target_rel,
2187 std::path::Path::new("themes/light_Atom One Light")
2188 );
2189 assert_eq!(atom.transforms, vec!["template"]);
2190
2191 let github = ghostty
2192 .files
2193 .iter()
2194 .find(|f| f.source_rel == std::path::Path::new("themes/Github Light Default"))
2195 .unwrap();
2196 assert_eq!(
2197 github.target_rel,
2198 std::path::Path::new("themes/light_Github Light Default")
2199 );
2200 assert_eq!(github.transforms, vec!["template"]);
2201 }
2202 }
2203
2204 #[test]
2205 fn unknown_transform_rejected_at_load() {
2206 let toml =
2207 b"dest = \"/tmp\"\n[[files]]\nsource = \"f\"\ntransform = \"no-such-transform\"\n";
2208 assert!(
2209 parse_category_toml("test", toml).is_err() || {
2210 let file = FileToml {
2213 source: "f".to_string(),
2214 target: None,
2215 dest: None,
2216 description: None,
2217 display_name: None,
2218 platforms: None,
2219 transform: Some("no-such-transform".to_string()),
2220 transforms: None,
2221 install_mode: None,
2222 managed_keys: None,
2223 requires_admin: false,
2224 restart_hint: None,
2225 generator: None,
2226 };
2227 resolve_transforms(&file, "test").is_err()
2228 }
2229 );
2230 }
2231
2232 #[test]
2233 fn both_transform_and_transforms_rejected() {
2234 let file = FileToml {
2235 source: "f".to_string(),
2236 target: None,
2237 dest: None,
2238 description: None,
2239 display_name: None,
2240 platforms: None,
2241 transform: Some("jsonc-to-json".to_string()),
2242 transforms: Some(vec!["jsonc-to-json".to_string()]),
2243 install_mode: None,
2244 managed_keys: None,
2245 requires_admin: false,
2246 restart_hint: None,
2247 generator: None,
2248 };
2249 assert!(resolve_transforms(&file, "test").is_err());
2250 }
2251
2252 #[test]
2253 fn generator_metadata_parses_and_validates_condition_env() {
2254 let parsed = parse_category_toml(
2255 "sample",
2256 br#"
2257dest = "/tmp"
2258[[files]]
2259source = "fallback.conf"
2260generator = { script = "generate.ts", runtime = "bun", env = ["SOURCE_URL"], when_env = "SOURCE_URL" }
2261"#,
2262 )
2263 .unwrap();
2264 let generator = resolve_generator(
2265 parsed.files.unwrap().remove(0).generator,
2266 "app/sample/shine.toml",
2267 )
2268 .unwrap()
2269 .unwrap();
2270 assert_eq!(generator.script, Path::new("generate.ts"));
2271 assert_eq!(generator.runtime, ArtifactRuntime::Bun);
2272 assert_eq!(generator.when_env, "SOURCE_URL");
2273 assert!(generator.auto);
2274 }
2275
2276 #[test]
2277 fn generator_auto_can_be_disabled() {
2278 let parsed = parse_category_toml(
2279 "sample",
2280 br#"
2281description = "sample"
2282dest = "~/.config/sample"
2283
2284[[files]]
2285source = "fallback.txt"
2286generator = { script = "generate.ts", runtime = "bun", env = ["SOURCE_URL"], when_env = "SOURCE_URL", auto = false }
2287"#,
2288 )
2289 .unwrap();
2290 let generator = resolve_generator(
2291 parsed.files.unwrap().remove(0).generator,
2292 "app/sample/shine.toml",
2293 )
2294 .unwrap()
2295 .unwrap();
2296 assert!(!generator.auto);
2297 }
2298
2299 #[test]
2300 fn generator_condition_must_be_in_declared_env() {
2301 let error = parse_category_toml(
2302 "sample",
2303 br#"
2304dest = "/tmp"
2305[[files]]
2306source = "fallback.conf"
2307generator = { script = "generate.ts", runtime = "bun", env = ["OTHER_URL"], when_env = "SOURCE_URL" }
2308"#,
2309 )
2310 .unwrap_err();
2311 assert!(error.to_string().contains("must be declared"));
2312 }
2313}