Skip to main content

cli/shells/
metadata.rs

1use crate::config::Config;
2use crate::platform::{OperatingSystem, current_platform};
3use crate::presets;
4use anyhow::{Context, Result, bail};
5use serde::Deserialize;
6#[cfg(test)]
7use std::collections::BTreeMap;
8use std::collections::BTreeSet;
9use std::path::{Component, Path, PathBuf};
10use tokio::fs;
11
12use crate::preset_validation::PresetValidationFailure;
13
14#[derive(Debug, Clone)]
15pub struct ShellCategory {
16    pub name: String,
17    pub description: Option<String>,
18    pub files: Vec<ShellFile>,
19    // Tracks whether the category came from an explicit metadata file vs. auto-collection;
20    // reserved for future upgrade/list logic, mirroring AppCategory::uses_metadata.
21    #[allow(dead_code)]
22    pub uses_metadata: bool,
23}
24
25#[derive(Debug, Clone)]
26pub struct ShellFile {
27    pub source_rel: PathBuf,
28    pub command_name: String,
29    pub description: Vec<String>,
30    pub needs_source: bool,
31    /// How the command is invoked: native (symlink/shim) or via the `bun` runtime.
32    pub runtime: crate::bin_links::LinkRuntime,
33    /// Declared install-time transforms (e.g. `["template"]`) applied to the source
34    /// before linking. Empty means "no metadata-declared transform" — native scripts
35    /// may still opt into templating via the `# shine-template: true` annotation.
36    pub transforms: Vec<String>,
37    /// Runtime environment values injected into a Bun launcher via `Bun.env`,
38    /// using the `env run --with` grammar (ordered). Empty for native entries;
39    /// only `runtime = "bun"` entries may declare it (enforced at metadata load).
40    pub env: Vec<crate::env::EnvVarSpec>,
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub struct ShellTarget<'a> {
45    pub category: &'a str,
46    pub command: Option<&'a str>,
47}
48
49/// Parse the scoped shell lifecycle grammar: `category` or `category/command`.
50/// Bare command aliases remain inspection-only so mutation targets stay unambiguous.
51pub fn parse_lifecycle_target(target: &str) -> Result<ShellTarget<'_>> {
52    let target = target.trim();
53    if target.is_empty() {
54        bail!("shell preset target must not be empty");
55    }
56    let mut parts = target.split('/');
57    let category = parts.next().unwrap_or_default();
58    let command = parts.next();
59    if category.is_empty() || command.is_some_and(str::is_empty) || parts.next().is_some() {
60        bail!(
61            "invalid shell preset target `{target}`; expected <category> or <category>/<command>"
62        );
63    }
64    Ok(ShellTarget { category, command })
65}
66
67/// Select and validate one shell lifecycle target from the active preset namespace.
68pub async fn load_active_target(
69    config: &Config,
70    target: ShellTarget<'_>,
71) -> Result<Vec<ShellCategory>> {
72    let mut categories = load_active_categories(config, Some(target.category)).await?;
73    let Some(category) = categories.first_mut() else {
74        bail!("shell preset category not found: {}", target.category);
75    };
76    if let Some(command) = target.command {
77        category.files.retain(|file| file.command_name == command);
78        if category.files.is_empty() {
79            bail!(
80                "shell preset command not found: {}/{}",
81                target.category,
82                command
83            );
84        }
85    }
86    Ok(categories)
87}
88
89#[derive(Debug, Deserialize)]
90struct CategoryToml {
91    description: Option<String>,
92    files: Option<Vec<FileToml>>,
93}
94
95#[derive(Debug, Deserialize)]
96struct FileToml {
97    source: String,
98    target: Option<String>,
99    description: Option<String>,
100    needs_source: Option<bool>,
101    platforms: Option<Vec<String>>,
102    runtime: Option<String>,
103    transforms: Option<Vec<String>>,
104    env: Option<Vec<String>>,
105}
106
107pub fn load_embedded_categories(filter: Option<&str>) -> Result<Vec<ShellCategory>> {
108    let names = collect_embedded_category_names(filter);
109    let mut categories = Vec::new();
110    for name in names {
111        categories.push(load_embedded_category(&name)?);
112    }
113    Ok(categories)
114}
115
116pub async fn load_installed_categories(
117    config: &Config,
118    filter: Option<&str>,
119) -> Result<Vec<ShellCategory>> {
120    let shell_root = config.presets_dir().join("shell");
121    let mut names: BTreeSet<String> = collect_fs_category_names(&shell_root, filter)
122        .await?
123        .into_iter()
124        .collect();
125    if let Some(overlay) = config.active_presets_overlay_dir() {
126        names.extend(collect_fs_category_names(&overlay.join("shell"), filter).await?);
127    }
128    let mut categories = Vec::new();
129    for name in names {
130        categories.push(load_installed_category(config, &name).await?);
131    }
132    Ok(categories)
133}
134
135/// Loads categories from whichever source is active: installed (external
136/// presets mode) or embedded. Replaces the `if config.is_external_presets {
137/// load_installed_categories } else { load_embedded_categories }` branch
138/// repeated at every call site.
139pub async fn load_active_categories(
140    config: &Config,
141    filter: Option<&str>,
142) -> Result<Vec<ShellCategory>> {
143    if config.is_external_presets {
144        load_installed_categories(config, filter).await
145    } else {
146        load_embedded_categories(filter)
147    }
148}
149
150fn load_embedded_category(name: &str) -> Result<ShellCategory> {
151    let metadata_path = format!("shell/{name}/shine.toml");
152    if let Some(bytes) = presets::read_asset_bytes(&metadata_path) {
153        let parsed = parse_category_toml(name, &bytes)?;
154        let files = match parsed.files {
155            Some(files) => files
156                .into_iter()
157                .filter_map(|file| match file_matches_current_platform(name, &file) {
158                    Ok(true) => Some(Ok(file)),
159                    Ok(false) => None,
160                    Err(err) => Some(Err(err)),
161                })
162                .map(|file| {
163                    let file = file?;
164                    let ctx = format!("shell/{name}/shine.toml");
165                    let resolved = resolve_metadata_file(&file, &ctx)?;
166                    let asset_path = format!("shell/{name}/{}", resolved.source_rel.display());
167                    let bytes = presets::read_asset_bytes(&asset_path).with_context(|| {
168                        format!(
169                            "shell/{name}/shine.toml references missing file: {:?}",
170                            resolved.source_rel
171                        )
172                    })?;
173                    let description = resolved.describe(&bytes);
174                    Ok(resolved.into_shell_file(description))
175                })
176                .collect::<Result<Vec<_>>>()?,
177            None => collect_embedded_scripts(name)?
178                .into_iter()
179                .map(|source_rel| {
180                    let asset_path = format!("shell/{name}/{}", source_rel.display());
181                    let bytes = presets::read_asset_bytes(&asset_path).unwrap_or_default();
182                    let command_name = default_command_name(&source_rel)?;
183                    Ok(ShellFile {
184                        source_rel,
185                        command_name,
186                        description: presets::parse_script_description(&bytes),
187                        needs_source: false,
188                        runtime: crate::bin_links::LinkRuntime::Native,
189                        transforms: Vec::new(),
190                        env: Vec::new(),
191                    })
192                })
193                .collect::<Result<Vec<_>>>()?,
194        };
195
196        return Ok(ShellCategory {
197            name: name.to_string(),
198            description: parsed.description,
199            files,
200            uses_metadata: true,
201        });
202    }
203
204    Ok(ShellCategory {
205        name: name.to_string(),
206        description: None,
207        files: collect_embedded_scripts(name)?
208            .into_iter()
209            .map(|source_rel| {
210                let asset_path = format!("shell/{name}/{}", source_rel.display());
211                let bytes = presets::read_asset_bytes(&asset_path).unwrap_or_default();
212                Ok(ShellFile {
213                    command_name: default_command_name(&source_rel)?,
214                    description: presets::parse_script_description(&bytes),
215                    needs_source: false,
216                    runtime: crate::bin_links::LinkRuntime::Native,
217                    transforms: Vec::new(),
218                    env: Vec::new(),
219                    source_rel,
220                })
221            })
222            .collect::<Result<Vec<_>>>()?,
223        uses_metadata: false,
224    })
225}
226
227async fn load_installed_category(config: &Config, name: &str) -> Result<ShellCategory> {
228    let category_rel = Path::new("shell").join(name);
229    let metadata_path = config.preset_path(category_rel.join("shine.toml"));
230
231    if metadata_path.exists() {
232        let bytes = fs::read(&metadata_path)
233            .await
234            .with_context(|| format!("reading metadata: {}", metadata_path.display()))?;
235        let parsed = parse_category_toml(name, &bytes)?;
236        let files = match parsed.files {
237            Some(files) => files
238                .into_iter()
239                .filter_map(|file| match file_matches_current_platform(name, &file) {
240                    Ok(true) => Some(Ok(file)),
241                    Ok(false) => None,
242                    Err(err) => Some(Err(err)),
243                })
244                .map(|file| {
245                    let file = file?;
246                    let ctx = metadata_path.display().to_string();
247                    resolve_metadata_file(&file, &ctx)
248                })
249                .collect::<Result<Vec<_>>>()?,
250            None => collect_merged_fs_scripts(config, &category_rel)
251                .await?
252                .into_iter()
253                .map(|source_rel| {
254                    let command_name = default_command_name(&source_rel)?;
255                    Ok(ResolvedFile::native(source_rel, command_name))
256                })
257                .collect::<Result<Vec<_>>>()?,
258        };
259
260        let mut shell_files = Vec::new();
261        for resolved in files {
262            let source_path = config.preset_path(category_rel.join(&resolved.source_rel));
263            if !source_path.exists() {
264                bail!(
265                    "shell/{name}/shine.toml references missing file: {}",
266                    resolved.source_rel.display()
267                );
268            }
269            let bytes = fs::read(&source_path)
270                .await
271                .with_context(|| format!("reading preset file: {}", source_path.display()))?;
272            let description = resolved.describe(&bytes);
273            shell_files.push(resolved.into_shell_file(description));
274        }
275
276        return Ok(ShellCategory {
277            name: name.to_string(),
278            description: parsed.description,
279            files: shell_files,
280            uses_metadata: true,
281        });
282    }
283
284    let mut files = Vec::new();
285    for source_rel in collect_merged_fs_scripts(config, &category_rel).await? {
286        let source_path = config.preset_path(category_rel.join(&source_rel));
287        let bytes = fs::read(&source_path)
288            .await
289            .with_context(|| format!("reading preset file: {}", source_path.display()))?;
290        files.push(ShellFile {
291            command_name: default_command_name(&source_rel)?,
292            description: presets::parse_script_description(&bytes),
293            needs_source: false,
294            runtime: crate::bin_links::LinkRuntime::Native,
295            transforms: Vec::new(),
296            env: Vec::new(),
297            source_rel,
298        });
299    }
300
301    Ok(ShellCategory {
302        name: name.to_string(),
303        description: None,
304        files,
305        uses_metadata: false,
306    })
307}
308
309async fn collect_merged_fs_scripts(config: &Config, category_rel: &Path) -> Result<Vec<PathBuf>> {
310    crate::preset_meta::merge_fs_tree(config, category_rel, "directory", |rel| {
311        if !is_shell_script(rel) {
312            return Ok(None);
313        }
314        Ok(Some(normalize_shell_source(rel)?))
315    })
316    .await
317}
318
319fn parse_category_toml(name: &str, bytes: &[u8]) -> Result<CategoryToml> {
320    toml::from_slice(bytes).with_context(|| format!("failed to parse shell/{name}/shine.toml"))
321}
322
323fn file_matches_current_platform(category: &str, file: &FileToml) -> Result<bool> {
324    file_matches_platform(category, file, current_platform())
325}
326
327fn file_matches_platform(
328    category: &str,
329    file: &FileToml,
330    current: OperatingSystem,
331) -> Result<bool> {
332    crate::preset_meta::platform_matches(
333        file.platforms.as_deref(),
334        current,
335        &format!("shell/{category}/shine.toml"),
336    )
337}
338
339#[cfg(test)]
340pub(crate) fn built_in_platform_availability() -> Result<BTreeMap<String, BTreeSet<OperatingSystem>>>
341{
342    let mut capabilities: BTreeMap<String, BTreeSet<OperatingSystem>> = BTreeMap::new();
343    for name in crate::preset_meta::collect_pristine_embedded_category_names("shell") {
344        let metadata_path = format!("shell/{name}/shine.toml");
345        if let Some(bytes) = presets::read_embedded_asset_bytes(&metadata_path) {
346            let parsed = parse_category_toml(&name, &bytes)?;
347            if let Some(files) = parsed.files {
348                for file in files {
349                    let command = resolve_metadata_file(&file, &metadata_path)?.command_name;
350                    let platforms = capabilities
351                        .entry(format!("shell/{name}/{command}"))
352                        .or_default();
353                    for platform in OperatingSystem::ALL {
354                        if file_matches_platform(&name, &file, platform)? {
355                            platforms.insert(platform);
356                        }
357                    }
358                }
359                continue;
360            }
361        }
362
363        let prefix = format!("shell/{name}/");
364        for asset_path in presets::embedded_asset_paths(&format!("shell/{name}")) {
365            let Some(relative) = asset_path.strip_prefix(&prefix) else {
366                continue;
367            };
368            let source = PathBuf::from(relative);
369            if relative == "shine.toml" || !is_shell_script(&source) {
370                continue;
371            }
372            let command = default_command_name(&source)?;
373            capabilities.insert(
374                format!("shell/{name}/{command}"),
375                OperatingSystem::ALL.into_iter().collect(),
376            );
377        }
378    }
379    Ok(capabilities)
380}
381
382fn collect_embedded_category_names(filter: Option<&str>) -> Vec<String> {
383    crate::preset_meta::collect_embedded_category_names("shell", filter)
384}
385
386async fn collect_fs_category_names(shell_root: &Path, filter: Option<&str>) -> Result<Vec<String>> {
387    crate::preset_meta::collect_fs_category_names(shell_root, filter, "shell presets directory")
388        .await
389}
390
391fn collect_embedded_scripts(name: &str) -> Result<Vec<PathBuf>> {
392    let prefix = format!("shell/{name}/");
393    let mut scripts = BTreeSet::new();
394    for asset_path in presets::asset_paths(&format!("shell/{name}")) {
395        let Some(rest) = asset_path.strip_prefix(&prefix) else {
396            continue;
397        };
398        if rest == "shine.toml" {
399            continue;
400        }
401        let rel = PathBuf::from(rest);
402        if !is_shell_script(&rel) {
403            continue;
404        }
405        scripts.insert(normalize_shell_source(rest)?);
406    }
407    Ok(scripts.into_iter().collect())
408}
409
410/// Validate a `[[files]]` `source` as a safe relative path (no absolute, no `..`,
411/// not `shine.toml`) without checking its extension.
412fn normalize_relative_source(path: impl AsRef<Path>) -> Result<PathBuf> {
413    let path = path.as_ref();
414    if path.as_os_str().is_empty() {
415        bail!("source path must not be empty");
416    }
417    if path.is_absolute() {
418        bail!("source path must be relative");
419    }
420
421    let mut normalized = PathBuf::new();
422    for component in path.components() {
423        match component {
424            Component::Normal(part) => normalized.push(part),
425            Component::CurDir => {}
426            Component::ParentDir => bail!("source path must not contain '..'"),
427            _ => bail!("source path must be relative"),
428        }
429    }
430
431    if normalized.as_os_str().is_empty() {
432        bail!("source path must not be empty");
433    }
434    if normalized.file_name().and_then(|name| name.to_str()) == Some("shine.toml") {
435        bail!("source path must not point to shine.toml");
436    }
437    Ok(normalized)
438}
439
440/// Native (auto-collected or `runtime = "native"`) source: relative + `.sh`/`.ps1`.
441fn normalize_shell_source(path: impl AsRef<Path>) -> Result<PathBuf> {
442    let normalized = normalize_relative_source(path)?;
443    if !is_shell_script(&normalized) {
444        bail!("source path must end with .sh or .ps1");
445    }
446    Ok(normalized)
447}
448
449/// Metadata source validated against the declared runtime's allowed extensions.
450fn normalize_source(
451    path: impl AsRef<Path>,
452    runtime: crate::bin_links::LinkRuntime,
453) -> Result<PathBuf> {
454    let normalized = normalize_relative_source(path)?;
455    match runtime {
456        crate::bin_links::LinkRuntime::Native => {
457            if !is_shell_script(&normalized) {
458                bail!("source path must end with .sh or .ps1");
459            }
460        }
461        crate::bin_links::LinkRuntime::Bun => {
462            if !is_bun_script(&normalized) {
463                bail!("bun source path must end with .ts, .js, .mts, or .mjs");
464            }
465        }
466    }
467    Ok(normalized)
468}
469
470fn is_shell_script(path: &Path) -> bool {
471    matches!(
472        path.extension().and_then(|ext| ext.to_str()),
473        Some("sh" | "ps1")
474    )
475}
476
477fn is_bun_script(path: &Path) -> bool {
478    matches!(
479        path.extension().and_then(|ext| ext.to_str()),
480        Some("ts" | "js" | "mts" | "mjs")
481    )
482}
483
484fn parse_runtime(value: Option<&str>) -> Result<crate::bin_links::LinkRuntime> {
485    match value {
486        None | Some("native") => Ok(crate::bin_links::LinkRuntime::Native),
487        Some("bun") => Ok(crate::bin_links::LinkRuntime::Bun),
488        Some(other) => bail!("unsupported runtime `{other}` (expected `bun`)"),
489    }
490}
491
492/// A validated `[[files]]` entry, before its script bytes are read for the
493/// description. Shared by the embedded and installed metadata loaders.
494struct ResolvedFile {
495    source_rel: PathBuf,
496    command_name: String,
497    /// Optional `description = "..."` from `[[files]]`; when set it overrides the
498    /// description parsed from the source's leading comment block.
499    description: Option<String>,
500    needs_source: bool,
501    runtime: crate::bin_links::LinkRuntime,
502    transforms: Vec<String>,
503    env: Vec<crate::env::EnvVarSpec>,
504}
505
506impl ResolvedFile {
507    fn native(source_rel: PathBuf, command_name: String) -> Self {
508        Self {
509            source_rel,
510            command_name,
511            description: None,
512            needs_source: false,
513            runtime: crate::bin_links::LinkRuntime::Native,
514            transforms: Vec::new(),
515            env: Vec::new(),
516        }
517    }
518
519    /// Resolve the command description from the source `bytes`: an explicit
520    /// metadata `description` wins; otherwise parse the source's leading comment
521    /// block with the runtime-correct leader (`//` for bun, `#` for native).
522    fn describe(&self, bytes: &[u8]) -> Vec<String> {
523        if let Some(description) = &self.description {
524            return vec![description.clone()];
525        }
526        match self.runtime {
527            crate::bin_links::LinkRuntime::Bun => presets::parse_bun_description(bytes),
528            crate::bin_links::LinkRuntime::Native => presets::parse_script_description(bytes),
529        }
530    }
531
532    fn into_shell_file(self, description: Vec<String>) -> ShellFile {
533        ShellFile {
534            source_rel: self.source_rel,
535            command_name: self.command_name,
536            description,
537            needs_source: self.needs_source,
538            runtime: self.runtime,
539            transforms: self.transforms,
540            env: self.env,
541        }
542    }
543}
544
545fn resolve_metadata_file(file: &FileToml, ctx: &str) -> Result<ResolvedFile> {
546    let runtime = parse_runtime(file.runtime.as_deref())
547        .with_context(|| format!("invalid runtime in {ctx}"))?;
548    let needs_source = file.needs_source.unwrap_or(false);
549    if runtime == crate::bin_links::LinkRuntime::Bun && needs_source {
550        bail!("{ctx}: `runtime = \"bun\"` cannot be combined with `needs_source = true`");
551    }
552    let source_rel = normalize_source(&file.source, runtime)
553        .with_context(|| format!("invalid source in {ctx}"))?;
554    let command_name = resolve_command_name(&source_rel, file.target.as_deref())
555        .with_context(|| format!("invalid target in {ctx}"))?;
556    let transforms = file.transforms.clone().unwrap_or_default();
557    let env = crate::env::parse_env_specs(file.env.as_deref().unwrap_or_default())
558        .with_context(|| format!("invalid env in {ctx}"))?;
559    if runtime != crate::bin_links::LinkRuntime::Bun && !env.is_empty() {
560        bail!("{ctx}: `env` is only valid when `runtime = \"bun\"`");
561    }
562    Ok(ResolvedFile {
563        source_rel,
564        command_name,
565        description: file.description.clone(),
566        needs_source,
567        runtime,
568        transforms,
569        env,
570    })
571}
572
573fn resolve_command_name(source_rel: &Path, target: Option<&str>) -> Result<String> {
574    match target {
575        Some(target) => validate_command_name(target),
576        None => default_command_name(source_rel),
577    }
578}
579
580fn default_command_name(source_rel: &Path) -> Result<String> {
581    let stem = crate::bin_links::link_stem(source_rel);
582    let stem = stem
583        .into_string()
584        .map_err(|_| anyhow::anyhow!("command name must be valid UTF-8"))?;
585    validate_command_name(&stem)
586}
587
588fn validate_command_name(target: &str) -> Result<String> {
589    let trimmed = target.trim();
590    if trimmed.is_empty() {
591        bail!("command name must not be empty");
592    }
593    if trimmed == "." || trimmed == ".." {
594        bail!("command name must be a plain filename");
595    }
596    let path = Path::new(trimmed);
597    match path.components().next() {
598        Some(Component::Normal(_)) if path.components().count() == 1 => Ok(trimmed.to_string()),
599        _ => bail!("command name must be a plain filename"),
600    }
601}
602
603/// Validate one on-disk shell category without loading configuration or
604/// executing any command. Returns whether the category has explicit metadata.
605pub(crate) fn validate_preset_category(
606    name: &str,
607    root: &Path,
608) -> std::result::Result<bool, PresetValidationFailure> {
609    let manifest_path = root.join("shine.toml");
610    if !manifest_path.is_file() {
611        let sources = collect_local_scripts(root)?;
612        validate_legacy_commands(root, &sources)?;
613        return Ok(false);
614    }
615
616    let bytes = std::fs::read(&manifest_path).map_err(|error| {
617        PresetValidationFailure::at(
618            "read_failed",
619            format!("cannot read shell metadata: {error}"),
620            &manifest_path,
621        )
622    })?;
623    let parsed: CategoryToml = toml::from_slice(&bytes).map_err(|error| {
624        PresetValidationFailure::at(
625            "invalid_metadata",
626            format!("failed to parse shell/{name}/shine.toml: {error}"),
627            &manifest_path,
628        )
629    })?;
630    let context = format!("shell/{name}/shine.toml");
631
632    let mut resolved = Vec::new();
633    match &parsed.files {
634        Some(files) if files.is_empty() => {
635            return Err(PresetValidationFailure::at(
636                "invalid_metadata",
637                format!("{context} files must not be empty"),
638                &manifest_path,
639            ));
640        }
641        Some(files) => {
642            for file in files {
643                let entry = resolve_metadata_file(file, &context)
644                    .map_err(|error| invalid_metadata(error, &manifest_path))?;
645                crate::install_core::transforms::validate(&entry.transforms)
646                    .with_context(|| format!("invalid transforms in {context}"))
647                    .map_err(|error| invalid_metadata(error, &manifest_path))?;
648                validate_reference(root, &entry.source_rel, "shell source")?;
649                for platform in OperatingSystem::ALL {
650                    file_matches_platform(name, file, platform)
651                        .map_err(|error| invalid_metadata(error, &manifest_path))?;
652                }
653                resolved.push((file.platforms.clone(), entry));
654            }
655        }
656        None => {
657            for source in collect_local_scripts(root)? {
658                let command_name = default_command_name(&source)
659                    .map_err(|error| invalid_metadata(error, &manifest_path))?;
660                // Auto-collected entries apply to both platforms, matching the
661                // runtime loader's compatibility behavior.
662                resolved.push((None, ResolvedFile::native(source, command_name)));
663            }
664        }
665    }
666
667    let uses_bun = resolved
668        .iter()
669        .any(|(_, entry)| entry.runtime == crate::bin_links::LinkRuntime::Bun);
670    for platform in OperatingSystem::ALL {
671        let mut commands = BTreeSet::new();
672        for (platforms, entry) in &resolved {
673            if !crate::preset_meta::platform_matches(platforms.as_deref(), platform, &context)
674                .map_err(|error| invalid_metadata(error, &manifest_path))?
675            {
676                continue;
677            }
678            if !commands.insert(entry.command_name.clone()) {
679                return Err(PresetValidationFailure::at(
680                    "duplicate_command",
681                    format!(
682                        "shell/{name} declares command `{}` more than once for {}",
683                        entry.command_name,
684                        platform.as_str()
685                    ),
686                    &manifest_path,
687                ));
688            }
689        }
690    }
691
692    if uses_bun {
693        crate::bun_runtime::resolve(root, true).map_err(|error| {
694            PresetValidationFailure::at("bun_dependency_policy", error.to_string(), root)
695        })?;
696    }
697    Ok(true)
698}
699
700fn invalid_metadata(error: anyhow::Error, path: &Path) -> PresetValidationFailure {
701    PresetValidationFailure::at("invalid_metadata", error.to_string(), path)
702}
703
704fn collect_local_scripts(
705    root: &Path,
706) -> std::result::Result<Vec<PathBuf>, PresetValidationFailure> {
707    let mut scripts = Vec::new();
708    let mut pending = vec![root.to_path_buf()];
709    while let Some(directory) = pending.pop() {
710        let entries = std::fs::read_dir(&directory).map_err(|error| {
711            PresetValidationFailure::at(
712                "read_failed",
713                format!("cannot read shell category: {error}"),
714                &directory,
715            )
716        })?;
717        for entry in entries {
718            let entry = entry.map_err(|error| {
719                PresetValidationFailure::at("read_failed", error.to_string(), &directory)
720            })?;
721            let file_type = entry.file_type().map_err(|error| {
722                PresetValidationFailure::at("read_failed", error.to_string(), entry.path())
723            })?;
724            if file_type.is_dir() {
725                pending.push(entry.path());
726            } else if file_type.is_file() {
727                let relative = entry
728                    .path()
729                    .strip_prefix(root)
730                    .map(Path::to_path_buf)
731                    .map_err(|error| {
732                        PresetValidationFailure::at("read_failed", error.to_string(), entry.path())
733                    })?;
734                if is_shell_script(&relative) {
735                    scripts.push(relative);
736                }
737            }
738        }
739    }
740    scripts.sort();
741    if scripts.is_empty() {
742        return Err(PresetValidationFailure::at(
743            "no_files",
744            "shell preset category contains no .sh or .ps1 files",
745            root,
746        ));
747    }
748    Ok(scripts)
749}
750
751fn validate_legacy_commands(
752    root: &Path,
753    sources: &[PathBuf],
754) -> std::result::Result<(), PresetValidationFailure> {
755    let mut commands = BTreeSet::new();
756    for source in sources {
757        let command = default_command_name(source).map_err(|error| {
758            PresetValidationFailure::at("invalid_metadata", error.to_string(), root.join(source))
759        })?;
760        if !commands.insert(command.clone()) {
761            return Err(PresetValidationFailure::at(
762                "duplicate_command",
763                format!("legacy shell category resolves more than one file to `{command}`"),
764                root,
765            ));
766        }
767    }
768    Ok(())
769}
770
771fn validate_reference(
772    root: &Path,
773    relative: &Path,
774    label: &str,
775) -> std::result::Result<(), PresetValidationFailure> {
776    let path = root.join(relative);
777    let canonical = std::fs::canonicalize(&path).map_err(|error| {
778        PresetValidationFailure::at(
779            "missing_reference",
780            format!("{label} is missing or unreadable: {error}"),
781            &path,
782        )
783    })?;
784    if !canonical.starts_with(root) || !canonical.is_file() {
785        return Err(PresetValidationFailure::at(
786            "invalid_reference",
787            format!("{label} must be a file inside the preset category"),
788            path,
789        ));
790    }
791    Ok(())
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797    use tokio::fs;
798
799    async fn make_temp_dir() -> PathBuf {
800        crate::test_support::make_temp_dir("shine-shell-meta").await
801    }
802
803    #[test]
804    fn embedded_proxy_category_uses_renamed_commands() {
805        let categories = load_embedded_categories(Some("proxy")).unwrap();
806        let proxy = categories.iter().find(|cat| cat.name == "proxy").unwrap();
807        let names: Vec<_> = proxy
808            .files
809            .iter()
810            .map(|file| file.command_name.as_str())
811            .collect();
812        assert!(names.contains(&"setproxy"));
813        assert!(names.contains(&"usetproxy"));
814        assert!(!names.contains(&"set_proxy"));
815    }
816
817    #[test]
818    fn embedded_proxy_category_uses_platform_specific_scripts() {
819        let categories = load_embedded_categories(Some("proxy")).unwrap();
820        let proxy = categories.iter().find(|cat| cat.name == "proxy").unwrap();
821        let sources: Vec<_> = proxy
822            .files
823            .iter()
824            .map(|file| file.source_rel.as_path())
825            .collect();
826
827        if cfg!(windows) {
828            assert!(sources.contains(&Path::new("set_proxy.ps1")));
829            assert!(sources.contains(&Path::new("uset_proxy.ps1")));
830            assert!(!sources.contains(&Path::new("set_proxy.sh")));
831            assert!(!sources.contains(&Path::new("uset_proxy.sh")));
832        } else {
833            assert!(sources.contains(&Path::new("set_proxy.sh")));
834            assert!(sources.contains(&Path::new("uset_proxy.sh")));
835            assert!(!sources.contains(&Path::new("set_proxy.ps1")));
836            assert!(!sources.contains(&Path::new("uset_proxy.ps1")));
837        }
838    }
839
840    #[test]
841    fn metadata_platform_filter_accepts_current_platform() {
842        let file = FileToml {
843            source: "set_proxy.ps1".to_string(),
844            target: Some("setproxy".to_string()),
845            description: None,
846            needs_source: Some(true),
847            platforms: Some(vec!["windows".to_string()]),
848            runtime: None,
849            transforms: None,
850            env: None,
851        };
852
853        assert!(file_matches_platform("proxy", &file, OperatingSystem::Windows).unwrap());
854        assert!(!file_matches_platform("proxy", &file, OperatingSystem::Linux).unwrap());
855    }
856
857    #[test]
858    fn metadata_platform_filter_defaults_to_all_platforms() {
859        let file = FileToml {
860            source: "set_proxy.sh".to_string(),
861            target: Some("setproxy".to_string()),
862            description: None,
863            needs_source: Some(true),
864            platforms: None,
865            runtime: None,
866            transforms: None,
867            env: None,
868        };
869
870        assert!(file_matches_platform("proxy", &file, OperatingSystem::Windows).unwrap());
871        assert!(file_matches_platform("proxy", &file, OperatingSystem::Macos).unwrap());
872        assert!(file_matches_platform("proxy", &file, OperatingSystem::Linux).unwrap());
873    }
874
875    #[test]
876    fn metadata_platform_filter_rejects_unknown_platforms() {
877        let file = FileToml {
878            source: "set_proxy.sh".to_string(),
879            target: Some("setproxy".to_string()),
880            description: None,
881            needs_source: Some(true),
882            platforms: Some(vec!["plan9".to_string()]),
883            runtime: None,
884            transforms: None,
885            env: None,
886        };
887
888        let err = file_matches_platform("proxy", &file, OperatingSystem::Linux)
889            .unwrap_err()
890            .to_string();
891        assert!(err.contains("unsupported platform `plan9`"));
892    }
893
894    #[test]
895    fn embedded_agent_category_uses_cross_platform_bun_entry() {
896        let categories = load_embedded_categories(Some("agent")).unwrap();
897        let agent = categories.iter().find(|cat| cat.name == "agent").unwrap();
898
899        assert_eq!(agent.files.len(), 1);
900        assert_eq!(agent.files[0].command_name, "ccenv");
901        assert_eq!(agent.files[0].source_rel, PathBuf::from("cc.ts"));
902        assert!(!agent.files[0].needs_source);
903        assert_eq!(agent.files[0].runtime, crate::bin_links::LinkRuntime::Bun);
904        assert!(agent.files[0].transforms.is_empty());
905        assert!(agent.files[0].env.is_empty());
906    }
907
908    #[test]
909    fn embedded_image_tools_category_exposes_cross_platform_bun_commands() {
910        let categories = load_embedded_categories(Some("image-tools")).unwrap();
911        let category = categories
912            .iter()
913            .find(|category| category.name == "image-tools")
914            .unwrap();
915        let commands: Vec<_> = category
916            .files
917            .iter()
918            .map(|file| {
919                (
920                    file.command_name.as_str(),
921                    file.runtime,
922                    file.env
923                        .iter()
924                        .map(|spec| spec.source.as_str())
925                        .collect::<Vec<_>>(),
926                )
927            })
928            .collect();
929
930        assert_eq!(
931            commands,
932            vec![
933                (
934                    "img-compress",
935                    crate::bin_links::LinkRuntime::Bun,
936                    vec!["IMAGE_QUALITY"]
937                ),
938                (
939                    "img-resize",
940                    crate::bin_links::LinkRuntime::Bun,
941                    vec!["IMAGE_QUALITY", "IMAGE_MAX_WIDTH", "IMAGE_MAX_HEIGHT"],
942                ),
943                (
944                    "img-convert",
945                    crate::bin_links::LinkRuntime::Bun,
946                    vec!["IMAGE_QUALITY"]
947                ),
948            ]
949        );
950    }
951
952    #[test]
953    fn embedded_utils_category_exposes_copyfile_command() {
954        let categories = load_embedded_categories(Some("utils")).unwrap();
955        let utils = categories.iter().find(|cat| cat.name == "utils").unwrap();
956
957        if cfg!(windows) {
958            assert_eq!(utils.files.len(), 2);
959            let env_export = utils
960                .files
961                .iter()
962                .find(|f| f.command_name == "shine-env-export")
963                .expect("shine-env-export should be present");
964            assert!(env_export.needs_source);
965
966            let theme_sync = utils
967                .files
968                .iter()
969                .find(|f| f.command_name == "shine-theme-sync")
970                .expect("shine-theme-sync should be present");
971            assert_eq!(theme_sync.source_rel, PathBuf::from("shine-theme-sync.ps1"));
972            assert!(theme_sync.needs_source);
973        } else {
974            assert_eq!(utils.files.len(), 3);
975            let copyfile = utils
976                .files
977                .iter()
978                .find(|f| f.command_name == "copyfile")
979                .expect("copyfile should be present");
980            assert_eq!(copyfile.source_rel, PathBuf::from("copyfile.sh"));
981            assert!(!copyfile.needs_source);
982            assert!(
983                copyfile.description.contains(
984                    &"Copy a file's contents to the local clipboard via OSC52.".to_string()
985                )
986            );
987
988            let env_export = utils
989                .files
990                .iter()
991                .find(|f| f.command_name == "shine-env-export")
992                .expect("shine-env-export should be present");
993            assert_eq!(env_export.source_rel, PathBuf::from("shine-env-export.sh"));
994            assert!(env_export.needs_source);
995
996            let theme_sync = utils
997                .files
998                .iter()
999                .find(|f| f.command_name == "shine-theme-sync")
1000                .expect("shine-theme-sync should be present");
1001            assert_eq!(theme_sync.source_rel, PathBuf::from("shine-theme-sync.sh"));
1002            assert!(theme_sync.needs_source);
1003        }
1004    }
1005
1006    #[tokio::test]
1007    async fn installed_metadata_applies_target_names() {
1008        let dir = make_temp_dir().await;
1009        let category_root = dir.join("presets/shell/custom");
1010        fs::create_dir_all(&category_root).await.unwrap();
1011        fs::write(
1012            category_root.join("shine.toml"),
1013            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\n",
1014        )
1015        .await
1016        .unwrap();
1017        fs::write(
1018            category_root.join("set_proxy.sh"),
1019            b"#!/bin/bash\n# Set proxy.\n",
1020        )
1021        .await
1022        .unwrap();
1023
1024        let mut config = Config::new_for_test(&dir);
1025        config.is_external_presets = true;
1026        let categories = load_installed_categories(&config, Some("custom"))
1027            .await
1028            .unwrap();
1029        assert_eq!(categories.len(), 1);
1030        assert_eq!(categories[0].files[0].command_name, "setproxy");
1031
1032        fs::remove_dir_all(&dir).await.unwrap();
1033    }
1034
1035    #[tokio::test]
1036    async fn external_presets_and_overlay_categories_are_merged() {
1037        let dir = make_temp_dir().await;
1038        let base_root = dir.join("presets/shell/custom");
1039        let overlay = dir.join("overlay");
1040        let overlay_root = overlay.join("shell/custom");
1041        let overlay_only = overlay.join("shell/personal");
1042        fs::create_dir_all(&base_root).await.unwrap();
1043        fs::create_dir_all(&overlay_root).await.unwrap();
1044        fs::create_dir_all(&overlay_only).await.unwrap();
1045        fs::write(
1046            base_root.join("shine.toml"),
1047            b"[[files]]\nsource = \"tool.sh\"\n",
1048        )
1049        .await
1050        .unwrap();
1051        fs::write(base_root.join("tool.sh"), b"#!/bin/bash\n# Base tool.\n")
1052            .await
1053            .unwrap();
1054        fs::write(
1055            overlay_root.join("tool.sh"),
1056            b"#!/bin/bash\n# Overlay tool.\n",
1057        )
1058        .await
1059        .unwrap();
1060        fs::write(
1061            overlay_only.join("personal.sh"),
1062            b"#!/bin/bash\n# Personal tool.\n",
1063        )
1064        .await
1065        .unwrap();
1066
1067        let mut config = Config::new_for_test(&dir);
1068        config.is_external_presets = true;
1069        config.presets_overlay_dir_override = Some(overlay);
1070        let categories = load_installed_categories(&config, None).await.unwrap();
1071
1072        let custom = categories.iter().find(|cat| cat.name == "custom").unwrap();
1073        assert_eq!(custom.files[0].description, vec!["Overlay tool."]);
1074        assert!(categories.iter().any(|cat| cat.name == "personal"));
1075
1076        fs::remove_dir_all(&dir).await.unwrap();
1077    }
1078
1079    #[tokio::test]
1080    async fn installed_category_accepts_powershell_scripts() {
1081        let dir = make_temp_dir().await;
1082        let category_root = dir.join("presets/shell/custom");
1083        fs::create_dir_all(&category_root).await.unwrap();
1084        fs::write(
1085            category_root.join("tool.ps1"),
1086            b"# Tool.\nWrite-Output hi\n",
1087        )
1088        .await
1089        .unwrap();
1090
1091        let mut config = Config::new_for_test(&dir);
1092        config.is_external_presets = true;
1093        let categories = load_installed_categories(&config, Some("custom"))
1094            .await
1095            .unwrap();
1096
1097        assert_eq!(categories.len(), 1);
1098        assert_eq!(categories[0].files[0].source_rel, PathBuf::from("tool.ps1"));
1099        assert_eq!(categories[0].files[0].command_name, "tool");
1100
1101        fs::remove_dir_all(&dir).await.unwrap();
1102    }
1103
1104    #[tokio::test]
1105    async fn installed_metadata_filters_platform_specific_files() {
1106        let dir = make_temp_dir().await;
1107        let category_root = dir.join("presets/shell/custom");
1108        fs::create_dir_all(&category_root).await.unwrap();
1109        fs::write(
1110            category_root.join("shine.toml"),
1111            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"tool\"\nplatforms = [\"unix\"]\n\n[[files]]\nsource = \"tool.ps1\"\ntarget = \"tool\"\nplatforms = [\"windows\"]\n",
1112        )
1113        .await
1114        .unwrap();
1115        fs::write(category_root.join("tool.sh"), b"#!/bin/bash\n")
1116            .await
1117            .unwrap();
1118        fs::write(category_root.join("tool.ps1"), b"Write-Output hi\n")
1119            .await
1120            .unwrap();
1121
1122        let mut config = Config::new_for_test(&dir);
1123        config.is_external_presets = true;
1124        let categories = load_installed_categories(&config, Some("custom"))
1125            .await
1126            .unwrap();
1127
1128        assert_eq!(categories.len(), 1);
1129        assert_eq!(categories[0].files.len(), 1);
1130        let expected = if cfg!(windows) { "tool.ps1" } else { "tool.sh" };
1131        assert_eq!(categories[0].files[0].source_rel, PathBuf::from(expected));
1132        assert_eq!(categories[0].files[0].command_name, "tool");
1133
1134        fs::remove_dir_all(&dir).await.unwrap();
1135    }
1136
1137    #[test]
1138    fn rejects_invalid_command_names() {
1139        let err = validate_command_name("bin/setproxy")
1140            .unwrap_err()
1141            .to_string();
1142        assert!(err.contains("plain filename"));
1143    }
1144
1145    #[test]
1146    fn parse_runtime_accepts_native_and_bun_rejects_others() {
1147        use crate::bin_links::LinkRuntime;
1148        assert_eq!(parse_runtime(None).unwrap(), LinkRuntime::Native);
1149        assert_eq!(parse_runtime(Some("native")).unwrap(), LinkRuntime::Native);
1150        assert_eq!(parse_runtime(Some("bun")).unwrap(), LinkRuntime::Bun);
1151        let err = parse_runtime(Some("deno")).unwrap_err().to_string();
1152        assert!(err.contains("unsupported runtime"));
1153    }
1154
1155    #[test]
1156    fn normalize_source_enforces_extension_per_runtime() {
1157        use crate::bin_links::LinkRuntime;
1158        for ext in ["ts", "js", "mts", "mjs"] {
1159            assert!(
1160                normalize_source(format!("tool.{ext}"), LinkRuntime::Bun).is_ok(),
1161                ".{ext} should be a valid bun source"
1162            );
1163        }
1164        assert!(normalize_source("tool.sh", LinkRuntime::Bun).is_err());
1165        assert!(normalize_source("tool.ts", LinkRuntime::Native).is_err());
1166        assert!(normalize_source("tool.sh", LinkRuntime::Native).is_ok());
1167        // Path traversal is rejected regardless of runtime.
1168        assert!(normalize_source("../evil.ts", LinkRuntime::Bun).is_err());
1169    }
1170
1171    async fn write_bun_category(dir: &Path, shine_toml: &[u8]) -> Config {
1172        let category_root = dir.join("presets/shell/custom");
1173        fs::create_dir_all(&category_root).await.unwrap();
1174        fs::write(category_root.join("shine.toml"), shine_toml)
1175            .await
1176            .unwrap();
1177        fs::write(category_root.join("tool.ts"), b"// tool\n")
1178            .await
1179            .unwrap();
1180        let mut config = Config::new_for_test(dir);
1181        config.is_external_presets = true;
1182        config
1183    }
1184
1185    #[tokio::test]
1186    async fn installed_metadata_accepts_bun_runtime_with_transforms() {
1187        let dir = make_temp_dir().await;
1188        let config = write_bun_category(
1189            &dir,
1190            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ntransforms = [\"template\"]\n",
1191        )
1192        .await;
1193
1194        let categories = load_installed_categories(&config, Some("custom"))
1195            .await
1196            .unwrap();
1197        let file = &categories[0].files[0];
1198        assert_eq!(file.command_name, "mytool");
1199        assert_eq!(file.source_rel, PathBuf::from("tool.ts"));
1200        assert_eq!(file.runtime, crate::bin_links::LinkRuntime::Bun);
1201        assert_eq!(file.transforms, vec!["template".to_string()]);
1202        assert!(!file.needs_source);
1203
1204        fs::remove_dir_all(&dir).await.unwrap();
1205    }
1206
1207    #[tokio::test]
1208    async fn installed_metadata_defaults_bun_command_name_to_stem() {
1209        let dir = make_temp_dir().await;
1210        let config = write_bun_category(
1211            &dir,
1212            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\n",
1213        )
1214        .await;
1215
1216        let categories = load_installed_categories(&config, Some("custom"))
1217            .await
1218            .unwrap();
1219        assert_eq!(categories[0].files[0].command_name, "tool");
1220
1221        fs::remove_dir_all(&dir).await.unwrap();
1222    }
1223
1224    #[tokio::test]
1225    async fn installed_metadata_rejects_bun_with_needs_source() {
1226        let dir = make_temp_dir().await;
1227        let config = write_bun_category(
1228            &dir,
1229            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\nneeds_source = true\n",
1230        )
1231        .await;
1232
1233        let err = load_installed_categories(&config, Some("custom"))
1234            .await
1235            .unwrap_err()
1236            .to_string();
1237        assert!(err.contains("needs_source"), "unexpected error: {err}");
1238
1239        fs::remove_dir_all(&dir).await.unwrap();
1240    }
1241
1242    #[tokio::test]
1243    async fn installed_metadata_rejects_unknown_runtime() {
1244        let dir = make_temp_dir().await;
1245        let config = write_bun_category(
1246            &dir,
1247            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"deno\"\n",
1248        )
1249        .await;
1250
1251        let err = format!(
1252            "{:#}",
1253            load_installed_categories(&config, Some("custom"))
1254                .await
1255                .unwrap_err()
1256        );
1257        assert!(
1258            err.contains("unsupported runtime"),
1259            "unexpected error: {err}"
1260        );
1261
1262        fs::remove_dir_all(&dir).await.unwrap();
1263    }
1264
1265    #[tokio::test]
1266    async fn installed_metadata_parses_bun_env_declarations_in_order() {
1267        let dir = make_temp_dir().await;
1268        let config = write_bun_category(
1269            &dir,
1270            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\nenv = [\"API_URL\", \"SERVICE_TOKEN=API_TOKEN\"]\n",
1271        )
1272        .await;
1273
1274        let categories = load_installed_categories(&config, Some("custom"))
1275            .await
1276            .unwrap();
1277        let env = &categories[0].files[0].env;
1278        assert_eq!(env.len(), 2);
1279        assert_eq!(env[0].to_with_arg(), "API_URL");
1280        assert_eq!(env[1].to_with_arg(), "SERVICE_TOKEN=API_TOKEN");
1281
1282        fs::remove_dir_all(&dir).await.unwrap();
1283    }
1284
1285    #[tokio::test]
1286    async fn installed_metadata_rejects_env_on_native_entry() {
1287        let dir = make_temp_dir().await;
1288        let category_root = dir.join("presets/shell/custom");
1289        fs::create_dir_all(&category_root).await.unwrap();
1290        fs::write(
1291            category_root.join("shine.toml"),
1292            b"[[files]]\nsource = \"tool.sh\"\nenv = [\"API_URL\"]\n",
1293        )
1294        .await
1295        .unwrap();
1296        fs::write(category_root.join("tool.sh"), b"#!/bin/bash\n")
1297            .await
1298            .unwrap();
1299        let mut config = Config::new_for_test(&dir);
1300        config.is_external_presets = true;
1301
1302        let err = format!(
1303            "{:#}",
1304            load_installed_categories(&config, Some("custom"))
1305                .await
1306                .unwrap_err()
1307        );
1308        assert!(
1309            err.contains("`env` is only valid when `runtime = \"bun\"`"),
1310            "unexpected error: {err}"
1311        );
1312
1313        fs::remove_dir_all(&dir).await.unwrap();
1314    }
1315
1316    #[tokio::test]
1317    async fn installed_metadata_rejects_bun_env_invalid_name() {
1318        let dir = make_temp_dir().await;
1319        let config = write_bun_category(
1320            &dir,
1321            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\nenv = [\"BAD-NAME\"]\n",
1322        )
1323        .await;
1324
1325        let err = format!(
1326            "{:#}",
1327            load_installed_categories(&config, Some("custom"))
1328                .await
1329                .unwrap_err()
1330        );
1331        assert!(
1332            err.contains("invalid environment variable name"),
1333            "unexpected error: {err}"
1334        );
1335
1336        fs::remove_dir_all(&dir).await.unwrap();
1337    }
1338
1339    #[tokio::test]
1340    async fn installed_metadata_rejects_bun_env_duplicate_target() {
1341        let dir = make_temp_dir().await;
1342        let config = write_bun_category(
1343            &dir,
1344            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\nenv = [\"A=TOKEN\", \"B=TOKEN\"]\n",
1345        )
1346        .await;
1347
1348        let err = format!(
1349            "{:#}",
1350            load_installed_categories(&config, Some("custom"))
1351                .await
1352                .unwrap_err()
1353        );
1354        assert!(
1355            err.contains("duplicate target variable"),
1356            "unexpected error: {err}"
1357        );
1358
1359        fs::remove_dir_all(&dir).await.unwrap();
1360    }
1361
1362    #[tokio::test]
1363    async fn installed_metadata_bun_description_from_slash_header() {
1364        let dir = make_temp_dir().await;
1365        let category_root = dir.join("presets/shell/custom");
1366        fs::create_dir_all(&category_root).await.unwrap();
1367        fs::write(
1368            category_root.join("shine.toml"),
1369            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\n",
1370        )
1371        .await
1372        .unwrap();
1373        fs::write(
1374            category_root.join("tool.ts"),
1375            b"// Fetch and print status.\n// Reads Bun.env.API_URL.\nconsole.log('hi')\n",
1376        )
1377        .await
1378        .unwrap();
1379        let mut config = Config::new_for_test(&dir);
1380        config.is_external_presets = true;
1381
1382        let categories = load_installed_categories(&config, Some("custom"))
1383            .await
1384            .unwrap();
1385        assert_eq!(
1386            categories[0].files[0].description,
1387            vec!["Fetch and print status.", "Reads Bun.env.API_URL."]
1388        );
1389
1390        fs::remove_dir_all(&dir).await.unwrap();
1391    }
1392
1393    #[tokio::test]
1394    async fn installed_metadata_file_description_overrides_bun_header() {
1395        let dir = make_temp_dir().await;
1396        let category_root = dir.join("presets/shell/custom");
1397        fs::create_dir_all(&category_root).await.unwrap();
1398        fs::write(
1399            category_root.join("shine.toml"),
1400            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ndescription = \"Explicit metadata description.\"\n",
1401        )
1402        .await
1403        .unwrap();
1404        fs::write(
1405            category_root.join("tool.ts"),
1406            b"// header that should be overridden\nconsole.log('hi')\n",
1407        )
1408        .await
1409        .unwrap();
1410        let mut config = Config::new_for_test(&dir);
1411        config.is_external_presets = true;
1412
1413        let categories = load_installed_categories(&config, Some("custom"))
1414            .await
1415            .unwrap();
1416        assert_eq!(
1417            categories[0].files[0].description,
1418            vec!["Explicit metadata description."]
1419        );
1420
1421        fs::remove_dir_all(&dir).await.unwrap();
1422    }
1423
1424    #[tokio::test]
1425    async fn installed_metadata_file_description_overrides_native_hash_header() {
1426        let dir = make_temp_dir().await;
1427        let category_root = dir.join("presets/shell/custom");
1428        fs::create_dir_all(&category_root).await.unwrap();
1429        fs::write(
1430            category_root.join("shine.toml"),
1431            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\ndescription = \"From metadata.\"\n",
1432        )
1433        .await
1434        .unwrap();
1435        fs::write(
1436            category_root.join("tool.sh"),
1437            b"#!/bin/bash\n# hash header that should be overridden\necho hi\n",
1438        )
1439        .await
1440        .unwrap();
1441        let mut config = Config::new_for_test(&dir);
1442        config.is_external_presets = true;
1443
1444        let categories = load_installed_categories(&config, Some("custom"))
1445            .await
1446            .unwrap();
1447        assert_eq!(categories[0].files[0].description, vec!["From metadata."]);
1448
1449        fs::remove_dir_all(&dir).await.unwrap();
1450    }
1451
1452    #[tokio::test]
1453    async fn installed_metadata_rejects_bun_source_with_shell_extension() {
1454        let dir = make_temp_dir().await;
1455        let category_root = dir.join("presets/shell/custom");
1456        fs::create_dir_all(&category_root).await.unwrap();
1457        fs::write(
1458            category_root.join("shine.toml"),
1459            b"[[files]]\nsource = \"tool.sh\"\nruntime = \"bun\"\n",
1460        )
1461        .await
1462        .unwrap();
1463        fs::write(category_root.join("tool.sh"), b"#!/bin/bash\n")
1464            .await
1465            .unwrap();
1466        let mut config = Config::new_for_test(&dir);
1467        config.is_external_presets = true;
1468
1469        let err = format!(
1470            "{:#}",
1471            load_installed_categories(&config, Some("custom"))
1472                .await
1473                .unwrap_err()
1474        );
1475        assert!(err.contains("bun source path"), "unexpected error: {err}");
1476
1477        fs::remove_dir_all(&dir).await.unwrap();
1478    }
1479}