Skip to main content

cli/shells/
metadata.rs

1use crate::config::Config;
2use crate::platform::current_platform;
3use crate::presets;
4use anyhow::{Context, Result, bail};
5use serde::Deserialize;
6use std::collections::BTreeSet;
7use std::path::{Component, Path, PathBuf};
8use tokio::fs;
9
10#[derive(Debug, Clone)]
11pub struct ShellCategory {
12    pub name: String,
13    pub description: Option<String>,
14    pub files: Vec<ShellFile>,
15    // Tracks whether the category came from an explicit metadata file vs. auto-collection;
16    // reserved for future upgrade/list logic, mirroring AppCategory::uses_metadata.
17    #[allow(dead_code)]
18    pub uses_metadata: bool,
19}
20
21#[derive(Debug, Clone)]
22pub struct ShellFile {
23    pub source_rel: PathBuf,
24    pub command_name: String,
25    pub description: Vec<String>,
26    pub needs_source: bool,
27    /// How the command is invoked: native (symlink/shim) or via the `bun` runtime.
28    pub runtime: crate::bin_links::LinkRuntime,
29    /// Declared install-time transforms (e.g. `["template"]`) applied to the source
30    /// before linking. Empty means "no metadata-declared transform" — native scripts
31    /// may still opt into templating via the `# shine-template: true` annotation.
32    pub transforms: Vec<String>,
33    /// Runtime environment values injected into a Bun launcher via `Bun.env`,
34    /// using the `env run --with` grammar (ordered). Empty for native entries;
35    /// only `runtime = "bun"` entries may declare it (enforced at metadata load).
36    pub env: Vec<crate::env::EnvVarSpec>,
37}
38
39#[derive(Debug, Deserialize)]
40struct CategoryToml {
41    description: Option<String>,
42    files: Option<Vec<FileToml>>,
43}
44
45#[derive(Debug, Deserialize)]
46struct FileToml {
47    source: String,
48    target: Option<String>,
49    description: Option<String>,
50    needs_source: Option<bool>,
51    platforms: Option<Vec<String>>,
52    runtime: Option<String>,
53    transforms: Option<Vec<String>>,
54    env: Option<Vec<String>>,
55}
56
57pub fn load_embedded_categories(filter: Option<&str>) -> Result<Vec<ShellCategory>> {
58    let names = collect_embedded_category_names(filter);
59    let mut categories = Vec::new();
60    for name in names {
61        categories.push(load_embedded_category(&name)?);
62    }
63    Ok(categories)
64}
65
66pub async fn load_installed_categories(
67    config: &Config,
68    filter: Option<&str>,
69) -> Result<Vec<ShellCategory>> {
70    let shell_root = config.presets_dir().join("shell");
71    let mut names: BTreeSet<String> = collect_fs_category_names(&shell_root, filter)
72        .await?
73        .into_iter()
74        .collect();
75    if let Some(overlay) = config.active_presets_overlay_dir() {
76        names.extend(collect_fs_category_names(&overlay.join("shell"), filter).await?);
77    }
78    let mut categories = Vec::new();
79    for name in names {
80        categories.push(load_installed_category(config, &name).await?);
81    }
82    Ok(categories)
83}
84
85/// Loads categories from whichever source is active: installed (external
86/// presets mode) or embedded. Replaces the `if config.is_external_presets {
87/// load_installed_categories } else { load_embedded_categories }` branch
88/// repeated at every call site.
89pub async fn load_active_categories(
90    config: &Config,
91    filter: Option<&str>,
92) -> Result<Vec<ShellCategory>> {
93    if config.is_external_presets {
94        load_installed_categories(config, filter).await
95    } else {
96        load_embedded_categories(filter)
97    }
98}
99
100fn load_embedded_category(name: &str) -> Result<ShellCategory> {
101    let metadata_path = format!("shell/{name}/shine.toml");
102    if let Some(bytes) = presets::read_asset_bytes(&metadata_path) {
103        let parsed = parse_category_toml(name, &bytes)?;
104        let files = match parsed.files {
105            Some(files) => files
106                .into_iter()
107                .filter_map(|file| match file_matches_current_platform(name, &file) {
108                    Ok(true) => Some(Ok(file)),
109                    Ok(false) => None,
110                    Err(err) => Some(Err(err)),
111                })
112                .map(|file| {
113                    let file = file?;
114                    let ctx = format!("shell/{name}/shine.toml");
115                    let resolved = resolve_metadata_file(&file, &ctx)?;
116                    let asset_path = format!("shell/{name}/{}", resolved.source_rel.display());
117                    let bytes = presets::read_asset_bytes(&asset_path).with_context(|| {
118                        format!(
119                            "shell/{name}/shine.toml references missing file: {:?}",
120                            resolved.source_rel
121                        )
122                    })?;
123                    let description = resolved.describe(&bytes);
124                    Ok(resolved.into_shell_file(description))
125                })
126                .collect::<Result<Vec<_>>>()?,
127            None => collect_embedded_scripts(name)?
128                .into_iter()
129                .map(|source_rel| {
130                    let asset_path = format!("shell/{name}/{}", source_rel.display());
131                    let bytes = presets::read_asset_bytes(&asset_path).unwrap_or_default();
132                    let command_name = default_command_name(&source_rel)?;
133                    Ok(ShellFile {
134                        source_rel,
135                        command_name,
136                        description: presets::parse_script_description(&bytes),
137                        needs_source: false,
138                        runtime: crate::bin_links::LinkRuntime::Native,
139                        transforms: Vec::new(),
140                        env: Vec::new(),
141                    })
142                })
143                .collect::<Result<Vec<_>>>()?,
144        };
145
146        return Ok(ShellCategory {
147            name: name.to_string(),
148            description: parsed.description,
149            files,
150            uses_metadata: true,
151        });
152    }
153
154    Ok(ShellCategory {
155        name: name.to_string(),
156        description: None,
157        files: collect_embedded_scripts(name)?
158            .into_iter()
159            .map(|source_rel| {
160                let asset_path = format!("shell/{name}/{}", source_rel.display());
161                let bytes = presets::read_asset_bytes(&asset_path).unwrap_or_default();
162                Ok(ShellFile {
163                    command_name: default_command_name(&source_rel)?,
164                    description: presets::parse_script_description(&bytes),
165                    needs_source: false,
166                    runtime: crate::bin_links::LinkRuntime::Native,
167                    transforms: Vec::new(),
168                    env: Vec::new(),
169                    source_rel,
170                })
171            })
172            .collect::<Result<Vec<_>>>()?,
173        uses_metadata: false,
174    })
175}
176
177async fn load_installed_category(config: &Config, name: &str) -> Result<ShellCategory> {
178    let category_rel = Path::new("shell").join(name);
179    let metadata_path = config.preset_path(category_rel.join("shine.toml"));
180
181    if metadata_path.exists() {
182        let bytes = fs::read(&metadata_path)
183            .await
184            .with_context(|| format!("reading metadata: {}", metadata_path.display()))?;
185        let parsed = parse_category_toml(name, &bytes)?;
186        let files = match parsed.files {
187            Some(files) => files
188                .into_iter()
189                .filter_map(|file| match file_matches_current_platform(name, &file) {
190                    Ok(true) => Some(Ok(file)),
191                    Ok(false) => None,
192                    Err(err) => Some(Err(err)),
193                })
194                .map(|file| {
195                    let file = file?;
196                    let ctx = metadata_path.display().to_string();
197                    resolve_metadata_file(&file, &ctx)
198                })
199                .collect::<Result<Vec<_>>>()?,
200            None => collect_merged_fs_scripts(config, &category_rel)
201                .await?
202                .into_iter()
203                .map(|source_rel| {
204                    let command_name = default_command_name(&source_rel)?;
205                    Ok(ResolvedFile::native(source_rel, command_name))
206                })
207                .collect::<Result<Vec<_>>>()?,
208        };
209
210        let mut shell_files = Vec::new();
211        for resolved in files {
212            let source_path = config.preset_path(category_rel.join(&resolved.source_rel));
213            if !source_path.exists() {
214                bail!(
215                    "shell/{name}/shine.toml references missing file: {}",
216                    resolved.source_rel.display()
217                );
218            }
219            let bytes = fs::read(&source_path)
220                .await
221                .with_context(|| format!("reading preset file: {}", source_path.display()))?;
222            let description = resolved.describe(&bytes);
223            shell_files.push(resolved.into_shell_file(description));
224        }
225
226        return Ok(ShellCategory {
227            name: name.to_string(),
228            description: parsed.description,
229            files: shell_files,
230            uses_metadata: true,
231        });
232    }
233
234    let mut files = Vec::new();
235    for source_rel in collect_merged_fs_scripts(config, &category_rel).await? {
236        let source_path = config.preset_path(category_rel.join(&source_rel));
237        let bytes = fs::read(&source_path)
238            .await
239            .with_context(|| format!("reading preset file: {}", source_path.display()))?;
240        files.push(ShellFile {
241            command_name: default_command_name(&source_rel)?,
242            description: presets::parse_script_description(&bytes),
243            needs_source: false,
244            runtime: crate::bin_links::LinkRuntime::Native,
245            transforms: Vec::new(),
246            env: Vec::new(),
247            source_rel,
248        });
249    }
250
251    Ok(ShellCategory {
252        name: name.to_string(),
253        description: None,
254        files,
255        uses_metadata: false,
256    })
257}
258
259async fn collect_merged_fs_scripts(config: &Config, category_rel: &Path) -> Result<Vec<PathBuf>> {
260    crate::preset_meta::merge_fs_tree(config, category_rel, "directory", |rel| {
261        if !is_shell_script(rel) {
262            return Ok(None);
263        }
264        Ok(Some(normalize_shell_source(rel)?))
265    })
266    .await
267}
268
269fn parse_category_toml(name: &str, bytes: &[u8]) -> Result<CategoryToml> {
270    toml::from_slice(bytes).with_context(|| format!("failed to parse shell/{name}/shine.toml"))
271}
272
273fn file_matches_current_platform(category: &str, file: &FileToml) -> Result<bool> {
274    file_matches_platform(category, file, current_platform())
275}
276
277fn file_matches_platform(category: &str, file: &FileToml, current: &str) -> Result<bool> {
278    crate::preset_meta::platform_matches(
279        file.platforms.as_deref(),
280        current,
281        &format!("shell/{category}/shine.toml"),
282    )
283}
284
285fn collect_embedded_category_names(filter: Option<&str>) -> Vec<String> {
286    crate::preset_meta::collect_embedded_category_names("shell", filter)
287}
288
289async fn collect_fs_category_names(shell_root: &Path, filter: Option<&str>) -> Result<Vec<String>> {
290    crate::preset_meta::collect_fs_category_names(shell_root, filter, "shell presets directory")
291        .await
292}
293
294fn collect_embedded_scripts(name: &str) -> Result<Vec<PathBuf>> {
295    let prefix = format!("shell/{name}/");
296    let mut scripts = BTreeSet::new();
297    for asset_path in presets::asset_paths(&format!("shell/{name}")) {
298        let Some(rest) = asset_path.strip_prefix(&prefix) else {
299            continue;
300        };
301        if rest == "shine.toml" {
302            continue;
303        }
304        let rel = PathBuf::from(rest);
305        if !is_shell_script(&rel) {
306            continue;
307        }
308        scripts.insert(normalize_shell_source(rest)?);
309    }
310    Ok(scripts.into_iter().collect())
311}
312
313/// Validate a `[[files]]` `source` as a safe relative path (no absolute, no `..`,
314/// not `shine.toml`) without checking its extension.
315fn normalize_relative_source(path: impl AsRef<Path>) -> Result<PathBuf> {
316    let path = path.as_ref();
317    if path.as_os_str().is_empty() {
318        bail!("source path must not be empty");
319    }
320    if path.is_absolute() {
321        bail!("source path must be relative");
322    }
323
324    let mut normalized = PathBuf::new();
325    for component in path.components() {
326        match component {
327            Component::Normal(part) => normalized.push(part),
328            Component::CurDir => {}
329            Component::ParentDir => bail!("source path must not contain '..'"),
330            _ => bail!("source path must be relative"),
331        }
332    }
333
334    if normalized.as_os_str().is_empty() {
335        bail!("source path must not be empty");
336    }
337    if normalized.file_name().and_then(|name| name.to_str()) == Some("shine.toml") {
338        bail!("source path must not point to shine.toml");
339    }
340    Ok(normalized)
341}
342
343/// Native (auto-collected or `runtime = "native"`) source: relative + `.sh`/`.ps1`.
344fn normalize_shell_source(path: impl AsRef<Path>) -> Result<PathBuf> {
345    let normalized = normalize_relative_source(path)?;
346    if !is_shell_script(&normalized) {
347        bail!("source path must end with .sh or .ps1");
348    }
349    Ok(normalized)
350}
351
352/// Metadata source validated against the declared runtime's allowed extensions.
353fn normalize_source(
354    path: impl AsRef<Path>,
355    runtime: crate::bin_links::LinkRuntime,
356) -> Result<PathBuf> {
357    let normalized = normalize_relative_source(path)?;
358    match runtime {
359        crate::bin_links::LinkRuntime::Native => {
360            if !is_shell_script(&normalized) {
361                bail!("source path must end with .sh or .ps1");
362            }
363        }
364        crate::bin_links::LinkRuntime::Bun => {
365            if !is_bun_script(&normalized) {
366                bail!("bun source path must end with .ts, .js, .mts, or .mjs");
367            }
368        }
369    }
370    Ok(normalized)
371}
372
373fn is_shell_script(path: &Path) -> bool {
374    matches!(
375        path.extension().and_then(|ext| ext.to_str()),
376        Some("sh" | "ps1")
377    )
378}
379
380fn is_bun_script(path: &Path) -> bool {
381    matches!(
382        path.extension().and_then(|ext| ext.to_str()),
383        Some("ts" | "js" | "mts" | "mjs")
384    )
385}
386
387fn parse_runtime(value: Option<&str>) -> Result<crate::bin_links::LinkRuntime> {
388    match value {
389        None | Some("native") => Ok(crate::bin_links::LinkRuntime::Native),
390        Some("bun") => Ok(crate::bin_links::LinkRuntime::Bun),
391        Some(other) => bail!("unsupported runtime `{other}` (expected `bun`)"),
392    }
393}
394
395/// A validated `[[files]]` entry, before its script bytes are read for the
396/// description. Shared by the embedded and installed metadata loaders.
397struct ResolvedFile {
398    source_rel: PathBuf,
399    command_name: String,
400    /// Optional `description = "..."` from `[[files]]`; when set it overrides the
401    /// description parsed from the source's leading comment block.
402    description: Option<String>,
403    needs_source: bool,
404    runtime: crate::bin_links::LinkRuntime,
405    transforms: Vec<String>,
406    env: Vec<crate::env::EnvVarSpec>,
407}
408
409impl ResolvedFile {
410    fn native(source_rel: PathBuf, command_name: String) -> Self {
411        Self {
412            source_rel,
413            command_name,
414            description: None,
415            needs_source: false,
416            runtime: crate::bin_links::LinkRuntime::Native,
417            transforms: Vec::new(),
418            env: Vec::new(),
419        }
420    }
421
422    /// Resolve the command description from the source `bytes`: an explicit
423    /// metadata `description` wins; otherwise parse the source's leading comment
424    /// block with the runtime-correct leader (`//` for bun, `#` for native).
425    fn describe(&self, bytes: &[u8]) -> Vec<String> {
426        if let Some(description) = &self.description {
427            return vec![description.clone()];
428        }
429        match self.runtime {
430            crate::bin_links::LinkRuntime::Bun => presets::parse_bun_description(bytes),
431            crate::bin_links::LinkRuntime::Native => presets::parse_script_description(bytes),
432        }
433    }
434
435    fn into_shell_file(self, description: Vec<String>) -> ShellFile {
436        ShellFile {
437            source_rel: self.source_rel,
438            command_name: self.command_name,
439            description,
440            needs_source: self.needs_source,
441            runtime: self.runtime,
442            transforms: self.transforms,
443            env: self.env,
444        }
445    }
446}
447
448fn resolve_metadata_file(file: &FileToml, ctx: &str) -> Result<ResolvedFile> {
449    let runtime = parse_runtime(file.runtime.as_deref())
450        .with_context(|| format!("invalid runtime in {ctx}"))?;
451    let needs_source = file.needs_source.unwrap_or(false);
452    if runtime == crate::bin_links::LinkRuntime::Bun && needs_source {
453        bail!("{ctx}: `runtime = \"bun\"` cannot be combined with `needs_source = true`");
454    }
455    let source_rel = normalize_source(&file.source, runtime)
456        .with_context(|| format!("invalid source in {ctx}"))?;
457    let command_name = resolve_command_name(&source_rel, file.target.as_deref())
458        .with_context(|| format!("invalid target in {ctx}"))?;
459    let transforms = file.transforms.clone().unwrap_or_default();
460    let env = crate::env::parse_env_specs(file.env.as_deref().unwrap_or_default())
461        .with_context(|| format!("invalid env in {ctx}"))?;
462    if runtime != crate::bin_links::LinkRuntime::Bun && !env.is_empty() {
463        bail!("{ctx}: `env` is only valid when `runtime = \"bun\"`");
464    }
465    Ok(ResolvedFile {
466        source_rel,
467        command_name,
468        description: file.description.clone(),
469        needs_source,
470        runtime,
471        transforms,
472        env,
473    })
474}
475
476fn resolve_command_name(source_rel: &Path, target: Option<&str>) -> Result<String> {
477    match target {
478        Some(target) => validate_command_name(target),
479        None => default_command_name(source_rel),
480    }
481}
482
483fn default_command_name(source_rel: &Path) -> Result<String> {
484    let stem = crate::bin_links::link_stem(source_rel);
485    let stem = stem
486        .into_string()
487        .map_err(|_| anyhow::anyhow!("command name must be valid UTF-8"))?;
488    validate_command_name(&stem)
489}
490
491fn validate_command_name(target: &str) -> Result<String> {
492    let trimmed = target.trim();
493    if trimmed.is_empty() {
494        bail!("command name must not be empty");
495    }
496    if trimmed == "." || trimmed == ".." {
497        bail!("command name must be a plain filename");
498    }
499    let path = Path::new(trimmed);
500    match path.components().next() {
501        Some(Component::Normal(_)) if path.components().count() == 1 => Ok(trimmed.to_string()),
502        _ => bail!("command name must be a plain filename"),
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use tokio::fs;
510
511    async fn make_temp_dir() -> PathBuf {
512        crate::test_support::make_temp_dir("shine-shell-meta").await
513    }
514
515    #[test]
516    fn embedded_proxy_category_uses_renamed_commands() {
517        let categories = load_embedded_categories(Some("proxy")).unwrap();
518        let proxy = categories.iter().find(|cat| cat.name == "proxy").unwrap();
519        let names: Vec<_> = proxy
520            .files
521            .iter()
522            .map(|file| file.command_name.as_str())
523            .collect();
524        assert!(names.contains(&"setproxy"));
525        assert!(names.contains(&"usetproxy"));
526        assert!(!names.contains(&"set_proxy"));
527    }
528
529    #[test]
530    fn embedded_proxy_category_uses_platform_specific_scripts() {
531        let categories = load_embedded_categories(Some("proxy")).unwrap();
532        let proxy = categories.iter().find(|cat| cat.name == "proxy").unwrap();
533        let sources: Vec<_> = proxy
534            .files
535            .iter()
536            .map(|file| file.source_rel.as_path())
537            .collect();
538
539        if cfg!(windows) {
540            assert!(sources.contains(&Path::new("set_proxy.ps1")));
541            assert!(sources.contains(&Path::new("uset_proxy.ps1")));
542            assert!(!sources.contains(&Path::new("set_proxy.sh")));
543            assert!(!sources.contains(&Path::new("uset_proxy.sh")));
544        } else {
545            assert!(sources.contains(&Path::new("set_proxy.sh")));
546            assert!(sources.contains(&Path::new("uset_proxy.sh")));
547            assert!(!sources.contains(&Path::new("set_proxy.ps1")));
548            assert!(!sources.contains(&Path::new("uset_proxy.ps1")));
549        }
550    }
551
552    #[test]
553    fn metadata_platform_filter_accepts_current_platform() {
554        let file = FileToml {
555            source: "set_proxy.ps1".to_string(),
556            target: Some("setproxy".to_string()),
557            description: None,
558            needs_source: Some(true),
559            platforms: Some(vec!["windows".to_string()]),
560            runtime: None,
561            transforms: None,
562            env: None,
563        };
564
565        assert!(file_matches_platform("proxy", &file, "windows").unwrap());
566        assert!(!file_matches_platform("proxy", &file, "unix").unwrap());
567    }
568
569    #[test]
570    fn metadata_platform_filter_defaults_to_all_platforms() {
571        let file = FileToml {
572            source: "set_proxy.sh".to_string(),
573            target: Some("setproxy".to_string()),
574            description: None,
575            needs_source: Some(true),
576            platforms: None,
577            runtime: None,
578            transforms: None,
579            env: None,
580        };
581
582        assert!(file_matches_platform("proxy", &file, "windows").unwrap());
583        assert!(file_matches_platform("proxy", &file, "unix").unwrap());
584    }
585
586    #[test]
587    fn metadata_platform_filter_rejects_unknown_platforms() {
588        let file = FileToml {
589            source: "set_proxy.sh".to_string(),
590            target: Some("setproxy".to_string()),
591            description: None,
592            needs_source: Some(true),
593            platforms: Some(vec!["plan9".to_string()]),
594            runtime: None,
595            transforms: None,
596            env: None,
597        };
598
599        let err = file_matches_platform("proxy", &file, "unix")
600            .unwrap_err()
601            .to_string();
602        assert!(err.contains("unsupported platform `plan9`"));
603    }
604
605    #[test]
606    fn embedded_agent_category_uses_cross_platform_bun_entry() {
607        let categories = load_embedded_categories(Some("agent")).unwrap();
608        let agent = categories.iter().find(|cat| cat.name == "agent").unwrap();
609
610        assert_eq!(agent.files.len(), 1);
611        assert_eq!(agent.files[0].command_name, "ccenv");
612        assert_eq!(agent.files[0].source_rel, PathBuf::from("cc.ts"));
613        assert!(!agent.files[0].needs_source);
614        assert_eq!(agent.files[0].runtime, crate::bin_links::LinkRuntime::Bun);
615        assert!(agent.files[0].transforms.is_empty());
616        assert!(agent.files[0].env.is_empty());
617    }
618
619    #[test]
620    fn embedded_utils_category_exposes_copyfile_command() {
621        let categories = load_embedded_categories(Some("utils")).unwrap();
622        let utils = categories.iter().find(|cat| cat.name == "utils").unwrap();
623
624        if cfg!(windows) {
625            assert_eq!(utils.files.len(), 2);
626            let env_export = utils
627                .files
628                .iter()
629                .find(|f| f.command_name == "shine-env-export")
630                .expect("shine-env-export should be present");
631            assert!(env_export.needs_source);
632
633            let theme_sync = utils
634                .files
635                .iter()
636                .find(|f| f.command_name == "shine-theme-sync")
637                .expect("shine-theme-sync should be present");
638            assert_eq!(theme_sync.source_rel, PathBuf::from("shine-theme-sync.ps1"));
639            assert!(theme_sync.needs_source);
640        } else {
641            assert_eq!(utils.files.len(), 3);
642            let copyfile = utils
643                .files
644                .iter()
645                .find(|f| f.command_name == "copyfile")
646                .expect("copyfile should be present");
647            assert_eq!(copyfile.source_rel, PathBuf::from("copyfile.sh"));
648            assert!(!copyfile.needs_source);
649            assert!(
650                copyfile.description.contains(
651                    &"Copy a file's contents to the local clipboard via OSC52.".to_string()
652                )
653            );
654
655            let env_export = utils
656                .files
657                .iter()
658                .find(|f| f.command_name == "shine-env-export")
659                .expect("shine-env-export should be present");
660            assert_eq!(env_export.source_rel, PathBuf::from("shine-env-export.sh"));
661            assert!(env_export.needs_source);
662
663            let theme_sync = utils
664                .files
665                .iter()
666                .find(|f| f.command_name == "shine-theme-sync")
667                .expect("shine-theme-sync should be present");
668            assert_eq!(theme_sync.source_rel, PathBuf::from("shine-theme-sync.sh"));
669            assert!(theme_sync.needs_source);
670        }
671    }
672
673    #[tokio::test]
674    async fn installed_metadata_applies_target_names() {
675        let dir = make_temp_dir().await;
676        let category_root = dir.join("presets/shell/custom");
677        fs::create_dir_all(&category_root).await.unwrap();
678        fs::write(
679            category_root.join("shine.toml"),
680            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\n",
681        )
682        .await
683        .unwrap();
684        fs::write(
685            category_root.join("set_proxy.sh"),
686            b"#!/bin/bash\n# Set proxy.\n",
687        )
688        .await
689        .unwrap();
690
691        let mut config = Config::new_for_test(&dir);
692        config.is_external_presets = true;
693        let categories = load_installed_categories(&config, Some("custom"))
694            .await
695            .unwrap();
696        assert_eq!(categories.len(), 1);
697        assert_eq!(categories[0].files[0].command_name, "setproxy");
698
699        fs::remove_dir_all(&dir).await.unwrap();
700    }
701
702    #[tokio::test]
703    async fn external_presets_and_overlay_categories_are_merged() {
704        let dir = make_temp_dir().await;
705        let base_root = dir.join("presets/shell/custom");
706        let overlay = dir.join("overlay");
707        let overlay_root = overlay.join("shell/custom");
708        let overlay_only = overlay.join("shell/personal");
709        fs::create_dir_all(&base_root).await.unwrap();
710        fs::create_dir_all(&overlay_root).await.unwrap();
711        fs::create_dir_all(&overlay_only).await.unwrap();
712        fs::write(
713            base_root.join("shine.toml"),
714            b"[[files]]\nsource = \"tool.sh\"\n",
715        )
716        .await
717        .unwrap();
718        fs::write(base_root.join("tool.sh"), b"#!/bin/bash\n# Base tool.\n")
719            .await
720            .unwrap();
721        fs::write(
722            overlay_root.join("tool.sh"),
723            b"#!/bin/bash\n# Overlay tool.\n",
724        )
725        .await
726        .unwrap();
727        fs::write(
728            overlay_only.join("personal.sh"),
729            b"#!/bin/bash\n# Personal tool.\n",
730        )
731        .await
732        .unwrap();
733
734        let mut config = Config::new_for_test(&dir);
735        config.is_external_presets = true;
736        config.presets_overlay_dir_override = Some(overlay);
737        let categories = load_installed_categories(&config, None).await.unwrap();
738
739        let custom = categories.iter().find(|cat| cat.name == "custom").unwrap();
740        assert_eq!(custom.files[0].description, vec!["Overlay tool."]);
741        assert!(categories.iter().any(|cat| cat.name == "personal"));
742
743        fs::remove_dir_all(&dir).await.unwrap();
744    }
745
746    #[tokio::test]
747    async fn installed_category_accepts_powershell_scripts() {
748        let dir = make_temp_dir().await;
749        let category_root = dir.join("presets/shell/custom");
750        fs::create_dir_all(&category_root).await.unwrap();
751        fs::write(
752            category_root.join("tool.ps1"),
753            b"# Tool.\nWrite-Output hi\n",
754        )
755        .await
756        .unwrap();
757
758        let mut config = Config::new_for_test(&dir);
759        config.is_external_presets = true;
760        let categories = load_installed_categories(&config, Some("custom"))
761            .await
762            .unwrap();
763
764        assert_eq!(categories.len(), 1);
765        assert_eq!(categories[0].files[0].source_rel, PathBuf::from("tool.ps1"));
766        assert_eq!(categories[0].files[0].command_name, "tool");
767
768        fs::remove_dir_all(&dir).await.unwrap();
769    }
770
771    #[tokio::test]
772    async fn installed_metadata_filters_platform_specific_files() {
773        let dir = make_temp_dir().await;
774        let category_root = dir.join("presets/shell/custom");
775        fs::create_dir_all(&category_root).await.unwrap();
776        fs::write(
777            category_root.join("shine.toml"),
778            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"tool\"\nplatforms = [\"unix\"]\n\n[[files]]\nsource = \"tool.ps1\"\ntarget = \"tool\"\nplatforms = [\"windows\"]\n",
779        )
780        .await
781        .unwrap();
782        fs::write(category_root.join("tool.sh"), b"#!/bin/bash\n")
783            .await
784            .unwrap();
785        fs::write(category_root.join("tool.ps1"), b"Write-Output hi\n")
786            .await
787            .unwrap();
788
789        let mut config = Config::new_for_test(&dir);
790        config.is_external_presets = true;
791        let categories = load_installed_categories(&config, Some("custom"))
792            .await
793            .unwrap();
794
795        assert_eq!(categories.len(), 1);
796        assert_eq!(categories[0].files.len(), 1);
797        let expected = if cfg!(windows) { "tool.ps1" } else { "tool.sh" };
798        assert_eq!(categories[0].files[0].source_rel, PathBuf::from(expected));
799        assert_eq!(categories[0].files[0].command_name, "tool");
800
801        fs::remove_dir_all(&dir).await.unwrap();
802    }
803
804    #[test]
805    fn rejects_invalid_command_names() {
806        let err = validate_command_name("bin/setproxy")
807            .unwrap_err()
808            .to_string();
809        assert!(err.contains("plain filename"));
810    }
811
812    #[test]
813    fn parse_runtime_accepts_native_and_bun_rejects_others() {
814        use crate::bin_links::LinkRuntime;
815        assert_eq!(parse_runtime(None).unwrap(), LinkRuntime::Native);
816        assert_eq!(parse_runtime(Some("native")).unwrap(), LinkRuntime::Native);
817        assert_eq!(parse_runtime(Some("bun")).unwrap(), LinkRuntime::Bun);
818        let err = parse_runtime(Some("deno")).unwrap_err().to_string();
819        assert!(err.contains("unsupported runtime"));
820    }
821
822    #[test]
823    fn normalize_source_enforces_extension_per_runtime() {
824        use crate::bin_links::LinkRuntime;
825        for ext in ["ts", "js", "mts", "mjs"] {
826            assert!(
827                normalize_source(format!("tool.{ext}"), LinkRuntime::Bun).is_ok(),
828                ".{ext} should be a valid bun source"
829            );
830        }
831        assert!(normalize_source("tool.sh", LinkRuntime::Bun).is_err());
832        assert!(normalize_source("tool.ts", LinkRuntime::Native).is_err());
833        assert!(normalize_source("tool.sh", LinkRuntime::Native).is_ok());
834        // Path traversal is rejected regardless of runtime.
835        assert!(normalize_source("../evil.ts", LinkRuntime::Bun).is_err());
836    }
837
838    async fn write_bun_category(dir: &Path, shine_toml: &[u8]) -> Config {
839        let category_root = dir.join("presets/shell/custom");
840        fs::create_dir_all(&category_root).await.unwrap();
841        fs::write(category_root.join("shine.toml"), shine_toml)
842            .await
843            .unwrap();
844        fs::write(category_root.join("tool.ts"), b"// tool\n")
845            .await
846            .unwrap();
847        let mut config = Config::new_for_test(dir);
848        config.is_external_presets = true;
849        config
850    }
851
852    #[tokio::test]
853    async fn installed_metadata_accepts_bun_runtime_with_transforms() {
854        let dir = make_temp_dir().await;
855        let config = write_bun_category(
856            &dir,
857            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ntransforms = [\"template\"]\n",
858        )
859        .await;
860
861        let categories = load_installed_categories(&config, Some("custom"))
862            .await
863            .unwrap();
864        let file = &categories[0].files[0];
865        assert_eq!(file.command_name, "mytool");
866        assert_eq!(file.source_rel, PathBuf::from("tool.ts"));
867        assert_eq!(file.runtime, crate::bin_links::LinkRuntime::Bun);
868        assert_eq!(file.transforms, vec!["template".to_string()]);
869        assert!(!file.needs_source);
870
871        fs::remove_dir_all(&dir).await.unwrap();
872    }
873
874    #[tokio::test]
875    async fn installed_metadata_defaults_bun_command_name_to_stem() {
876        let dir = make_temp_dir().await;
877        let config = write_bun_category(
878            &dir,
879            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\n",
880        )
881        .await;
882
883        let categories = load_installed_categories(&config, Some("custom"))
884            .await
885            .unwrap();
886        assert_eq!(categories[0].files[0].command_name, "tool");
887
888        fs::remove_dir_all(&dir).await.unwrap();
889    }
890
891    #[tokio::test]
892    async fn installed_metadata_rejects_bun_with_needs_source() {
893        let dir = make_temp_dir().await;
894        let config = write_bun_category(
895            &dir,
896            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\nneeds_source = true\n",
897        )
898        .await;
899
900        let err = load_installed_categories(&config, Some("custom"))
901            .await
902            .unwrap_err()
903            .to_string();
904        assert!(err.contains("needs_source"), "unexpected error: {err}");
905
906        fs::remove_dir_all(&dir).await.unwrap();
907    }
908
909    #[tokio::test]
910    async fn installed_metadata_rejects_unknown_runtime() {
911        let dir = make_temp_dir().await;
912        let config = write_bun_category(
913            &dir,
914            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"deno\"\n",
915        )
916        .await;
917
918        let err = format!(
919            "{:#}",
920            load_installed_categories(&config, Some("custom"))
921                .await
922                .unwrap_err()
923        );
924        assert!(
925            err.contains("unsupported runtime"),
926            "unexpected error: {err}"
927        );
928
929        fs::remove_dir_all(&dir).await.unwrap();
930    }
931
932    #[tokio::test]
933    async fn installed_metadata_parses_bun_env_declarations_in_order() {
934        let dir = make_temp_dir().await;
935        let config = write_bun_category(
936            &dir,
937            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\nenv = [\"API_URL\", \"SERVICE_TOKEN=API_TOKEN\"]\n",
938        )
939        .await;
940
941        let categories = load_installed_categories(&config, Some("custom"))
942            .await
943            .unwrap();
944        let env = &categories[0].files[0].env;
945        assert_eq!(env.len(), 2);
946        assert_eq!(env[0].to_with_arg(), "API_URL");
947        assert_eq!(env[1].to_with_arg(), "SERVICE_TOKEN=API_TOKEN");
948
949        fs::remove_dir_all(&dir).await.unwrap();
950    }
951
952    #[tokio::test]
953    async fn installed_metadata_rejects_env_on_native_entry() {
954        let dir = make_temp_dir().await;
955        let category_root = dir.join("presets/shell/custom");
956        fs::create_dir_all(&category_root).await.unwrap();
957        fs::write(
958            category_root.join("shine.toml"),
959            b"[[files]]\nsource = \"tool.sh\"\nenv = [\"API_URL\"]\n",
960        )
961        .await
962        .unwrap();
963        fs::write(category_root.join("tool.sh"), b"#!/bin/bash\n")
964            .await
965            .unwrap();
966        let mut config = Config::new_for_test(&dir);
967        config.is_external_presets = true;
968
969        let err = format!(
970            "{:#}",
971            load_installed_categories(&config, Some("custom"))
972                .await
973                .unwrap_err()
974        );
975        assert!(
976            err.contains("`env` is only valid when `runtime = \"bun\"`"),
977            "unexpected error: {err}"
978        );
979
980        fs::remove_dir_all(&dir).await.unwrap();
981    }
982
983    #[tokio::test]
984    async fn installed_metadata_rejects_bun_env_invalid_name() {
985        let dir = make_temp_dir().await;
986        let config = write_bun_category(
987            &dir,
988            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\nenv = [\"BAD-NAME\"]\n",
989        )
990        .await;
991
992        let err = format!(
993            "{:#}",
994            load_installed_categories(&config, Some("custom"))
995                .await
996                .unwrap_err()
997        );
998        assert!(
999            err.contains("invalid environment variable name"),
1000            "unexpected error: {err}"
1001        );
1002
1003        fs::remove_dir_all(&dir).await.unwrap();
1004    }
1005
1006    #[tokio::test]
1007    async fn installed_metadata_rejects_bun_env_duplicate_target() {
1008        let dir = make_temp_dir().await;
1009        let config = write_bun_category(
1010            &dir,
1011            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\nenv = [\"A=TOKEN\", \"B=TOKEN\"]\n",
1012        )
1013        .await;
1014
1015        let err = format!(
1016            "{:#}",
1017            load_installed_categories(&config, Some("custom"))
1018                .await
1019                .unwrap_err()
1020        );
1021        assert!(
1022            err.contains("duplicate target variable"),
1023            "unexpected error: {err}"
1024        );
1025
1026        fs::remove_dir_all(&dir).await.unwrap();
1027    }
1028
1029    #[tokio::test]
1030    async fn installed_metadata_bun_description_from_slash_header() {
1031        let dir = make_temp_dir().await;
1032        let category_root = dir.join("presets/shell/custom");
1033        fs::create_dir_all(&category_root).await.unwrap();
1034        fs::write(
1035            category_root.join("shine.toml"),
1036            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\n",
1037        )
1038        .await
1039        .unwrap();
1040        fs::write(
1041            category_root.join("tool.ts"),
1042            b"// Fetch and print status.\n// Reads Bun.env.API_URL.\nconsole.log('hi')\n",
1043        )
1044        .await
1045        .unwrap();
1046        let mut config = Config::new_for_test(&dir);
1047        config.is_external_presets = true;
1048
1049        let categories = load_installed_categories(&config, Some("custom"))
1050            .await
1051            .unwrap();
1052        assert_eq!(
1053            categories[0].files[0].description,
1054            vec!["Fetch and print status.", "Reads Bun.env.API_URL."]
1055        );
1056
1057        fs::remove_dir_all(&dir).await.unwrap();
1058    }
1059
1060    #[tokio::test]
1061    async fn installed_metadata_file_description_overrides_bun_header() {
1062        let dir = make_temp_dir().await;
1063        let category_root = dir.join("presets/shell/custom");
1064        fs::create_dir_all(&category_root).await.unwrap();
1065        fs::write(
1066            category_root.join("shine.toml"),
1067            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ndescription = \"Explicit metadata description.\"\n",
1068        )
1069        .await
1070        .unwrap();
1071        fs::write(
1072            category_root.join("tool.ts"),
1073            b"// header that should be overridden\nconsole.log('hi')\n",
1074        )
1075        .await
1076        .unwrap();
1077        let mut config = Config::new_for_test(&dir);
1078        config.is_external_presets = true;
1079
1080        let categories = load_installed_categories(&config, Some("custom"))
1081            .await
1082            .unwrap();
1083        assert_eq!(
1084            categories[0].files[0].description,
1085            vec!["Explicit metadata description."]
1086        );
1087
1088        fs::remove_dir_all(&dir).await.unwrap();
1089    }
1090
1091    #[tokio::test]
1092    async fn installed_metadata_file_description_overrides_native_hash_header() {
1093        let dir = make_temp_dir().await;
1094        let category_root = dir.join("presets/shell/custom");
1095        fs::create_dir_all(&category_root).await.unwrap();
1096        fs::write(
1097            category_root.join("shine.toml"),
1098            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\ndescription = \"From metadata.\"\n",
1099        )
1100        .await
1101        .unwrap();
1102        fs::write(
1103            category_root.join("tool.sh"),
1104            b"#!/bin/bash\n# hash header that should be overridden\necho hi\n",
1105        )
1106        .await
1107        .unwrap();
1108        let mut config = Config::new_for_test(&dir);
1109        config.is_external_presets = true;
1110
1111        let categories = load_installed_categories(&config, Some("custom"))
1112            .await
1113            .unwrap();
1114        assert_eq!(categories[0].files[0].description, vec!["From metadata."]);
1115
1116        fs::remove_dir_all(&dir).await.unwrap();
1117    }
1118
1119    #[tokio::test]
1120    async fn installed_metadata_rejects_bun_source_with_shell_extension() {
1121        let dir = make_temp_dir().await;
1122        let category_root = dir.join("presets/shell/custom");
1123        fs::create_dir_all(&category_root).await.unwrap();
1124        fs::write(
1125            category_root.join("shine.toml"),
1126            b"[[files]]\nsource = \"tool.sh\"\nruntime = \"bun\"\n",
1127        )
1128        .await
1129        .unwrap();
1130        fs::write(category_root.join("tool.sh"), b"#!/bin/bash\n")
1131            .await
1132            .unwrap();
1133        let mut config = Config::new_for_test(&dir);
1134        config.is_external_presets = true;
1135
1136        let err = format!(
1137            "{:#}",
1138            load_installed_categories(&config, Some("custom"))
1139                .await
1140                .unwrap_err()
1141        );
1142        assert!(err.contains("bun source path"), "unexpected error: {err}");
1143
1144        fs::remove_dir_all(&dir).await.unwrap();
1145    }
1146}