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