1use super::manifest::AppInstallStrategy;
2use crate::config::Config;
3use crate::env::EnvVarSpec;
4use crate::platform::current_platform;
5use crate::presets;
6use anyhow::{Context, Result, bail};
7use serde::Deserialize;
8use std::collections::BTreeSet;
9use std::path::{Component, Path, PathBuf};
10use tokio::fs;
11
12#[derive(Debug, Clone)]
13pub struct AppCategory {
14 pub name: String,
15 pub description: Option<String>,
16 pub destination_root: Option<String>,
17 pub files: Vec<AppFile>,
18 pub list_mode: AppListMode,
19 pub post_upgrade: Vec<AppHook>,
20 pub post_install: Vec<AppHook>,
24 #[allow(dead_code)]
27 pub uses_metadata: bool,
28 pub has_explicit_files: bool,
31 pub artifact: Option<AppArtifact>,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum AppListMode {
36 Category,
37 Files,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct AppHook {
42 pub command: String,
43 pub args: Vec<String>,
44 pub show_output: bool,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum ArtifactRuntime {
52 #[default]
55 Native,
56 Bun,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct AppArtifact {
63 pub script: String,
64 pub teardown: Option<String>,
68 pub runtime: ArtifactRuntime,
71}
72
73#[derive(Debug, Clone)]
74pub struct AppFile {
75 pub source_rel: PathBuf,
76 pub target_rel: PathBuf,
77 pub description: Option<String>,
78 pub display_name: Option<String>,
79 pub legacy_dest_annotation: Option<String>,
80 pub transforms: Vec<String>,
81 pub install_strategy: AppInstallStrategy,
82 pub requires_admin: bool,
83 pub restart_hint: Option<String>,
84 pub generator: Option<AppGenerator>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct AppGenerator {
89 pub script: PathBuf,
90 pub runtime: ArtifactRuntime,
91 pub env: Vec<EnvVarSpec>,
92 pub when_env: String,
93 pub auto: bool,
97}
98
99#[derive(Debug, Deserialize)]
100struct CategoryToml {
101 description: Option<String>,
102 dest: DestToml,
103 list_mode: Option<ListModeToml>,
104 post_upgrade: Option<HookSpecToml>,
105 post_install: Option<HookSpecToml>,
106 artifact: Option<ArtifactToml>,
107 files: Option<Vec<FileToml>>,
108}
109
110#[derive(Debug, Clone, Deserialize)]
111struct ArtifactToml {
112 script: String,
113 #[serde(default)]
114 teardown: Option<String>,
115 #[serde(default)]
116 runtime: Option<ArtifactRuntimeToml>,
117}
118
119#[derive(Debug, Clone, Copy, Deserialize)]
120#[serde(rename_all = "kebab-case")]
121enum ArtifactRuntimeToml {
122 Native,
123 Bun,
124}
125
126#[derive(Debug, Clone, Deserialize)]
127#[serde(untagged)]
128enum HookSpecToml {
129 Single(HookToml),
130 Multiple(Vec<HookToml>),
131}
132
133#[derive(Debug, Clone, Deserialize)]
134struct HookToml {
135 command: String,
136 #[serde(default)]
137 args: Vec<String>,
138 #[serde(default)]
139 show_output: bool,
140}
141
142#[derive(Debug, Deserialize)]
143#[serde(untagged)]
144enum DestToml {
145 Single(String),
146 Platforms(PlatformDestToml),
147}
148
149#[derive(Debug, Deserialize)]
150struct PlatformDestToml {
151 windows: Option<String>,
152 unix: Option<String>,
153}
154
155#[derive(Debug, Clone, Copy, Deserialize)]
156#[serde(rename_all = "kebab-case")]
157enum ListModeToml {
158 Category,
159 Files,
160}
161
162#[derive(Debug, Clone, Copy, Deserialize)]
163#[serde(rename_all = "kebab-case")]
164enum InstallModeToml {
165 Copy,
166 JsonMerge,
167}
168
169impl From<ListModeToml> for AppListMode {
170 fn from(value: ListModeToml) -> Self {
171 match value {
172 ListModeToml::Category => Self::Category,
173 ListModeToml::Files => Self::Files,
174 }
175 }
176}
177
178#[derive(Debug, Deserialize)]
179struct FileToml {
180 source: String,
181 target: Option<String>,
182 description: Option<String>,
183 display_name: Option<String>,
184 #[serde(default)]
185 platforms: Option<Vec<String>>,
186 #[serde(default)]
187 transform: Option<String>,
188 #[serde(default)]
189 transforms: Option<Vec<String>>,
190 #[serde(default)]
191 install_mode: Option<InstallModeToml>,
192 #[serde(default)]
193 managed_keys: Option<Vec<String>>,
194 #[serde(default)]
195 requires_admin: bool,
196 restart_hint: Option<String>,
197 generator: Option<GeneratorToml>,
198}
199
200#[derive(Debug, Clone, Deserialize)]
201struct GeneratorToml {
202 script: String,
203 #[serde(default)]
204 runtime: Option<ArtifactRuntimeToml>,
205 #[serde(default)]
206 env: Vec<String>,
207 when_env: String,
208 #[serde(default = "default_true")]
209 auto: bool,
210}
211
212fn default_true() -> bool {
213 true
214}
215
216fn resolve_transforms(file: &FileToml, context: &str) -> Result<Vec<String>> {
217 let specs = match (&file.transform, &file.transforms) {
218 (Some(_), Some(_)) => {
219 bail!("{context}: use 'transform' or 'transforms', not both")
220 }
221 (Some(t), None) => vec![t.clone()],
222 (None, Some(ts)) => ts.clone(),
223 (None, None) => vec![],
224 };
225 super::transforms::validate(&specs).with_context(|| format!("{context}: invalid transform"))?;
226 Ok(specs)
227}
228
229fn resolve_install_strategy(file: &FileToml, context: &str) -> Result<AppInstallStrategy> {
230 match file.install_mode.unwrap_or(InstallModeToml::Copy) {
231 InstallModeToml::Copy => {
232 if file.managed_keys.is_some() {
233 bail!("{context}: 'managed_keys' requires install_mode = \"json-merge\"");
234 }
235 Ok(AppInstallStrategy::Copy)
236 }
237 InstallModeToml::JsonMerge => {
238 let managed_keys = file
239 .managed_keys
240 .clone()
241 .ok_or_else(|| anyhow::anyhow!("{context}: json-merge requires 'managed_keys'"))?;
242 if managed_keys.is_empty() {
243 bail!("{context}: managed_keys must not be empty");
244 }
245 for key in &managed_keys {
246 if key.trim().is_empty() {
247 bail!("{context}: managed_keys must not contain empty keys");
248 }
249 if key.contains('.') {
250 bail!("{context}: managed_keys must be top-level JSON keys");
251 }
252 }
253 Ok(AppInstallStrategy::JsonMerge { managed_keys })
254 }
255 }
256}
257
258fn resolve_hooks(hook: Option<HookSpecToml>, field: &str, context: &str) -> Result<Vec<AppHook>> {
259 let Some(hook) = hook else {
260 return Ok(Vec::new());
261 };
262 let hooks = match hook {
263 HookSpecToml::Single(hook) => vec![hook],
264 HookSpecToml::Multiple(hooks) => hooks,
265 };
266 if hooks.is_empty() {
267 bail!("{context}: {field} must not be empty");
268 }
269 let mut resolved = Vec::with_capacity(hooks.len());
270 for hook in hooks {
271 if hook.command.trim().is_empty() {
272 bail!("{context}: {field}.command must not be empty");
273 }
274 resolved.push(AppHook {
275 command: hook.command,
276 args: hook.args,
277 show_output: hook.show_output,
278 });
279 }
280 Ok(resolved)
281}
282
283fn resolve_artifact(artifact: Option<ArtifactToml>, context: &str) -> Result<Option<AppArtifact>> {
284 let Some(artifact) = artifact else {
285 return Ok(None);
286 };
287 if artifact.script.trim().is_empty() {
288 bail!("{context}: artifact.script must not be empty");
289 }
290 if let Some(teardown) = &artifact.teardown
291 && teardown.trim().is_empty()
292 {
293 bail!("{context}: artifact.teardown must not be empty");
294 }
295 let runtime = match artifact.runtime.unwrap_or(ArtifactRuntimeToml::Native) {
296 ArtifactRuntimeToml::Native => ArtifactRuntime::Native,
297 ArtifactRuntimeToml::Bun => {
298 for name in
301 std::iter::once(artifact.script.as_str()).chain(artifact.teardown.as_deref())
302 {
303 if !has_bun_extension(name) {
304 bail!(
305 "{context}: artifact runtime = \"bun\" requires a .ts/.js/.mts/.mjs script, got '{name}'"
306 );
307 }
308 }
309 ArtifactRuntime::Bun
310 }
311 };
312 Ok(Some(AppArtifact {
313 script: artifact.script,
314 teardown: artifact.teardown,
315 runtime,
316 }))
317}
318
319fn resolve_generator(
320 generator: Option<GeneratorToml>,
321 context: &str,
322) -> Result<Option<AppGenerator>> {
323 let Some(generator) = generator else {
324 return Ok(None);
325 };
326 let script = normalize_relative(&generator.script)
327 .with_context(|| format!("{context}: invalid generator.script"))?;
328 let runtime = match generator.runtime.unwrap_or(ArtifactRuntimeToml::Native) {
329 ArtifactRuntimeToml::Native => ArtifactRuntime::Native,
330 ArtifactRuntimeToml::Bun => {
331 if !has_bun_extension(&generator.script) {
332 bail!(
333 "{context}: generator runtime = \"bun\" requires a .ts/.js/.mts/.mjs script, got '{}'",
334 generator.script
335 );
336 }
337 ArtifactRuntime::Bun
338 }
339 };
340 let env = crate::env::parse_env_specs(&generator.env)
341 .with_context(|| format!("{context}: invalid generator.env"))?;
342 crate::env::validate_env_key(&generator.when_env)
343 .with_context(|| format!("{context}: invalid generator.when_env"))?;
344 if !env.iter().any(|spec| spec.source == generator.when_env) {
345 bail!(
346 "{context}: generator.when_env '{}' must be declared in generator.env",
347 generator.when_env
348 );
349 }
350 Ok(Some(AppGenerator {
351 script,
352 runtime,
353 env,
354 when_env: generator.when_env,
355 auto: generator.auto,
356 }))
357}
358
359fn has_bun_extension(name: &str) -> bool {
360 matches!(
361 Path::new(name).extension().and_then(|e| e.to_str()),
362 Some("ts" | "js" | "mts" | "mjs")
363 )
364}
365
366fn default_list_mode(has_explicit_files: bool) -> AppListMode {
367 if has_explicit_files {
368 AppListMode::Files
369 } else {
370 AppListMode::Category
371 }
372}
373
374pub fn load_embedded_categories(filter: Option<&str>) -> Result<Vec<AppCategory>> {
375 let filter = filter.map(str::to_string);
376 let names = collect_embedded_category_names(filter.as_deref());
377 let mut categories = Vec::new();
378
379 for name in names {
380 if let Some(category) = load_embedded_category(&name)? {
381 categories.push(category);
382 }
383 }
384
385 Ok(categories)
386}
387
388pub async fn load_installed_categories(
389 config: &Config,
390 filter: Option<&str>,
391) -> Result<Vec<AppCategory>> {
392 let app_root = config.presets_dir().join("app");
393 let mut category_names: BTreeSet<String> = collect_fs_category_names(&app_root, filter)
394 .await?
395 .into_iter()
396 .collect();
397 if let Some(overlay) = config.active_presets_overlay_dir() {
398 category_names.extend(collect_fs_category_names(&overlay.join("app"), filter).await?);
399 }
400 if let Some(filter) = filter
401 && category_names.is_empty()
402 {
403 bail!("app preset category not found: {filter}");
404 }
405 let mut categories = Vec::new();
406
407 for name in category_names {
408 if let Some(category) = load_installed_category(config, &name).await? {
409 categories.push(category);
410 }
411 }
412
413 Ok(categories)
414}
415
416pub async fn load_active_categories(
421 config: &Config,
422 filter: Option<&str>,
423) -> Result<Vec<AppCategory>> {
424 if config.is_external_presets {
425 load_installed_categories(config, filter).await
426 } else {
427 load_embedded_categories(filter)
428 }
429}
430
431fn load_embedded_category(name: &str) -> Result<Option<AppCategory>> {
432 let metadata_path = format!("app/{name}/shine.toml");
433 if let Some(bytes) = presets::read_asset_bytes(&metadata_path) {
434 let parsed = parse_category_toml(name, &bytes)?;
435 let has_explicit_files = parsed.files.is_some();
436 let post_upgrade = resolve_hooks(parsed.post_upgrade, "post_upgrade", &metadata_path)?;
437 let post_install = resolve_hooks(parsed.post_install, "post_install", &metadata_path)?;
438 let artifact = resolve_artifact(parsed.artifact, &metadata_path)?;
439 let Some(dest_root) = parsed.dest.select_for_current_platform(name)? else {
440 return Ok(None);
441 };
442 let files = match parsed.files {
443 Some(files) => {
444 let mut filtered = Vec::new();
445 for file in files {
446 if file_matches_current_platform(name, &file)? {
447 filtered.push(file);
448 }
449 }
450 filtered
451 .into_iter()
452 .map(|file| {
453 let context = format!("app/{name}/shine.toml");
454 let source_rel = normalize_relative(&file.source)
455 .with_context(|| format!("invalid source for {context}"))?;
456 let target_rel =
457 normalize_relative(file.target.as_deref().unwrap_or(&file.source))
458 .with_context(|| format!("invalid target for {context}"))?;
459 let transforms = resolve_transforms(&file, &context)?;
460 let install_strategy = resolve_install_strategy(&file, &context)?;
461 let generator = resolve_generator(file.generator.clone(), &context)?;
462 Ok(AppFile {
463 source_rel,
464 target_rel,
465 description: file.description,
466 display_name: file.display_name,
467 legacy_dest_annotation: None,
468 transforms,
469 install_strategy,
470 requires_admin: file.requires_admin,
471 restart_hint: file.restart_hint,
472 generator,
473 })
474 })
475 .collect::<Result<Vec<_>>>()?
476 }
477 None => collect_embedded_files(name)?
478 .into_iter()
479 .map(|rel| AppFile {
480 source_rel: rel.clone(),
481 target_rel: rel,
482 description: None,
483 display_name: None,
484 legacy_dest_annotation: None,
485 transforms: vec![],
486 install_strategy: AppInstallStrategy::Copy,
487 requires_admin: false,
488 restart_hint: None,
489 generator: None,
490 })
491 .collect(),
492 };
493 if files.is_empty() {
494 return Ok(None);
495 }
496
497 return Ok(Some(AppCategory {
498 name: name.to_string(),
499 description: parsed.description,
500 destination_root: Some(dest_root),
501 files,
502 list_mode: parsed
503 .list_mode
504 .map(Into::into)
505 .unwrap_or_else(|| default_list_mode(has_explicit_files)),
506 post_upgrade,
507 post_install,
508 uses_metadata: true,
509 has_explicit_files,
510 artifact,
511 }));
512 }
513
514 Ok(Some(AppCategory {
515 name: name.to_string(),
516 description: None,
517 destination_root: None,
518 files: collect_embedded_files(name)?
519 .into_iter()
520 .map(|rel| {
521 let asset_path = format!("app/{name}/{}", rel.to_string_lossy());
522 let bytes = presets::read_asset_bytes(&asset_path).unwrap_or_default();
523 AppFile {
524 source_rel: rel.clone(),
525 target_rel: rel,
526 description: parse_legacy_description(&bytes),
527 display_name: None,
528 legacy_dest_annotation: presets::parse_dest_annotation(&bytes),
529 transforms: vec![],
530 install_strategy: AppInstallStrategy::Copy,
531 requires_admin: false,
532 restart_hint: None,
533 generator: None,
534 }
535 })
536 .collect(),
537 list_mode: AppListMode::Category,
538 post_upgrade: Vec::new(),
539 post_install: Vec::new(),
540 uses_metadata: false,
541 has_explicit_files: false,
542 artifact: None,
543 }))
544}
545
546async fn load_installed_category(config: &Config, name: &str) -> Result<Option<AppCategory>> {
547 let category_rel = Path::new("app").join(name);
548 let metadata_path = config.preset_path(category_rel.join("shine.toml"));
549
550 if metadata_path.exists() {
551 let bytes = fs::read(&metadata_path)
552 .await
553 .with_context(|| format!("reading metadata: {}", metadata_path.display()))?;
554 let parsed = parse_category_toml(name, &bytes)?;
555 let has_explicit_files = parsed.files.is_some();
556 let post_upgrade = resolve_hooks(
557 parsed.post_upgrade,
558 "post_upgrade",
559 &metadata_path.display().to_string(),
560 )?;
561 let post_install = resolve_hooks(
562 parsed.post_install,
563 "post_install",
564 &metadata_path.display().to_string(),
565 )?;
566 let artifact = resolve_artifact(parsed.artifact, &metadata_path.display().to_string())?;
567 let Some(dest_root) = parsed.dest.select_for_current_platform(name)? else {
568 return Ok(None);
569 };
570 let files = match parsed.files {
571 Some(files) => {
572 let mut filtered = Vec::new();
573 for file in files {
574 if file_matches_current_platform(name, &file)? {
575 filtered.push(file);
576 }
577 }
578 filtered
579 .into_iter()
580 .map(|file| {
581 let context = metadata_path.display().to_string();
582 let source_rel = normalize_relative(&file.source)
583 .with_context(|| format!("invalid source for {context}"))?;
584 let target_rel =
585 normalize_relative(file.target.as_deref().unwrap_or(&file.source))
586 .with_context(|| format!("invalid target for {context}"))?;
587 let transforms = resolve_transforms(&file, &context)?;
588 let install_strategy = resolve_install_strategy(&file, &context)?;
589 let generator = resolve_generator(file.generator.clone(), &context)?;
590 Ok(AppFile {
591 source_rel,
592 target_rel,
593 description: file.description,
594 display_name: file.display_name,
595 legacy_dest_annotation: None,
596 transforms,
597 install_strategy,
598 requires_admin: file.requires_admin,
599 restart_hint: file.restart_hint,
600 generator,
601 })
602 })
603 .collect::<Result<Vec<_>>>()?
604 }
605 None => collect_merged_fs_files(config, &category_rel)
606 .await?
607 .into_iter()
608 .map(|rel| AppFile {
609 source_rel: rel.clone(),
610 target_rel: rel,
611 description: None,
612 display_name: None,
613 legacy_dest_annotation: None,
614 transforms: vec![],
615 install_strategy: AppInstallStrategy::Copy,
616 requires_admin: false,
617 restart_hint: None,
618 generator: None,
619 })
620 .collect(),
621 };
622 if files.is_empty() {
623 return Ok(None);
624 }
625
626 for file in &files {
627 let source_path = config.preset_path(category_rel.join(&file.source_rel));
628 if !source_path.exists() {
629 bail!(
630 "app/{name}/shine.toml references missing file: {}",
631 file.source_rel.display()
632 );
633 }
634 if let Some(generator) = &file.generator {
635 let script_path = config.preset_path(category_rel.join(&generator.script));
636 if !script_path.exists() {
637 bail!(
638 "app/{name}/shine.toml references missing generator script: {}",
639 generator.script.display()
640 );
641 }
642 }
643 }
644
645 return Ok(Some(AppCategory {
646 name: name.to_string(),
647 description: parsed.description,
648 destination_root: Some(dest_root),
649 files,
650 list_mode: parsed
651 .list_mode
652 .map(Into::into)
653 .unwrap_or_else(|| default_list_mode(has_explicit_files)),
654 post_upgrade,
655 post_install,
656 uses_metadata: true,
657 has_explicit_files,
658 artifact,
659 }));
660 }
661
662 let mut files = Vec::new();
663 for rel in collect_merged_fs_files(config, &category_rel).await? {
664 let source_path = config.preset_path(category_rel.join(&rel));
665 let bytes = fs::read(&source_path)
666 .await
667 .with_context(|| format!("reading preset file: {}", source_path.display()))?;
668 files.push(AppFile {
669 source_rel: rel.clone(),
670 target_rel: rel,
671 description: parse_legacy_description(&bytes),
672 display_name: None,
673 legacy_dest_annotation: presets::parse_dest_annotation(&bytes),
674 transforms: vec![],
675 install_strategy: AppInstallStrategy::Copy,
676 requires_admin: false,
677 restart_hint: None,
678 generator: None,
679 });
680 }
681
682 Ok(Some(AppCategory {
683 name: name.to_string(),
684 description: None,
685 destination_root: None,
686 files,
687 list_mode: AppListMode::Category,
688 post_upgrade: Vec::new(),
689 post_install: Vec::new(),
690 uses_metadata: false,
691 has_explicit_files: false,
692 artifact: None,
693 }))
694}
695
696async fn collect_merged_fs_files(config: &Config, category_rel: &Path) -> Result<Vec<PathBuf>> {
697 crate::preset_meta::merge_fs_tree(config, category_rel, "preset category", |rel| {
698 if rel == Path::new("shine.toml") {
699 return Ok(None);
700 }
701 Ok(Some(normalize_relative(&rel.to_string_lossy())?))
702 })
703 .await
704}
705
706fn collect_embedded_category_names(filter: Option<&str>) -> Vec<String> {
707 crate::preset_meta::collect_embedded_category_names("app", filter)
708}
709
710async fn collect_fs_category_names(app_root: &Path, filter: Option<&str>) -> Result<Vec<String>> {
711 crate::preset_meta::collect_fs_category_names(app_root, filter, "app presets dir").await
712}
713
714fn collect_embedded_files(category: &str) -> Result<Vec<PathBuf>> {
715 let prefix = format!("app/{category}/");
716 let mut files = Vec::new();
717
718 for asset_path in presets::asset_paths(&prefix) {
719 let Some(rel) = asset_path.strip_prefix(&prefix) else {
720 continue;
721 };
722 if rel.is_empty() || rel == "shine.toml" {
723 continue;
724 }
725 files.push(normalize_relative(rel)?);
726 }
727
728 files.sort();
729 Ok(files)
730}
731
732fn parse_category_toml(name: &str, bytes: &[u8]) -> Result<CategoryToml> {
733 let parsed: CategoryToml = toml::from_slice(bytes)
734 .with_context(|| format!("failed to parse app/{name}/shine.toml"))?;
735
736 if let Some(dest) = parsed.dest.select_for_current_platform(name)? {
737 validate_dest(name, &dest)?;
738 }
739 if let Some(files) = &parsed.files {
740 for file in files {
741 file_matches_current_platform(name, file)?;
742 let context = format!("app/{name}/shine.toml");
743 resolve_transforms(file, &context)?;
744 resolve_install_strategy(file, &context)?;
745 resolve_generator(file.generator.clone(), &context)?;
746 }
747 }
748 resolve_hooks(
749 parsed.post_upgrade.clone(),
750 "post_upgrade",
751 &format!("app/{name}/shine.toml"),
752 )?;
753 resolve_hooks(
754 parsed.post_install.clone(),
755 "post_install",
756 &format!("app/{name}/shine.toml"),
757 )?;
758 resolve_artifact(parsed.artifact.clone(), &format!("app/{name}/shine.toml"))?;
759 Ok(parsed)
760}
761
762fn validate_dest(name: &str, dest: &str) -> Result<()> {
763 let expanded = crate::config::full_expand(dest)
764 .with_context(|| format!("failed to expand dest in app/{name}/shine.toml"))?;
765 if !Path::new(&expanded).is_absolute() {
766 bail!("app/{name}/shine.toml dest must be absolute after expansion");
767 }
768 let path = PathBuf::from(&expanded);
769 if path.components().any(|c| c == Component::ParentDir) {
770 bail!("app/{name}/shine.toml dest must not contain '..'");
771 }
772 Ok(())
773}
774
775impl DestToml {
776 fn select_for_current_platform(&self, category: &str) -> Result<Option<String>> {
777 self.select_for_platform(category, current_platform())
778 }
779
780 fn select_for_platform(&self, category: &str, current: &str) -> Result<Option<String>> {
781 match self {
782 Self::Single(dest) => Ok(Some(dest.clone())),
783 Self::Platforms(dest) => dest.select_for_platform(category, current),
784 }
785 }
786}
787
788impl PlatformDestToml {
789 fn select_for_platform(&self, category: &str, current: &str) -> Result<Option<String>> {
790 match current {
791 "windows" => Ok(self.windows.clone()),
792 "unix" => Ok(self.unix.clone()),
793 _ => bail!("app/{category}/shine.toml has unsupported current platform `{current}`"),
794 }
795 }
796}
797
798fn file_matches_current_platform(category: &str, file: &FileToml) -> Result<bool> {
799 file_matches_platform(category, file, current_platform())
800}
801
802fn file_matches_platform(category: &str, file: &FileToml, current: &str) -> Result<bool> {
803 crate::preset_meta::platform_matches(
804 file.platforms.as_deref(),
805 current,
806 &format!("app/{category}/shine.toml"),
807 )
808}
809
810fn normalize_relative(path: &str) -> Result<PathBuf> {
811 let path = Path::new(path);
812 if path.as_os_str().is_empty() {
813 bail!("path must not be empty");
814 }
815 if path.is_absolute() {
816 bail!("path must be relative");
817 }
818 if path.components().any(|c| matches!(c, Component::ParentDir)) {
819 bail!("path must not contain '..'");
820 }
821 Ok(path.to_path_buf())
822}
823
824fn parse_legacy_description(content: &[u8]) -> Option<String> {
825 presets::parse_script_description(content)
832 .into_iter()
833 .find(|line| !line.trim().is_empty())
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839
840 async fn write_test_category(root: &Path, name: &str) {
841 let category = root.join("app").join(name);
842 fs::create_dir_all(&category).await.unwrap();
843 fs::write(category.join("shine.toml"), "dest = \"~/.config/test\"\n")
844 .await
845 .unwrap();
846 fs::write(category.join("config.toml"), "test = true\n")
847 .await
848 .unwrap();
849 }
850
851 #[tokio::test]
852 async fn filtered_category_may_exist_in_only_one_merged_presets_root() {
853 let dir = std::env::temp_dir().join(format!(
854 "shine-app-metadata-merged-filter-{}",
855 uuid::Uuid::new_v4()
856 ));
857 let overlay = dir.join("overlay");
858 let mut config = Config::new_for_test(&dir);
859 config.presets_overlay_dir_override = Some(overlay.clone());
860
861 write_test_category(config.presets_dir(), "base-only").await;
862 write_test_category(&overlay, "overlay-only").await;
863
864 let base = load_installed_categories(&config, Some("base-only"))
865 .await
866 .unwrap();
867 let overlaid = load_installed_categories(&config, Some("overlay-only"))
868 .await
869 .unwrap();
870
871 assert_eq!(base.len(), 1);
872 assert_eq!(base[0].name, "base-only");
873 assert_eq!(overlaid.len(), 1);
874 assert_eq!(overlaid[0].name, "overlay-only");
875
876 fs::remove_dir_all(&dir).await.unwrap();
877 }
878
879 #[test]
880 fn embedded_vim_uses_metadata() {
881 let categories = load_embedded_categories(Some("vim")).unwrap();
882 let vim = categories.iter().find(|c| c.name == "vim").unwrap();
883 assert!(vim.uses_metadata);
884 assert_eq!(vim.destination_root.as_deref(), Some("~/.vim"));
885 assert!(!vim.files.is_empty());
886 }
887
888 #[test]
889 fn embedded_surge_installs_local_profile_resources() {
890 let categories = load_embedded_categories(Some("surge")).unwrap();
891 let surge = categories.iter().find(|c| c.name == "surge").unwrap();
892 assert!(surge.uses_metadata);
893 assert_eq!(
894 surge.destination_root.as_deref(),
895 Some("~/Library/Application Support/Surge/Profiles")
896 );
897 let files: Vec<_> = surge
898 .files
899 .iter()
900 .map(|file| {
901 (
902 file.source_rel.display().to_string(),
903 file.target_rel.display().to_string(),
904 )
905 })
906 .collect();
907 assert_eq!(
908 files,
909 vec![
910 (
911 "local-proxies.conf".to_string(),
912 "local-proxies.conf".to_string()
913 ),
914 (
915 "local-rules.conf".to_string(),
916 "local-rules.conf".to_string()
917 ),
918 ("rules/lan.list".to_string(), "rules/lan.list".to_string()),
919 (
920 "rules/lan-socks.list".to_string(),
921 "rules/lan-socks.list".to_string()
922 ),
923 (
924 "rules/other-direct.list".to_string(),
925 "rules/other-direct.list".to_string()
926 ),
927 (
928 "local-proxy-groups.conf".to_string(),
929 "local-proxy-groups.conf".to_string()
930 ),
931 (
932 "subscription-proxies.conf".to_string(),
933 "subscription-proxies.conf".to_string()
934 ),
935 ]
936 );
937 let subscription = surge
938 .files
939 .iter()
940 .find(|file| file.source_rel == Path::new("subscription-proxies.conf"))
941 .unwrap();
942 assert_eq!(
943 subscription.generator,
944 Some(AppGenerator {
945 script: PathBuf::from("generate-subscription.ts"),
946 runtime: ArtifactRuntime::Bun,
947 env: vec![EnvVarSpec {
948 source: "SURGE_SUBSCRIPTION_URL".to_string(),
949 target: "SURGE_SUBSCRIPTION_URL".to_string(),
950 }],
951 when_env: "SURGE_SUBSCRIPTION_URL".to_string(),
952 auto: false,
953 })
954 );
955 assert_eq!(
956 surge.post_upgrade,
957 vec![AppHook {
958 command: "/Applications/Surge.app/Contents/Applications/surge-cli".to_string(),
959 args: vec!["reload".to_string()],
960 show_output: false,
961 }]
962 );
963 assert_eq!(
964 surge.artifact,
965 Some(AppArtifact {
966 script: "build.ts".to_string(),
967 teardown: Some("unbuild.ts".to_string()),
968 runtime: ArtifactRuntime::Bun,
969 })
970 );
971 }
972
973 #[test]
974 fn post_upgrade_hook_parses_command_and_args() {
975 let parsed = parse_category_toml(
976 "sample",
977 br#"
978dest = "~/.config/sample"
979post_upgrade = { command = "/bin/echo", args = ["updated"] }
980
981[[files]]
982source = "config.toml"
983"#,
984 )
985 .unwrap();
986 let hooks = resolve_hooks(parsed.post_upgrade, "post_upgrade", "sample").unwrap();
987 assert_eq!(hooks.len(), 1);
988 assert_eq!(hooks[0].command, "/bin/echo");
989 assert_eq!(hooks[0].args, vec!["updated"]);
990 assert!(
991 !hooks[0].show_output,
992 "show_output must default to false when omitted"
993 );
994 }
995
996 #[test]
997 fn post_upgrade_hook_parses_show_output_flag() {
998 let parsed = parse_category_toml(
999 "sample",
1000 br#"
1001dest = "~/.config/sample"
1002post_upgrade = { command = "/bin/echo", args = ["updated"], show_output = true }
1003
1004[[files]]
1005source = "config.toml"
1006"#,
1007 )
1008 .unwrap();
1009 let hooks = resolve_hooks(parsed.post_upgrade, "post_upgrade", "sample").unwrap();
1010 assert_eq!(hooks.len(), 1);
1011 assert!(hooks[0].show_output);
1012 }
1013
1014 #[test]
1015 fn post_upgrade_hook_parses_multiple_commands() {
1016 let parsed = parse_category_toml(
1017 "sample",
1018 br#"
1019dest = "~/.config/sample"
1020post_upgrade = [
1021 { command = "/bin/echo", args = ["updated"] },
1022 { command = "/bin/echo", args = ["reloaded"] },
1023]
1024
1025[[files]]
1026source = "config.toml"
1027"#,
1028 )
1029 .unwrap();
1030 let hooks = resolve_hooks(parsed.post_upgrade, "post_upgrade", "sample").unwrap();
1031 assert_eq!(hooks.len(), 2);
1032 assert_eq!(hooks[0].args, vec!["updated"]);
1033 assert_eq!(hooks[1].args, vec!["reloaded"]);
1034 }
1035
1036 #[test]
1037 fn artifact_script_parses() {
1038 let parsed = parse_category_toml(
1039 "sample",
1040 br#"
1041dest = "~/.config/sample"
1042
1043[artifact]
1044script = "build.sh"
1045
1046[[files]]
1047source = "config.toml"
1048"#,
1049 )
1050 .unwrap();
1051 let artifact = resolve_artifact(parsed.artifact, "sample").unwrap();
1052 assert_eq!(
1053 artifact,
1054 Some(AppArtifact {
1055 script: "build.sh".to_string(),
1056 teardown: None,
1057 runtime: ArtifactRuntime::Native,
1058 })
1059 );
1060 }
1061
1062 #[test]
1063 fn artifact_teardown_parses() {
1064 let parsed = parse_category_toml(
1065 "sample",
1066 br#"
1067dest = "~/.config/sample"
1068
1069[artifact]
1070script = "build.sh"
1071teardown = "unbuild.sh"
1072
1073[[files]]
1074source = "config.toml"
1075"#,
1076 )
1077 .unwrap();
1078 let artifact = resolve_artifact(parsed.artifact, "sample").unwrap();
1079 assert_eq!(
1080 artifact,
1081 Some(AppArtifact {
1082 script: "build.sh".to_string(),
1083 teardown: Some("unbuild.sh".to_string()),
1084 runtime: ArtifactRuntime::Native,
1085 })
1086 );
1087 }
1088
1089 #[test]
1090 fn artifact_empty_teardown_is_rejected() {
1091 let parsed = parse_category_toml(
1092 "sample",
1093 br#"
1094dest = "~/.config/sample"
1095
1096[artifact]
1097script = "build.sh"
1098teardown = " "
1099
1100[[files]]
1101source = "config.toml"
1102"#,
1103 );
1104 let err = parsed.unwrap_err();
1105 assert!(
1106 err.to_string()
1107 .contains("artifact.teardown must not be empty")
1108 );
1109 }
1110
1111 #[test]
1112 fn post_install_hook_parses_single_and_array() {
1113 let single = parse_category_toml(
1114 "sample",
1115 br#"
1116dest = "~/.config/sample"
1117post_install = { command = "/bin/echo", args = ["installed"] }
1118
1119[[files]]
1120source = "config.toml"
1121"#,
1122 )
1123 .unwrap();
1124 let hooks = resolve_hooks(single.post_install, "post_install", "sample").unwrap();
1125 assert_eq!(hooks.len(), 1);
1126 assert_eq!(hooks[0].command, "/bin/echo");
1127 assert_eq!(hooks[0].args, vec!["installed"]);
1128
1129 let multiple = parse_category_toml(
1130 "sample",
1131 br#"
1132dest = "~/.config/sample"
1133post_install = [
1134 { command = "/bin/echo", args = ["a"] },
1135 { command = "/bin/echo", args = ["b"] },
1136]
1137
1138[[files]]
1139source = "config.toml"
1140"#,
1141 )
1142 .unwrap();
1143 let hooks = resolve_hooks(multiple.post_install, "post_install", "sample").unwrap();
1144 assert_eq!(hooks.len(), 2);
1145 }
1146
1147 #[test]
1148 fn post_install_empty_command_is_rejected() {
1149 let err = parse_category_toml(
1150 "sample",
1151 br#"
1152dest = "~/.config/sample"
1153post_install = { command = " " }
1154
1155[[files]]
1156source = "config.toml"
1157"#,
1158 )
1159 .unwrap_err();
1160 assert!(
1161 err.to_string()
1162 .contains("post_install.command must not be empty")
1163 );
1164 }
1165
1166 #[test]
1167 fn artifact_section_absent_is_none() {
1168 let parsed = parse_category_toml(
1169 "sample",
1170 br#"
1171dest = "~/.config/sample"
1172
1173[[files]]
1174source = "config.toml"
1175"#,
1176 )
1177 .unwrap();
1178 assert!(
1179 resolve_artifact(parsed.artifact, "sample")
1180 .unwrap()
1181 .is_none()
1182 );
1183 }
1184
1185 #[test]
1186 fn artifact_empty_script_is_rejected() {
1187 let parsed = parse_category_toml(
1188 "sample",
1189 br#"
1190dest = "~/.config/sample"
1191
1192[artifact]
1193script = ""
1194
1195[[files]]
1196source = "config.toml"
1197"#,
1198 );
1199 let err = parsed.unwrap_err();
1200 assert!(err.to_string().contains("artifact.script"));
1201 }
1202
1203 #[test]
1204 fn artifact_runtime_defaults_native_and_bun_requires_bun_extension() {
1205 let parse = |body: &str| -> CategoryToml {
1206 toml::from_str(&format!(
1207 "description = \"S\"\ndest = \"~/x\"\n\n{body}\n\n[[files]]\nsource = \"c\"\n"
1208 ))
1209 .unwrap()
1210 };
1211
1212 let native = resolve_artifact(parse("[artifact]\nscript = \"build.sh\"").artifact, "s")
1214 .unwrap()
1215 .unwrap();
1216 assert_eq!(native.runtime, ArtifactRuntime::Native);
1217
1218 let bun = resolve_artifact(
1220 parse(
1221 "[artifact]\nscript = \"build.ts\"\nteardown = \"unbuild.ts\"\nruntime = \"bun\"",
1222 )
1223 .artifact,
1224 "s",
1225 )
1226 .unwrap()
1227 .unwrap();
1228 assert_eq!(bun.runtime, ArtifactRuntime::Bun);
1229
1230 assert!(
1232 resolve_artifact(
1233 parse("[artifact]\nscript = \"build.sh\"\nruntime = \"bun\"").artifact,
1234 "s"
1235 )
1236 .is_err()
1237 );
1238 assert!(
1239 resolve_artifact(
1240 parse(
1241 "[artifact]\nscript = \"build.ts\"\nteardown = \"unbuild.sh\"\nruntime = \"bun\""
1242 )
1243 .artifact,
1244 "s"
1245 )
1246 .is_err()
1247 );
1248 }
1249
1250 #[test]
1251 fn embedded_surge_declares_artifact_script() {
1252 let categories = load_embedded_categories(Some("surge")).unwrap();
1253 let surge = categories.iter().find(|c| c.name == "surge").unwrap();
1254 assert_eq!(
1255 surge.artifact,
1256 Some(AppArtifact {
1257 script: "build.ts".to_string(),
1258 teardown: Some("unbuild.ts".to_string()),
1259 runtime: ArtifactRuntime::Bun,
1260 })
1261 );
1262 }
1263
1264 #[test]
1265 fn embedded_clash_verge_installs_plain_merge_profile() {
1266 let categories = load_embedded_categories(Some("clash-verge")).unwrap();
1267 let clash = categories.iter().find(|c| c.name == "clash-verge").unwrap();
1268 assert!(clash.uses_metadata);
1269 assert_eq!(
1270 clash.destination_root.as_deref(),
1271 Some("~/.shine/clash-verge")
1272 );
1273
1274 assert_eq!(clash.files.len(), 1);
1275 let file = &clash.files[0];
1276 assert_eq!(file.source_rel, std::path::Path::new("merge.yaml"));
1277 assert_eq!(file.target_rel, std::path::Path::new("merge.yaml"));
1278 assert!(file.transforms.is_empty());
1281 assert_eq!(file.install_strategy, AppInstallStrategy::Copy);
1282
1283 let merge = include_str!("../../../presets/app/clash-verge/merge.yaml");
1284 assert!(merge.contains("# proxies:"));
1285 assert!(merge.contains("# proxy-groups:"));
1286 assert!(merge.contains("# prepend-rules:"));
1287 assert!(merge.contains("type: file, behavior: classical, format: text"));
1288 assert!(merge.contains("http://127.0.0.1:8080/rules/lan.list"));
1289 assert!(merge.contains("https://rules.example.com/surge/lan.list"));
1290
1291 let build_hook = vec![AppHook {
1295 command: "shine".to_string(),
1296 args: vec![
1297 "app".to_string(),
1298 "artifact".to_string(),
1299 "apply".to_string(),
1300 "clash-verge".to_string(),
1301 ],
1302 show_output: true,
1303 }];
1304 assert_eq!(clash.post_install, build_hook);
1305 assert_eq!(clash.post_upgrade, build_hook);
1306 assert_eq!(
1307 clash.artifact,
1308 Some(AppArtifact {
1309 script: "build.ts".to_string(),
1310 teardown: Some("unbuild.ts".to_string()),
1311 runtime: ArtifactRuntime::Bun,
1312 })
1313 );
1314 }
1315
1316 #[test]
1317 fn legacy_description_is_first_comment_line_only() {
1318 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";
1321 assert_eq!(
1322 parse_legacy_description(yaml).as_deref(),
1323 Some("Clash Verge Rev merge profile. Summary line.")
1324 );
1325 let gitconfig = b"# shine-dest: ~/.gitconfig\n# Personal git configuration.\n\n[pull]\n";
1327 assert_eq!(
1328 parse_legacy_description(gitconfig).as_deref(),
1329 Some("Personal git configuration.")
1330 );
1331 assert_eq!(parse_legacy_description(b"proxies: []\n"), None);
1333 }
1334
1335 #[test]
1336 fn embedded_git_stays_legacy() {
1337 let categories = load_embedded_categories(Some("git")).unwrap();
1338 let git = categories.iter().find(|c| c.name == "git").unwrap();
1339 assert!(!git.uses_metadata);
1340 assert_eq!(git.files.len(), 1);
1341 assert_eq!(
1342 git.files[0].legacy_dest_annotation.as_deref(),
1343 Some("~/.gitconfig")
1344 );
1345 }
1346
1347 #[test]
1348 fn embedded_docker_engine_has_jsonc_transform() {
1349 let categories = load_embedded_categories(Some("docker-engine")).unwrap();
1350 let docker = categories
1351 .iter()
1352 .find(|c| c.name == "docker-engine")
1353 .unwrap();
1354 assert!(docker.uses_metadata);
1355 #[cfg(windows)]
1356 assert_eq!(docker.destination_root.as_deref(), Some("~/.docker"));
1357 #[cfg(not(windows))]
1358 assert_eq!(docker.destination_root.as_deref(), Some("/etc/docker"));
1359 assert_eq!(docker.files.len(), 1);
1360
1361 let file = &docker.files[0];
1362 assert_eq!(file.source_rel, std::path::Path::new("daemon.jsonc"));
1363 assert_eq!(file.target_rel, std::path::Path::new("daemon.json"));
1364 assert_eq!(file.transforms, vec!["template", "jsonc-to-json"]);
1365 assert_eq!(file.install_strategy, AppInstallStrategy::Copy);
1366 assert!(file.requires_admin);
1367 assert!(
1368 file.restart_hint
1369 .as_deref()
1370 .is_some_and(|hint| hint.contains("Restart Docker Engine"))
1371 );
1372 }
1373
1374 #[test]
1375 fn embedded_docker_desktop_uses_json_merge_install_strategy() {
1376 let categories = load_embedded_categories(Some("docker-desktop")).unwrap();
1377 #[cfg(not(windows))]
1378 {
1379 assert!(categories.is_empty());
1380 }
1381
1382 #[cfg(windows)]
1383 let docker = categories
1384 .iter()
1385 .find(|c| c.name == "docker-desktop")
1386 .unwrap();
1387
1388 #[cfg(windows)]
1389 {
1390 assert!(docker.uses_metadata);
1391 assert_eq!(docker.files.len(), 1);
1392 let file = &docker.files[0];
1393 assert_eq!(file.target_rel, std::path::Path::new("settings-store.json"));
1394 assert_eq!(file.transforms, vec!["template", "jsonc-to-json"]);
1395 assert_eq!(
1396 file.install_strategy,
1397 AppInstallStrategy::JsonMerge {
1398 managed_keys: vec!["proxy".to_string(), "containersProxy".to_string()],
1399 }
1400 );
1401 }
1402 }
1403
1404 #[test]
1405 fn embedded_archey4_is_unix_only() {
1406 let categories = load_embedded_categories(Some("archey4")).unwrap();
1407
1408 #[cfg(windows)]
1409 {
1410 assert!(categories.is_empty());
1411 return;
1412 }
1413
1414 #[cfg(not(windows))]
1415 {
1416 let archey4 = categories.iter().find(|c| c.name == "archey4").unwrap();
1417 assert!(archey4.uses_metadata);
1418 assert_eq!(
1419 archey4.destination_root.as_deref(),
1420 Some("~/.config/archey4")
1421 );
1422 }
1423 }
1424
1425 #[test]
1426 fn unix_absolute_dest_is_valid_on_all_platforms() {
1427 parse_category_toml("docker-engine", b"dest = \"/etc/docker\"\n").unwrap();
1428 }
1429
1430 #[test]
1431 fn platform_dest_selects_current_platform() {
1432 let parsed = parse_category_toml(
1433 "docker-engine",
1434 b"[dest]\nwindows = \"~/.docker\"\nunix = \"/etc/docker\"\n",
1435 )
1436 .unwrap();
1437
1438 #[cfg(windows)]
1439 assert_eq!(
1440 parsed
1441 .dest
1442 .select_for_current_platform("docker-engine")
1443 .unwrap(),
1444 Some("~/.docker".to_string())
1445 );
1446 #[cfg(not(windows))]
1447 assert_eq!(
1448 parsed
1449 .dest
1450 .select_for_current_platform("docker-engine")
1451 .unwrap(),
1452 Some("/etc/docker".to_string())
1453 );
1454 }
1455
1456 #[test]
1457 fn unsupported_file_platform_is_rejected() {
1458 let err = parse_category_toml(
1459 "docker-engine",
1460 br#"
1461dest = "/etc/docker"
1462
1463[[files]]
1464source = "daemon.jsonc"
1465platforms = ["plan9"]
1466"#,
1467 )
1468 .unwrap_err();
1469
1470 assert!(err.to_string().contains("unsupported platform"));
1471 }
1472
1473 #[test]
1474 fn file_platform_filter_matches_expected_platforms() {
1475 let windows_only: FileToml = toml::from_str(
1476 r#"
1477source = "daemon.jsonc"
1478platforms = ["windows"]
1479"#,
1480 )
1481 .unwrap();
1482 let unix_only: FileToml = toml::from_str(
1483 r#"
1484source = "daemon.jsonc"
1485platforms = ["unix"]
1486"#,
1487 )
1488 .unwrap();
1489
1490 assert!(file_matches_platform("docker-engine", &windows_only, "windows").unwrap());
1491 assert!(!file_matches_platform("docker-engine", &windows_only, "unix").unwrap());
1492 assert!(file_matches_platform("docker-engine", &unix_only, "unix").unwrap());
1493 assert!(!file_matches_platform("docker-engine", &unix_only, "windows").unwrap());
1494 }
1495
1496 #[test]
1497 fn json_merge_requires_managed_keys() {
1498 let err = parse_category_toml(
1499 "docker-desktop",
1500 br#"
1501dest = "~/.docker/desktop"
1502
1503[[files]]
1504source = "settings-store.jsonc"
1505target = "settings-store.json"
1506install_mode = "json-merge"
1507"#,
1508 )
1509 .unwrap_err();
1510
1511 assert!(err.to_string().contains("managed_keys"));
1512 }
1513
1514 #[test]
1515 fn embedded_ghostty_has_theme_files_with_template_transform() {
1516 let categories = load_embedded_categories(Some("ghostty")).unwrap();
1517
1518 #[cfg(windows)]
1519 {
1520 assert!(categories.is_empty());
1521 return;
1522 }
1523
1524 #[cfg(not(windows))]
1525 {
1526 let ghostty = categories.iter().find(|c| c.name == "ghostty").unwrap();
1527 assert!(ghostty.uses_metadata);
1528 assert!(ghostty.has_explicit_files);
1529 assert_eq!(
1530 ghostty.destination_root.as_deref(),
1531 Some("~/.config/ghostty")
1532 );
1533 assert_eq!(ghostty.list_mode, AppListMode::Category);
1534 assert_eq!(ghostty.files.len(), 6);
1535
1536 let shine_light = ghostty
1537 .files
1538 .iter()
1539 .find(|f| f.source_rel == std::path::Path::new("themes/Shine Light"))
1540 .unwrap();
1541 assert_eq!(
1542 shine_light.target_rel,
1543 std::path::Path::new("themes/Shine Light")
1544 );
1545 assert_eq!(shine_light.transforms, vec!["template"]);
1546
1547 let light = ghostty
1548 .files
1549 .iter()
1550 .find(|f| f.source_rel == std::path::Path::new("themes/iTerm2 Solarized Light"))
1551 .unwrap();
1552 assert_eq!(
1553 light.target_rel,
1554 std::path::Path::new("themes/light_iTerm2 Solarized Light")
1555 );
1556 assert_eq!(light.transforms, vec!["template"]);
1557
1558 let dark = ghostty
1559 .files
1560 .iter()
1561 .find(|f| f.source_rel == std::path::Path::new("themes/Alien Blood"))
1562 .unwrap();
1563 assert_eq!(
1564 dark.target_rel,
1565 std::path::Path::new("themes/dark_Alien Blood")
1566 );
1567 assert_eq!(dark.transforms, vec!["template"]);
1568
1569 let atom = ghostty
1570 .files
1571 .iter()
1572 .find(|f| f.source_rel == std::path::Path::new("themes/Atom One Light"))
1573 .unwrap();
1574 assert_eq!(
1575 atom.target_rel,
1576 std::path::Path::new("themes/light_Atom One Light")
1577 );
1578 assert_eq!(atom.transforms, vec!["template"]);
1579
1580 let github = ghostty
1581 .files
1582 .iter()
1583 .find(|f| f.source_rel == std::path::Path::new("themes/Github Light Default"))
1584 .unwrap();
1585 assert_eq!(
1586 github.target_rel,
1587 std::path::Path::new("themes/light_Github Light Default")
1588 );
1589 assert_eq!(github.transforms, vec!["template"]);
1590 }
1591 }
1592
1593 #[test]
1594 fn unknown_transform_rejected_at_load() {
1595 let toml =
1596 b"dest = \"/tmp\"\n[[files]]\nsource = \"f\"\ntransform = \"no-such-transform\"\n";
1597 assert!(
1598 parse_category_toml("test", toml).is_err() || {
1599 let file = FileToml {
1602 source: "f".to_string(),
1603 target: None,
1604 description: None,
1605 display_name: None,
1606 platforms: None,
1607 transform: Some("no-such-transform".to_string()),
1608 transforms: None,
1609 install_mode: None,
1610 managed_keys: None,
1611 requires_admin: false,
1612 restart_hint: None,
1613 generator: None,
1614 };
1615 resolve_transforms(&file, "test").is_err()
1616 }
1617 );
1618 }
1619
1620 #[test]
1621 fn both_transform_and_transforms_rejected() {
1622 let file = FileToml {
1623 source: "f".to_string(),
1624 target: None,
1625 description: None,
1626 display_name: None,
1627 platforms: None,
1628 transform: Some("jsonc-to-json".to_string()),
1629 transforms: Some(vec!["jsonc-to-json".to_string()]),
1630 install_mode: None,
1631 managed_keys: None,
1632 requires_admin: false,
1633 restart_hint: None,
1634 generator: None,
1635 };
1636 assert!(resolve_transforms(&file, "test").is_err());
1637 }
1638
1639 #[test]
1640 fn generator_metadata_parses_and_validates_condition_env() {
1641 let parsed = parse_category_toml(
1642 "sample",
1643 br#"
1644dest = "/tmp"
1645[[files]]
1646source = "fallback.conf"
1647generator = { script = "generate.ts", runtime = "bun", env = ["SOURCE_URL"], when_env = "SOURCE_URL" }
1648"#,
1649 )
1650 .unwrap();
1651 let generator = resolve_generator(
1652 parsed.files.unwrap().remove(0).generator,
1653 "app/sample/shine.toml",
1654 )
1655 .unwrap()
1656 .unwrap();
1657 assert_eq!(generator.script, Path::new("generate.ts"));
1658 assert_eq!(generator.runtime, ArtifactRuntime::Bun);
1659 assert_eq!(generator.when_env, "SOURCE_URL");
1660 assert!(generator.auto);
1661 }
1662
1663 #[test]
1664 fn generator_auto_can_be_disabled() {
1665 let parsed = parse_category_toml(
1666 "sample",
1667 br#"
1668description = "sample"
1669dest = "~/.config/sample"
1670
1671[[files]]
1672source = "fallback.txt"
1673generator = { script = "generate.ts", runtime = "bun", env = ["SOURCE_URL"], when_env = "SOURCE_URL", auto = false }
1674"#,
1675 )
1676 .unwrap();
1677 let generator = resolve_generator(
1678 parsed.files.unwrap().remove(0).generator,
1679 "app/sample/shine.toml",
1680 )
1681 .unwrap()
1682 .unwrap();
1683 assert!(!generator.auto);
1684 }
1685
1686 #[test]
1687 fn generator_condition_must_be_in_declared_env() {
1688 let error = parse_category_toml(
1689 "sample",
1690 br#"
1691dest = "/tmp"
1692[[files]]
1693source = "fallback.conf"
1694generator = { script = "generate.ts", runtime = "bun", env = ["OTHER_URL"], when_env = "SOURCE_URL" }
1695"#,
1696 )
1697 .unwrap_err();
1698 assert!(error.to_string().contains("must be declared"));
1699 }
1700}