Skip to main content

cli/apps/
metadata.rs

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