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