Skip to main content

cli/apps/
metadata.rs

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