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(Clone, Copy, Debug, Eq, PartialEq)]
40pub struct ShellTarget<'a> {
41 pub category: &'a str,
42 pub command: Option<&'a str>,
43}
44
45pub 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
63pub 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
131pub 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
359fn 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
389fn 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
398fn 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
441struct ResolvedFile {
444 source_rel: PathBuf,
445 command_name: String,
446 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 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 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}