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_utils_category_exposes_copyfile_command() {
667        let categories = load_embedded_categories(Some("utils")).unwrap();
668        let utils = categories.iter().find(|cat| cat.name == "utils").unwrap();
669
670        if cfg!(windows) {
671            assert_eq!(utils.files.len(), 2);
672            let env_export = utils
673                .files
674                .iter()
675                .find(|f| f.command_name == "shine-env-export")
676                .expect("shine-env-export should be present");
677            assert!(env_export.needs_source);
678
679            let theme_sync = utils
680                .files
681                .iter()
682                .find(|f| f.command_name == "shine-theme-sync")
683                .expect("shine-theme-sync should be present");
684            assert_eq!(theme_sync.source_rel, PathBuf::from("shine-theme-sync.ps1"));
685            assert!(theme_sync.needs_source);
686        } else {
687            assert_eq!(utils.files.len(), 3);
688            let copyfile = utils
689                .files
690                .iter()
691                .find(|f| f.command_name == "copyfile")
692                .expect("copyfile should be present");
693            assert_eq!(copyfile.source_rel, PathBuf::from("copyfile.sh"));
694            assert!(!copyfile.needs_source);
695            assert!(
696                copyfile.description.contains(
697                    &"Copy a file's contents to the local clipboard via OSC52.".to_string()
698                )
699            );
700
701            let env_export = utils
702                .files
703                .iter()
704                .find(|f| f.command_name == "shine-env-export")
705                .expect("shine-env-export should be present");
706            assert_eq!(env_export.source_rel, PathBuf::from("shine-env-export.sh"));
707            assert!(env_export.needs_source);
708
709            let theme_sync = utils
710                .files
711                .iter()
712                .find(|f| f.command_name == "shine-theme-sync")
713                .expect("shine-theme-sync should be present");
714            assert_eq!(theme_sync.source_rel, PathBuf::from("shine-theme-sync.sh"));
715            assert!(theme_sync.needs_source);
716        }
717    }
718
719    #[tokio::test]
720    async fn installed_metadata_applies_target_names() {
721        let dir = make_temp_dir().await;
722        let category_root = dir.join("presets/shell/custom");
723        fs::create_dir_all(&category_root).await.unwrap();
724        fs::write(
725            category_root.join("shine.toml"),
726            b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\n",
727        )
728        .await
729        .unwrap();
730        fs::write(
731            category_root.join("set_proxy.sh"),
732            b"#!/bin/bash\n# Set proxy.\n",
733        )
734        .await
735        .unwrap();
736
737        let mut config = Config::new_for_test(&dir);
738        config.is_external_presets = true;
739        let categories = load_installed_categories(&config, Some("custom"))
740            .await
741            .unwrap();
742        assert_eq!(categories.len(), 1);
743        assert_eq!(categories[0].files[0].command_name, "setproxy");
744
745        fs::remove_dir_all(&dir).await.unwrap();
746    }
747
748    #[tokio::test]
749    async fn external_presets_and_overlay_categories_are_merged() {
750        let dir = make_temp_dir().await;
751        let base_root = dir.join("presets/shell/custom");
752        let overlay = dir.join("overlay");
753        let overlay_root = overlay.join("shell/custom");
754        let overlay_only = overlay.join("shell/personal");
755        fs::create_dir_all(&base_root).await.unwrap();
756        fs::create_dir_all(&overlay_root).await.unwrap();
757        fs::create_dir_all(&overlay_only).await.unwrap();
758        fs::write(
759            base_root.join("shine.toml"),
760            b"[[files]]\nsource = \"tool.sh\"\n",
761        )
762        .await
763        .unwrap();
764        fs::write(base_root.join("tool.sh"), b"#!/bin/bash\n# Base tool.\n")
765            .await
766            .unwrap();
767        fs::write(
768            overlay_root.join("tool.sh"),
769            b"#!/bin/bash\n# Overlay tool.\n",
770        )
771        .await
772        .unwrap();
773        fs::write(
774            overlay_only.join("personal.sh"),
775            b"#!/bin/bash\n# Personal tool.\n",
776        )
777        .await
778        .unwrap();
779
780        let mut config = Config::new_for_test(&dir);
781        config.is_external_presets = true;
782        config.presets_overlay_dir_override = Some(overlay);
783        let categories = load_installed_categories(&config, None).await.unwrap();
784
785        let custom = categories.iter().find(|cat| cat.name == "custom").unwrap();
786        assert_eq!(custom.files[0].description, vec!["Overlay tool."]);
787        assert!(categories.iter().any(|cat| cat.name == "personal"));
788
789        fs::remove_dir_all(&dir).await.unwrap();
790    }
791
792    #[tokio::test]
793    async fn installed_category_accepts_powershell_scripts() {
794        let dir = make_temp_dir().await;
795        let category_root = dir.join("presets/shell/custom");
796        fs::create_dir_all(&category_root).await.unwrap();
797        fs::write(
798            category_root.join("tool.ps1"),
799            b"# Tool.\nWrite-Output hi\n",
800        )
801        .await
802        .unwrap();
803
804        let mut config = Config::new_for_test(&dir);
805        config.is_external_presets = true;
806        let categories = load_installed_categories(&config, Some("custom"))
807            .await
808            .unwrap();
809
810        assert_eq!(categories.len(), 1);
811        assert_eq!(categories[0].files[0].source_rel, PathBuf::from("tool.ps1"));
812        assert_eq!(categories[0].files[0].command_name, "tool");
813
814        fs::remove_dir_all(&dir).await.unwrap();
815    }
816
817    #[tokio::test]
818    async fn installed_metadata_filters_platform_specific_files() {
819        let dir = make_temp_dir().await;
820        let category_root = dir.join("presets/shell/custom");
821        fs::create_dir_all(&category_root).await.unwrap();
822        fs::write(
823            category_root.join("shine.toml"),
824            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"tool\"\nplatforms = [\"unix\"]\n\n[[files]]\nsource = \"tool.ps1\"\ntarget = \"tool\"\nplatforms = [\"windows\"]\n",
825        )
826        .await
827        .unwrap();
828        fs::write(category_root.join("tool.sh"), b"#!/bin/bash\n")
829            .await
830            .unwrap();
831        fs::write(category_root.join("tool.ps1"), b"Write-Output hi\n")
832            .await
833            .unwrap();
834
835        let mut config = Config::new_for_test(&dir);
836        config.is_external_presets = true;
837        let categories = load_installed_categories(&config, Some("custom"))
838            .await
839            .unwrap();
840
841        assert_eq!(categories.len(), 1);
842        assert_eq!(categories[0].files.len(), 1);
843        let expected = if cfg!(windows) { "tool.ps1" } else { "tool.sh" };
844        assert_eq!(categories[0].files[0].source_rel, PathBuf::from(expected));
845        assert_eq!(categories[0].files[0].command_name, "tool");
846
847        fs::remove_dir_all(&dir).await.unwrap();
848    }
849
850    #[test]
851    fn rejects_invalid_command_names() {
852        let err = validate_command_name("bin/setproxy")
853            .unwrap_err()
854            .to_string();
855        assert!(err.contains("plain filename"));
856    }
857
858    #[test]
859    fn parse_runtime_accepts_native_and_bun_rejects_others() {
860        use crate::bin_links::LinkRuntime;
861        assert_eq!(parse_runtime(None).unwrap(), LinkRuntime::Native);
862        assert_eq!(parse_runtime(Some("native")).unwrap(), LinkRuntime::Native);
863        assert_eq!(parse_runtime(Some("bun")).unwrap(), LinkRuntime::Bun);
864        let err = parse_runtime(Some("deno")).unwrap_err().to_string();
865        assert!(err.contains("unsupported runtime"));
866    }
867
868    #[test]
869    fn normalize_source_enforces_extension_per_runtime() {
870        use crate::bin_links::LinkRuntime;
871        for ext in ["ts", "js", "mts", "mjs"] {
872            assert!(
873                normalize_source(format!("tool.{ext}"), LinkRuntime::Bun).is_ok(),
874                ".{ext} should be a valid bun source"
875            );
876        }
877        assert!(normalize_source("tool.sh", LinkRuntime::Bun).is_err());
878        assert!(normalize_source("tool.ts", LinkRuntime::Native).is_err());
879        assert!(normalize_source("tool.sh", LinkRuntime::Native).is_ok());
880        // Path traversal is rejected regardless of runtime.
881        assert!(normalize_source("../evil.ts", LinkRuntime::Bun).is_err());
882    }
883
884    async fn write_bun_category(dir: &Path, shine_toml: &[u8]) -> Config {
885        let category_root = dir.join("presets/shell/custom");
886        fs::create_dir_all(&category_root).await.unwrap();
887        fs::write(category_root.join("shine.toml"), shine_toml)
888            .await
889            .unwrap();
890        fs::write(category_root.join("tool.ts"), b"// tool\n")
891            .await
892            .unwrap();
893        let mut config = Config::new_for_test(dir);
894        config.is_external_presets = true;
895        config
896    }
897
898    #[tokio::test]
899    async fn installed_metadata_accepts_bun_runtime_with_transforms() {
900        let dir = make_temp_dir().await;
901        let config = write_bun_category(
902            &dir,
903            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ntransforms = [\"template\"]\n",
904        )
905        .await;
906
907        let categories = load_installed_categories(&config, Some("custom"))
908            .await
909            .unwrap();
910        let file = &categories[0].files[0];
911        assert_eq!(file.command_name, "mytool");
912        assert_eq!(file.source_rel, PathBuf::from("tool.ts"));
913        assert_eq!(file.runtime, crate::bin_links::LinkRuntime::Bun);
914        assert_eq!(file.transforms, vec!["template".to_string()]);
915        assert!(!file.needs_source);
916
917        fs::remove_dir_all(&dir).await.unwrap();
918    }
919
920    #[tokio::test]
921    async fn installed_metadata_defaults_bun_command_name_to_stem() {
922        let dir = make_temp_dir().await;
923        let config = write_bun_category(
924            &dir,
925            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\n",
926        )
927        .await;
928
929        let categories = load_installed_categories(&config, Some("custom"))
930            .await
931            .unwrap();
932        assert_eq!(categories[0].files[0].command_name, "tool");
933
934        fs::remove_dir_all(&dir).await.unwrap();
935    }
936
937    #[tokio::test]
938    async fn installed_metadata_rejects_bun_with_needs_source() {
939        let dir = make_temp_dir().await;
940        let config = write_bun_category(
941            &dir,
942            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\nneeds_source = true\n",
943        )
944        .await;
945
946        let err = load_installed_categories(&config, Some("custom"))
947            .await
948            .unwrap_err()
949            .to_string();
950        assert!(err.contains("needs_source"), "unexpected error: {err}");
951
952        fs::remove_dir_all(&dir).await.unwrap();
953    }
954
955    #[tokio::test]
956    async fn installed_metadata_rejects_unknown_runtime() {
957        let dir = make_temp_dir().await;
958        let config = write_bun_category(
959            &dir,
960            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"deno\"\n",
961        )
962        .await;
963
964        let err = format!(
965            "{:#}",
966            load_installed_categories(&config, Some("custom"))
967                .await
968                .unwrap_err()
969        );
970        assert!(
971            err.contains("unsupported runtime"),
972            "unexpected error: {err}"
973        );
974
975        fs::remove_dir_all(&dir).await.unwrap();
976    }
977
978    #[tokio::test]
979    async fn installed_metadata_parses_bun_env_declarations_in_order() {
980        let dir = make_temp_dir().await;
981        let config = write_bun_category(
982            &dir,
983            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\nenv = [\"API_URL\", \"SERVICE_TOKEN=API_TOKEN\"]\n",
984        )
985        .await;
986
987        let categories = load_installed_categories(&config, Some("custom"))
988            .await
989            .unwrap();
990        let env = &categories[0].files[0].env;
991        assert_eq!(env.len(), 2);
992        assert_eq!(env[0].to_with_arg(), "API_URL");
993        assert_eq!(env[1].to_with_arg(), "SERVICE_TOKEN=API_TOKEN");
994
995        fs::remove_dir_all(&dir).await.unwrap();
996    }
997
998    #[tokio::test]
999    async fn installed_metadata_rejects_env_on_native_entry() {
1000        let dir = make_temp_dir().await;
1001        let category_root = dir.join("presets/shell/custom");
1002        fs::create_dir_all(&category_root).await.unwrap();
1003        fs::write(
1004            category_root.join("shine.toml"),
1005            b"[[files]]\nsource = \"tool.sh\"\nenv = [\"API_URL\"]\n",
1006        )
1007        .await
1008        .unwrap();
1009        fs::write(category_root.join("tool.sh"), b"#!/bin/bash\n")
1010            .await
1011            .unwrap();
1012        let mut config = Config::new_for_test(&dir);
1013        config.is_external_presets = true;
1014
1015        let err = format!(
1016            "{:#}",
1017            load_installed_categories(&config, Some("custom"))
1018                .await
1019                .unwrap_err()
1020        );
1021        assert!(
1022            err.contains("`env` is only valid when `runtime = \"bun\"`"),
1023            "unexpected error: {err}"
1024        );
1025
1026        fs::remove_dir_all(&dir).await.unwrap();
1027    }
1028
1029    #[tokio::test]
1030    async fn installed_metadata_rejects_bun_env_invalid_name() {
1031        let dir = make_temp_dir().await;
1032        let config = write_bun_category(
1033            &dir,
1034            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\nenv = [\"BAD-NAME\"]\n",
1035        )
1036        .await;
1037
1038        let err = format!(
1039            "{:#}",
1040            load_installed_categories(&config, Some("custom"))
1041                .await
1042                .unwrap_err()
1043        );
1044        assert!(
1045            err.contains("invalid environment variable name"),
1046            "unexpected error: {err}"
1047        );
1048
1049        fs::remove_dir_all(&dir).await.unwrap();
1050    }
1051
1052    #[tokio::test]
1053    async fn installed_metadata_rejects_bun_env_duplicate_target() {
1054        let dir = make_temp_dir().await;
1055        let config = write_bun_category(
1056            &dir,
1057            b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\nenv = [\"A=TOKEN\", \"B=TOKEN\"]\n",
1058        )
1059        .await;
1060
1061        let err = format!(
1062            "{:#}",
1063            load_installed_categories(&config, Some("custom"))
1064                .await
1065                .unwrap_err()
1066        );
1067        assert!(
1068            err.contains("duplicate target variable"),
1069            "unexpected error: {err}"
1070        );
1071
1072        fs::remove_dir_all(&dir).await.unwrap();
1073    }
1074
1075    #[tokio::test]
1076    async fn installed_metadata_bun_description_from_slash_header() {
1077        let dir = make_temp_dir().await;
1078        let category_root = dir.join("presets/shell/custom");
1079        fs::create_dir_all(&category_root).await.unwrap();
1080        fs::write(
1081            category_root.join("shine.toml"),
1082            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\n",
1083        )
1084        .await
1085        .unwrap();
1086        fs::write(
1087            category_root.join("tool.ts"),
1088            b"// Fetch and print status.\n// Reads Bun.env.API_URL.\nconsole.log('hi')\n",
1089        )
1090        .await
1091        .unwrap();
1092        let mut config = Config::new_for_test(&dir);
1093        config.is_external_presets = true;
1094
1095        let categories = load_installed_categories(&config, Some("custom"))
1096            .await
1097            .unwrap();
1098        assert_eq!(
1099            categories[0].files[0].description,
1100            vec!["Fetch and print status.", "Reads Bun.env.API_URL."]
1101        );
1102
1103        fs::remove_dir_all(&dir).await.unwrap();
1104    }
1105
1106    #[tokio::test]
1107    async fn installed_metadata_file_description_overrides_bun_header() {
1108        let dir = make_temp_dir().await;
1109        let category_root = dir.join("presets/shell/custom");
1110        fs::create_dir_all(&category_root).await.unwrap();
1111        fs::write(
1112            category_root.join("shine.toml"),
1113            b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ndescription = \"Explicit metadata description.\"\n",
1114        )
1115        .await
1116        .unwrap();
1117        fs::write(
1118            category_root.join("tool.ts"),
1119            b"// header that should be overridden\nconsole.log('hi')\n",
1120        )
1121        .await
1122        .unwrap();
1123        let mut config = Config::new_for_test(&dir);
1124        config.is_external_presets = true;
1125
1126        let categories = load_installed_categories(&config, Some("custom"))
1127            .await
1128            .unwrap();
1129        assert_eq!(
1130            categories[0].files[0].description,
1131            vec!["Explicit metadata description."]
1132        );
1133
1134        fs::remove_dir_all(&dir).await.unwrap();
1135    }
1136
1137    #[tokio::test]
1138    async fn installed_metadata_file_description_overrides_native_hash_header() {
1139        let dir = make_temp_dir().await;
1140        let category_root = dir.join("presets/shell/custom");
1141        fs::create_dir_all(&category_root).await.unwrap();
1142        fs::write(
1143            category_root.join("shine.toml"),
1144            b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\ndescription = \"From metadata.\"\n",
1145        )
1146        .await
1147        .unwrap();
1148        fs::write(
1149            category_root.join("tool.sh"),
1150            b"#!/bin/bash\n# hash header that should be overridden\necho hi\n",
1151        )
1152        .await
1153        .unwrap();
1154        let mut config = Config::new_for_test(&dir);
1155        config.is_external_presets = true;
1156
1157        let categories = load_installed_categories(&config, Some("custom"))
1158            .await
1159            .unwrap();
1160        assert_eq!(categories[0].files[0].description, vec!["From metadata."]);
1161
1162        fs::remove_dir_all(&dir).await.unwrap();
1163    }
1164
1165    #[tokio::test]
1166    async fn installed_metadata_rejects_bun_source_with_shell_extension() {
1167        let dir = make_temp_dir().await;
1168        let category_root = dir.join("presets/shell/custom");
1169        fs::create_dir_all(&category_root).await.unwrap();
1170        fs::write(
1171            category_root.join("shine.toml"),
1172            b"[[files]]\nsource = \"tool.sh\"\nruntime = \"bun\"\n",
1173        )
1174        .await
1175        .unwrap();
1176        fs::write(category_root.join("tool.sh"), b"#!/bin/bash\n")
1177            .await
1178            .unwrap();
1179        let mut config = Config::new_for_test(&dir);
1180        config.is_external_presets = true;
1181
1182        let err = format!(
1183            "{:#}",
1184            load_installed_categories(&config, Some("custom"))
1185                .await
1186                .unwrap_err()
1187        );
1188        assert!(err.contains("bun source path"), "unexpected error: {err}");
1189
1190        fs::remove_dir_all(&dir).await.unwrap();
1191    }
1192}