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