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