1use crate::config::Config;
2use crate::platform::{OperatingSystem, current_platform};
3use crate::presets;
4use anyhow::{Context, Result, bail};
5use serde::Deserialize;
6#[cfg(test)]
7use std::collections::BTreeMap;
8use std::collections::BTreeSet;
9use std::path::{Component, Path, PathBuf};
10use tokio::fs;
11
12use crate::preset_validation::PresetValidationFailure;
13
14#[derive(Debug, Clone)]
15pub struct ShellCategory {
16 pub name: String,
17 pub description: Option<String>,
18 pub files: Vec<ShellFile>,
19 #[allow(dead_code)]
22 pub uses_metadata: bool,
23}
24
25#[derive(Debug, Clone)]
26pub struct ShellFile {
27 pub source_rel: PathBuf,
28 pub command_name: String,
29 pub description: Vec<String>,
30 pub needs_source: bool,
31 pub runtime: crate::bin_links::LinkRuntime,
33 pub transforms: Vec<String>,
37 pub env: Vec<crate::env::EnvVarSpec>,
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub struct ShellTarget<'a> {
45 pub category: &'a str,
46 pub command: Option<&'a str>,
47}
48
49pub fn parse_lifecycle_target(target: &str) -> Result<ShellTarget<'_>> {
52 let target = target.trim();
53 if target.is_empty() {
54 bail!("shell preset target must not be empty");
55 }
56 let mut parts = target.split('/');
57 let category = parts.next().unwrap_or_default();
58 let command = parts.next();
59 if category.is_empty() || command.is_some_and(str::is_empty) || parts.next().is_some() {
60 bail!(
61 "invalid shell preset target `{target}`; expected <category> or <category>/<command>"
62 );
63 }
64 Ok(ShellTarget { category, command })
65}
66
67pub async fn load_active_target(
69 config: &Config,
70 target: ShellTarget<'_>,
71) -> Result<Vec<ShellCategory>> {
72 let mut categories = load_active_categories(config, Some(target.category)).await?;
73 let Some(category) = categories.first_mut() else {
74 bail!("shell preset category not found: {}", target.category);
75 };
76 if let Some(command) = target.command {
77 category.files.retain(|file| file.command_name == command);
78 if category.files.is_empty() {
79 bail!(
80 "shell preset command not found: {}/{}",
81 target.category,
82 command
83 );
84 }
85 }
86 Ok(categories)
87}
88
89#[derive(Debug, Deserialize)]
90struct CategoryToml {
91 description: Option<String>,
92 files: Option<Vec<FileToml>>,
93}
94
95#[derive(Debug, Deserialize)]
96struct FileToml {
97 source: String,
98 target: Option<String>,
99 description: Option<String>,
100 needs_source: Option<bool>,
101 platforms: Option<Vec<String>>,
102 runtime: Option<String>,
103 transforms: Option<Vec<String>>,
104 env: Option<Vec<String>>,
105}
106
107pub fn load_embedded_categories(filter: Option<&str>) -> Result<Vec<ShellCategory>> {
108 let names = collect_embedded_category_names(filter);
109 let mut categories = Vec::new();
110 for name in names {
111 categories.push(load_embedded_category(&name)?);
112 }
113 Ok(categories)
114}
115
116pub async fn load_installed_categories(
117 config: &Config,
118 filter: Option<&str>,
119) -> Result<Vec<ShellCategory>> {
120 let shell_root = config.presets_dir().join("shell");
121 let mut names: BTreeSet<String> = collect_fs_category_names(&shell_root, filter)
122 .await?
123 .into_iter()
124 .collect();
125 if let Some(overlay) = config.active_presets_overlay_dir() {
126 names.extend(collect_fs_category_names(&overlay.join("shell"), filter).await?);
127 }
128 let mut categories = Vec::new();
129 for name in names {
130 categories.push(load_installed_category(config, &name).await?);
131 }
132 Ok(categories)
133}
134
135pub async fn load_active_categories(
140 config: &Config,
141 filter: Option<&str>,
142) -> Result<Vec<ShellCategory>> {
143 if config.is_external_presets {
144 load_installed_categories(config, filter).await
145 } else {
146 load_embedded_categories(filter)
147 }
148}
149
150fn load_embedded_category(name: &str) -> Result<ShellCategory> {
151 let metadata_path = format!("shell/{name}/shine.toml");
152 if let Some(bytes) = presets::read_asset_bytes(&metadata_path) {
153 let parsed = parse_category_toml(name, &bytes)?;
154 let files = match parsed.files {
155 Some(files) => files
156 .into_iter()
157 .filter_map(|file| match file_matches_current_platform(name, &file) {
158 Ok(true) => Some(Ok(file)),
159 Ok(false) => None,
160 Err(err) => Some(Err(err)),
161 })
162 .map(|file| {
163 let file = file?;
164 let ctx = format!("shell/{name}/shine.toml");
165 let resolved = resolve_metadata_file(&file, &ctx)?;
166 let asset_path = format!("shell/{name}/{}", resolved.source_rel.display());
167 let bytes = presets::read_asset_bytes(&asset_path).with_context(|| {
168 format!(
169 "shell/{name}/shine.toml references missing file: {:?}",
170 resolved.source_rel
171 )
172 })?;
173 let description = resolved.describe(&bytes);
174 Ok(resolved.into_shell_file(description))
175 })
176 .collect::<Result<Vec<_>>>()?,
177 None => collect_embedded_scripts(name)?
178 .into_iter()
179 .map(|source_rel| {
180 let asset_path = format!("shell/{name}/{}", source_rel.display());
181 let bytes = presets::read_asset_bytes(&asset_path).unwrap_or_default();
182 let command_name = default_command_name(&source_rel)?;
183 Ok(ShellFile {
184 source_rel,
185 command_name,
186 description: presets::parse_script_description(&bytes),
187 needs_source: false,
188 runtime: crate::bin_links::LinkRuntime::Native,
189 transforms: Vec::new(),
190 env: Vec::new(),
191 })
192 })
193 .collect::<Result<Vec<_>>>()?,
194 };
195
196 return Ok(ShellCategory {
197 name: name.to_string(),
198 description: parsed.description,
199 files,
200 uses_metadata: true,
201 });
202 }
203
204 Ok(ShellCategory {
205 name: name.to_string(),
206 description: None,
207 files: collect_embedded_scripts(name)?
208 .into_iter()
209 .map(|source_rel| {
210 let asset_path = format!("shell/{name}/{}", source_rel.display());
211 let bytes = presets::read_asset_bytes(&asset_path).unwrap_or_default();
212 Ok(ShellFile {
213 command_name: default_command_name(&source_rel)?,
214 description: presets::parse_script_description(&bytes),
215 needs_source: false,
216 runtime: crate::bin_links::LinkRuntime::Native,
217 transforms: Vec::new(),
218 env: Vec::new(),
219 source_rel,
220 })
221 })
222 .collect::<Result<Vec<_>>>()?,
223 uses_metadata: false,
224 })
225}
226
227async fn load_installed_category(config: &Config, name: &str) -> Result<ShellCategory> {
228 let category_rel = Path::new("shell").join(name);
229 let metadata_path = config.preset_path(category_rel.join("shine.toml"));
230
231 if metadata_path.exists() {
232 let bytes = fs::read(&metadata_path)
233 .await
234 .with_context(|| format!("reading metadata: {}", metadata_path.display()))?;
235 let parsed = parse_category_toml(name, &bytes)?;
236 let files = match parsed.files {
237 Some(files) => files
238 .into_iter()
239 .filter_map(|file| match file_matches_current_platform(name, &file) {
240 Ok(true) => Some(Ok(file)),
241 Ok(false) => None,
242 Err(err) => Some(Err(err)),
243 })
244 .map(|file| {
245 let file = file?;
246 let ctx = metadata_path.display().to_string();
247 resolve_metadata_file(&file, &ctx)
248 })
249 .collect::<Result<Vec<_>>>()?,
250 None => collect_merged_fs_scripts(config, &category_rel)
251 .await?
252 .into_iter()
253 .map(|source_rel| {
254 let command_name = default_command_name(&source_rel)?;
255 Ok(ResolvedFile::native(source_rel, command_name))
256 })
257 .collect::<Result<Vec<_>>>()?,
258 };
259
260 let mut shell_files = Vec::new();
261 for resolved in files {
262 let source_path = config.preset_path(category_rel.join(&resolved.source_rel));
263 if !source_path.exists() {
264 bail!(
265 "shell/{name}/shine.toml references missing file: {}",
266 resolved.source_rel.display()
267 );
268 }
269 let bytes = fs::read(&source_path)
270 .await
271 .with_context(|| format!("reading preset file: {}", source_path.display()))?;
272 let description = resolved.describe(&bytes);
273 shell_files.push(resolved.into_shell_file(description));
274 }
275
276 return Ok(ShellCategory {
277 name: name.to_string(),
278 description: parsed.description,
279 files: shell_files,
280 uses_metadata: true,
281 });
282 }
283
284 let mut files = Vec::new();
285 for source_rel in collect_merged_fs_scripts(config, &category_rel).await? {
286 let source_path = config.preset_path(category_rel.join(&source_rel));
287 let bytes = fs::read(&source_path)
288 .await
289 .with_context(|| format!("reading preset file: {}", source_path.display()))?;
290 files.push(ShellFile {
291 command_name: default_command_name(&source_rel)?,
292 description: presets::parse_script_description(&bytes),
293 needs_source: false,
294 runtime: crate::bin_links::LinkRuntime::Native,
295 transforms: Vec::new(),
296 env: Vec::new(),
297 source_rel,
298 });
299 }
300
301 Ok(ShellCategory {
302 name: name.to_string(),
303 description: None,
304 files,
305 uses_metadata: false,
306 })
307}
308
309async fn collect_merged_fs_scripts(config: &Config, category_rel: &Path) -> Result<Vec<PathBuf>> {
310 crate::preset_meta::merge_fs_tree(config, category_rel, "directory", |rel| {
311 if !is_shell_script(rel) {
312 return Ok(None);
313 }
314 Ok(Some(normalize_shell_source(rel)?))
315 })
316 .await
317}
318
319fn parse_category_toml(name: &str, bytes: &[u8]) -> Result<CategoryToml> {
320 toml::from_slice(bytes).with_context(|| format!("failed to parse shell/{name}/shine.toml"))
321}
322
323fn file_matches_current_platform(category: &str, file: &FileToml) -> Result<bool> {
324 file_matches_platform(category, file, current_platform())
325}
326
327fn file_matches_platform(
328 category: &str,
329 file: &FileToml,
330 current: OperatingSystem,
331) -> Result<bool> {
332 crate::preset_meta::platform_matches(
333 file.platforms.as_deref(),
334 current,
335 &format!("shell/{category}/shine.toml"),
336 )
337}
338
339#[cfg(test)]
340pub(crate) fn built_in_platform_availability() -> Result<BTreeMap<String, BTreeSet<OperatingSystem>>>
341{
342 let mut capabilities: BTreeMap<String, BTreeSet<OperatingSystem>> = BTreeMap::new();
343 for name in crate::preset_meta::collect_pristine_embedded_category_names("shell") {
344 let metadata_path = format!("shell/{name}/shine.toml");
345 if let Some(bytes) = presets::read_embedded_asset_bytes(&metadata_path) {
346 let parsed = parse_category_toml(&name, &bytes)?;
347 if let Some(files) = parsed.files {
348 for file in files {
349 let command = resolve_metadata_file(&file, &metadata_path)?.command_name;
350 let platforms = capabilities
351 .entry(format!("shell/{name}/{command}"))
352 .or_default();
353 for platform in OperatingSystem::ALL {
354 if file_matches_platform(&name, &file, platform)? {
355 platforms.insert(platform);
356 }
357 }
358 }
359 continue;
360 }
361 }
362
363 let prefix = format!("shell/{name}/");
364 for asset_path in presets::embedded_asset_paths(&format!("shell/{name}")) {
365 let Some(relative) = asset_path.strip_prefix(&prefix) else {
366 continue;
367 };
368 let source = PathBuf::from(relative);
369 if relative == "shine.toml" || !is_shell_script(&source) {
370 continue;
371 }
372 let command = default_command_name(&source)?;
373 capabilities.insert(
374 format!("shell/{name}/{command}"),
375 OperatingSystem::ALL.into_iter().collect(),
376 );
377 }
378 }
379 Ok(capabilities)
380}
381
382fn collect_embedded_category_names(filter: Option<&str>) -> Vec<String> {
383 crate::preset_meta::collect_embedded_category_names("shell", filter)
384}
385
386async fn collect_fs_category_names(shell_root: &Path, filter: Option<&str>) -> Result<Vec<String>> {
387 crate::preset_meta::collect_fs_category_names(shell_root, filter, "shell presets directory")
388 .await
389}
390
391fn collect_embedded_scripts(name: &str) -> Result<Vec<PathBuf>> {
392 let prefix = format!("shell/{name}/");
393 let mut scripts = BTreeSet::new();
394 for asset_path in presets::asset_paths(&format!("shell/{name}")) {
395 let Some(rest) = asset_path.strip_prefix(&prefix) else {
396 continue;
397 };
398 if rest == "shine.toml" {
399 continue;
400 }
401 let rel = PathBuf::from(rest);
402 if !is_shell_script(&rel) {
403 continue;
404 }
405 scripts.insert(normalize_shell_source(rest)?);
406 }
407 Ok(scripts.into_iter().collect())
408}
409
410fn normalize_relative_source(path: impl AsRef<Path>) -> Result<PathBuf> {
413 let path = path.as_ref();
414 if path.as_os_str().is_empty() {
415 bail!("source path must not be empty");
416 }
417 if path.is_absolute() {
418 bail!("source path must be relative");
419 }
420
421 let mut normalized = PathBuf::new();
422 for component in path.components() {
423 match component {
424 Component::Normal(part) => normalized.push(part),
425 Component::CurDir => {}
426 Component::ParentDir => bail!("source path must not contain '..'"),
427 _ => bail!("source path must be relative"),
428 }
429 }
430
431 if normalized.as_os_str().is_empty() {
432 bail!("source path must not be empty");
433 }
434 if normalized.file_name().and_then(|name| name.to_str()) == Some("shine.toml") {
435 bail!("source path must not point to shine.toml");
436 }
437 Ok(normalized)
438}
439
440fn normalize_shell_source(path: impl AsRef<Path>) -> Result<PathBuf> {
442 let normalized = normalize_relative_source(path)?;
443 if !is_shell_script(&normalized) {
444 bail!("source path must end with .sh or .ps1");
445 }
446 Ok(normalized)
447}
448
449fn normalize_source(
451 path: impl AsRef<Path>,
452 runtime: crate::bin_links::LinkRuntime,
453) -> Result<PathBuf> {
454 let normalized = normalize_relative_source(path)?;
455 match runtime {
456 crate::bin_links::LinkRuntime::Native => {
457 if !is_shell_script(&normalized) {
458 bail!("source path must end with .sh or .ps1");
459 }
460 }
461 crate::bin_links::LinkRuntime::Bun => {
462 if !is_bun_script(&normalized) {
463 bail!("bun source path must end with .ts, .js, .mts, or .mjs");
464 }
465 }
466 }
467 Ok(normalized)
468}
469
470fn is_shell_script(path: &Path) -> bool {
471 matches!(
472 path.extension().and_then(|ext| ext.to_str()),
473 Some("sh" | "ps1")
474 )
475}
476
477fn is_bun_script(path: &Path) -> bool {
478 matches!(
479 path.extension().and_then(|ext| ext.to_str()),
480 Some("ts" | "js" | "mts" | "mjs")
481 )
482}
483
484fn parse_runtime(value: Option<&str>) -> Result<crate::bin_links::LinkRuntime> {
485 match value {
486 None | Some("native") => Ok(crate::bin_links::LinkRuntime::Native),
487 Some("bun") => Ok(crate::bin_links::LinkRuntime::Bun),
488 Some(other) => bail!("unsupported runtime `{other}` (expected `bun`)"),
489 }
490}
491
492struct ResolvedFile {
495 source_rel: PathBuf,
496 command_name: String,
497 description: Option<String>,
500 needs_source: bool,
501 runtime: crate::bin_links::LinkRuntime,
502 transforms: Vec<String>,
503 env: Vec<crate::env::EnvVarSpec>,
504}
505
506impl ResolvedFile {
507 fn native(source_rel: PathBuf, command_name: String) -> Self {
508 Self {
509 source_rel,
510 command_name,
511 description: None,
512 needs_source: false,
513 runtime: crate::bin_links::LinkRuntime::Native,
514 transforms: Vec::new(),
515 env: Vec::new(),
516 }
517 }
518
519 fn describe(&self, bytes: &[u8]) -> Vec<String> {
523 if let Some(description) = &self.description {
524 return vec![description.clone()];
525 }
526 match self.runtime {
527 crate::bin_links::LinkRuntime::Bun => presets::parse_bun_description(bytes),
528 crate::bin_links::LinkRuntime::Native => presets::parse_script_description(bytes),
529 }
530 }
531
532 fn into_shell_file(self, description: Vec<String>) -> ShellFile {
533 ShellFile {
534 source_rel: self.source_rel,
535 command_name: self.command_name,
536 description,
537 needs_source: self.needs_source,
538 runtime: self.runtime,
539 transforms: self.transforms,
540 env: self.env,
541 }
542 }
543}
544
545fn resolve_metadata_file(file: &FileToml, ctx: &str) -> Result<ResolvedFile> {
546 let runtime = parse_runtime(file.runtime.as_deref())
547 .with_context(|| format!("invalid runtime in {ctx}"))?;
548 let needs_source = file.needs_source.unwrap_or(false);
549 if runtime == crate::bin_links::LinkRuntime::Bun && needs_source {
550 bail!("{ctx}: `runtime = \"bun\"` cannot be combined with `needs_source = true`");
551 }
552 let source_rel = normalize_source(&file.source, runtime)
553 .with_context(|| format!("invalid source in {ctx}"))?;
554 let command_name = resolve_command_name(&source_rel, file.target.as_deref())
555 .with_context(|| format!("invalid target in {ctx}"))?;
556 let transforms = file.transforms.clone().unwrap_or_default();
557 let env = crate::env::parse_env_specs(file.env.as_deref().unwrap_or_default())
558 .with_context(|| format!("invalid env in {ctx}"))?;
559 if runtime != crate::bin_links::LinkRuntime::Bun && !env.is_empty() {
560 bail!("{ctx}: `env` is only valid when `runtime = \"bun\"`");
561 }
562 Ok(ResolvedFile {
563 source_rel,
564 command_name,
565 description: file.description.clone(),
566 needs_source,
567 runtime,
568 transforms,
569 env,
570 })
571}
572
573fn resolve_command_name(source_rel: &Path, target: Option<&str>) -> Result<String> {
574 match target {
575 Some(target) => validate_command_name(target),
576 None => default_command_name(source_rel),
577 }
578}
579
580fn default_command_name(source_rel: &Path) -> Result<String> {
581 let stem = crate::bin_links::link_stem(source_rel);
582 let stem = stem
583 .into_string()
584 .map_err(|_| anyhow::anyhow!("command name must be valid UTF-8"))?;
585 validate_command_name(&stem)
586}
587
588fn validate_command_name(target: &str) -> Result<String> {
589 let trimmed = target.trim();
590 if trimmed.is_empty() {
591 bail!("command name must not be empty");
592 }
593 if trimmed == "." || trimmed == ".." {
594 bail!("command name must be a plain filename");
595 }
596 let path = Path::new(trimmed);
597 match path.components().next() {
598 Some(Component::Normal(_)) if path.components().count() == 1 => Ok(trimmed.to_string()),
599 _ => bail!("command name must be a plain filename"),
600 }
601}
602
603pub(crate) fn validate_preset_category(
606 name: &str,
607 root: &Path,
608) -> std::result::Result<bool, PresetValidationFailure> {
609 let manifest_path = root.join("shine.toml");
610 if !manifest_path.is_file() {
611 let sources = collect_local_scripts(root)?;
612 validate_legacy_commands(root, &sources)?;
613 return Ok(false);
614 }
615
616 let bytes = std::fs::read(&manifest_path).map_err(|error| {
617 PresetValidationFailure::at(
618 "read_failed",
619 format!("cannot read shell metadata: {error}"),
620 &manifest_path,
621 )
622 })?;
623 let parsed: CategoryToml = toml::from_slice(&bytes).map_err(|error| {
624 PresetValidationFailure::at(
625 "invalid_metadata",
626 format!("failed to parse shell/{name}/shine.toml: {error}"),
627 &manifest_path,
628 )
629 })?;
630 let context = format!("shell/{name}/shine.toml");
631
632 let mut resolved = Vec::new();
633 match &parsed.files {
634 Some(files) if files.is_empty() => {
635 return Err(PresetValidationFailure::at(
636 "invalid_metadata",
637 format!("{context} files must not be empty"),
638 &manifest_path,
639 ));
640 }
641 Some(files) => {
642 for file in files {
643 let entry = resolve_metadata_file(file, &context)
644 .map_err(|error| invalid_metadata(error, &manifest_path))?;
645 crate::install_core::transforms::validate(&entry.transforms)
646 .with_context(|| format!("invalid transforms in {context}"))
647 .map_err(|error| invalid_metadata(error, &manifest_path))?;
648 validate_reference(root, &entry.source_rel, "shell source")?;
649 for platform in OperatingSystem::ALL {
650 file_matches_platform(name, file, platform)
651 .map_err(|error| invalid_metadata(error, &manifest_path))?;
652 }
653 resolved.push((file.platforms.clone(), entry));
654 }
655 }
656 None => {
657 for source in collect_local_scripts(root)? {
658 let command_name = default_command_name(&source)
659 .map_err(|error| invalid_metadata(error, &manifest_path))?;
660 resolved.push((None, ResolvedFile::native(source, command_name)));
663 }
664 }
665 }
666
667 let uses_bun = resolved
668 .iter()
669 .any(|(_, entry)| entry.runtime == crate::bin_links::LinkRuntime::Bun);
670 for platform in OperatingSystem::ALL {
671 let mut commands = BTreeSet::new();
672 for (platforms, entry) in &resolved {
673 if !crate::preset_meta::platform_matches(platforms.as_deref(), platform, &context)
674 .map_err(|error| invalid_metadata(error, &manifest_path))?
675 {
676 continue;
677 }
678 if !commands.insert(entry.command_name.clone()) {
679 return Err(PresetValidationFailure::at(
680 "duplicate_command",
681 format!(
682 "shell/{name} declares command `{}` more than once for {}",
683 entry.command_name,
684 platform.as_str()
685 ),
686 &manifest_path,
687 ));
688 }
689 }
690 }
691
692 if uses_bun {
693 crate::bun_runtime::resolve(root, true).map_err(|error| {
694 PresetValidationFailure::at("bun_dependency_policy", error.to_string(), root)
695 })?;
696 }
697 Ok(true)
698}
699
700fn invalid_metadata(error: anyhow::Error, path: &Path) -> PresetValidationFailure {
701 PresetValidationFailure::at("invalid_metadata", error.to_string(), path)
702}
703
704fn collect_local_scripts(
705 root: &Path,
706) -> std::result::Result<Vec<PathBuf>, PresetValidationFailure> {
707 let mut scripts = Vec::new();
708 let mut pending = vec![root.to_path_buf()];
709 while let Some(directory) = pending.pop() {
710 let entries = std::fs::read_dir(&directory).map_err(|error| {
711 PresetValidationFailure::at(
712 "read_failed",
713 format!("cannot read shell category: {error}"),
714 &directory,
715 )
716 })?;
717 for entry in entries {
718 let entry = entry.map_err(|error| {
719 PresetValidationFailure::at("read_failed", error.to_string(), &directory)
720 })?;
721 let file_type = entry.file_type().map_err(|error| {
722 PresetValidationFailure::at("read_failed", error.to_string(), entry.path())
723 })?;
724 if file_type.is_dir() {
725 pending.push(entry.path());
726 } else if file_type.is_file() {
727 let relative = entry
728 .path()
729 .strip_prefix(root)
730 .map(Path::to_path_buf)
731 .map_err(|error| {
732 PresetValidationFailure::at("read_failed", error.to_string(), entry.path())
733 })?;
734 if is_shell_script(&relative) {
735 scripts.push(relative);
736 }
737 }
738 }
739 }
740 scripts.sort();
741 if scripts.is_empty() {
742 return Err(PresetValidationFailure::at(
743 "no_files",
744 "shell preset category contains no .sh or .ps1 files",
745 root,
746 ));
747 }
748 Ok(scripts)
749}
750
751fn validate_legacy_commands(
752 root: &Path,
753 sources: &[PathBuf],
754) -> std::result::Result<(), PresetValidationFailure> {
755 let mut commands = BTreeSet::new();
756 for source in sources {
757 let command = default_command_name(source).map_err(|error| {
758 PresetValidationFailure::at("invalid_metadata", error.to_string(), root.join(source))
759 })?;
760 if !commands.insert(command.clone()) {
761 return Err(PresetValidationFailure::at(
762 "duplicate_command",
763 format!("legacy shell category resolves more than one file to `{command}`"),
764 root,
765 ));
766 }
767 }
768 Ok(())
769}
770
771fn validate_reference(
772 root: &Path,
773 relative: &Path,
774 label: &str,
775) -> std::result::Result<(), PresetValidationFailure> {
776 let path = root.join(relative);
777 let canonical = std::fs::canonicalize(&path).map_err(|error| {
778 PresetValidationFailure::at(
779 "missing_reference",
780 format!("{label} is missing or unreadable: {error}"),
781 &path,
782 )
783 })?;
784 if !canonical.starts_with(root) || !canonical.is_file() {
785 return Err(PresetValidationFailure::at(
786 "invalid_reference",
787 format!("{label} must be a file inside the preset category"),
788 path,
789 ));
790 }
791 Ok(())
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797 use tokio::fs;
798
799 async fn make_temp_dir() -> PathBuf {
800 crate::test_support::make_temp_dir("shine-shell-meta").await
801 }
802
803 #[test]
804 fn embedded_proxy_category_uses_renamed_commands() {
805 let categories = load_embedded_categories(Some("proxy")).unwrap();
806 let proxy = categories.iter().find(|cat| cat.name == "proxy").unwrap();
807 let names: Vec<_> = proxy
808 .files
809 .iter()
810 .map(|file| file.command_name.as_str())
811 .collect();
812 assert!(names.contains(&"setproxy"));
813 assert!(names.contains(&"usetproxy"));
814 assert!(!names.contains(&"set_proxy"));
815 }
816
817 #[test]
818 fn embedded_proxy_category_uses_platform_specific_scripts() {
819 let categories = load_embedded_categories(Some("proxy")).unwrap();
820 let proxy = categories.iter().find(|cat| cat.name == "proxy").unwrap();
821 let sources: Vec<_> = proxy
822 .files
823 .iter()
824 .map(|file| file.source_rel.as_path())
825 .collect();
826
827 if cfg!(windows) {
828 assert!(sources.contains(&Path::new("set_proxy.ps1")));
829 assert!(sources.contains(&Path::new("uset_proxy.ps1")));
830 assert!(!sources.contains(&Path::new("set_proxy.sh")));
831 assert!(!sources.contains(&Path::new("uset_proxy.sh")));
832 } else {
833 assert!(sources.contains(&Path::new("set_proxy.sh")));
834 assert!(sources.contains(&Path::new("uset_proxy.sh")));
835 assert!(!sources.contains(&Path::new("set_proxy.ps1")));
836 assert!(!sources.contains(&Path::new("uset_proxy.ps1")));
837 }
838 }
839
840 #[test]
841 fn metadata_platform_filter_accepts_current_platform() {
842 let file = FileToml {
843 source: "set_proxy.ps1".to_string(),
844 target: Some("setproxy".to_string()),
845 description: None,
846 needs_source: Some(true),
847 platforms: Some(vec!["windows".to_string()]),
848 runtime: None,
849 transforms: None,
850 env: None,
851 };
852
853 assert!(file_matches_platform("proxy", &file, OperatingSystem::Windows).unwrap());
854 assert!(!file_matches_platform("proxy", &file, OperatingSystem::Linux).unwrap());
855 }
856
857 #[test]
858 fn metadata_platform_filter_defaults_to_all_platforms() {
859 let file = FileToml {
860 source: "set_proxy.sh".to_string(),
861 target: Some("setproxy".to_string()),
862 description: None,
863 needs_source: Some(true),
864 platforms: None,
865 runtime: None,
866 transforms: None,
867 env: None,
868 };
869
870 assert!(file_matches_platform("proxy", &file, OperatingSystem::Windows).unwrap());
871 assert!(file_matches_platform("proxy", &file, OperatingSystem::Macos).unwrap());
872 assert!(file_matches_platform("proxy", &file, OperatingSystem::Linux).unwrap());
873 }
874
875 #[test]
876 fn metadata_platform_filter_rejects_unknown_platforms() {
877 let file = FileToml {
878 source: "set_proxy.sh".to_string(),
879 target: Some("setproxy".to_string()),
880 description: None,
881 needs_source: Some(true),
882 platforms: Some(vec!["plan9".to_string()]),
883 runtime: None,
884 transforms: None,
885 env: None,
886 };
887
888 let err = file_matches_platform("proxy", &file, OperatingSystem::Linux)
889 .unwrap_err()
890 .to_string();
891 assert!(err.contains("unsupported platform `plan9`"));
892 }
893
894 #[test]
895 fn embedded_agent_category_uses_cross_platform_bun_entry() {
896 let categories = load_embedded_categories(Some("agent")).unwrap();
897 let agent = categories.iter().find(|cat| cat.name == "agent").unwrap();
898
899 assert_eq!(agent.files.len(), 1);
900 assert_eq!(agent.files[0].command_name, "ccenv");
901 assert_eq!(agent.files[0].source_rel, PathBuf::from("cc.ts"));
902 assert!(!agent.files[0].needs_source);
903 assert_eq!(agent.files[0].runtime, crate::bin_links::LinkRuntime::Bun);
904 assert!(agent.files[0].transforms.is_empty());
905 assert!(agent.files[0].env.is_empty());
906 }
907
908 #[test]
909 fn embedded_image_tools_category_exposes_cross_platform_bun_commands() {
910 let categories = load_embedded_categories(Some("image-tools")).unwrap();
911 let category = categories
912 .iter()
913 .find(|category| category.name == "image-tools")
914 .unwrap();
915 let commands: Vec<_> = category
916 .files
917 .iter()
918 .map(|file| {
919 (
920 file.command_name.as_str(),
921 file.runtime,
922 file.env
923 .iter()
924 .map(|spec| spec.source.as_str())
925 .collect::<Vec<_>>(),
926 )
927 })
928 .collect();
929
930 assert_eq!(
931 commands,
932 vec![
933 (
934 "img-compress",
935 crate::bin_links::LinkRuntime::Bun,
936 vec!["IMAGE_QUALITY"]
937 ),
938 (
939 "img-resize",
940 crate::bin_links::LinkRuntime::Bun,
941 vec!["IMAGE_QUALITY", "IMAGE_MAX_WIDTH", "IMAGE_MAX_HEIGHT"],
942 ),
943 (
944 "img-convert",
945 crate::bin_links::LinkRuntime::Bun,
946 vec!["IMAGE_QUALITY"]
947 ),
948 ]
949 );
950 }
951
952 #[test]
953 fn embedded_utils_category_exposes_copyfile_command() {
954 let categories = load_embedded_categories(Some("utils")).unwrap();
955 let utils = categories.iter().find(|cat| cat.name == "utils").unwrap();
956
957 if cfg!(windows) {
958 assert_eq!(utils.files.len(), 2);
959 let env_export = utils
960 .files
961 .iter()
962 .find(|f| f.command_name == "shine-env-export")
963 .expect("shine-env-export should be present");
964 assert!(env_export.needs_source);
965
966 let theme_sync = utils
967 .files
968 .iter()
969 .find(|f| f.command_name == "shine-theme-sync")
970 .expect("shine-theme-sync should be present");
971 assert_eq!(theme_sync.source_rel, PathBuf::from("shine-theme-sync.ps1"));
972 assert!(theme_sync.needs_source);
973 } else {
974 assert_eq!(utils.files.len(), 3);
975 let copyfile = utils
976 .files
977 .iter()
978 .find(|f| f.command_name == "copyfile")
979 .expect("copyfile should be present");
980 assert_eq!(copyfile.source_rel, PathBuf::from("copyfile.sh"));
981 assert!(!copyfile.needs_source);
982 assert!(
983 copyfile.description.contains(
984 &"Copy a file's contents to the local clipboard via OSC52.".to_string()
985 )
986 );
987
988 let env_export = utils
989 .files
990 .iter()
991 .find(|f| f.command_name == "shine-env-export")
992 .expect("shine-env-export should be present");
993 assert_eq!(env_export.source_rel, PathBuf::from("shine-env-export.sh"));
994 assert!(env_export.needs_source);
995
996 let theme_sync = utils
997 .files
998 .iter()
999 .find(|f| f.command_name == "shine-theme-sync")
1000 .expect("shine-theme-sync should be present");
1001 assert_eq!(theme_sync.source_rel, PathBuf::from("shine-theme-sync.sh"));
1002 assert!(theme_sync.needs_source);
1003 }
1004 }
1005
1006 #[tokio::test]
1007 async fn installed_metadata_applies_target_names() {
1008 let dir = make_temp_dir().await;
1009 let category_root = dir.join("presets/shell/custom");
1010 fs::create_dir_all(&category_root).await.unwrap();
1011 fs::write(
1012 category_root.join("shine.toml"),
1013 b"[[files]]\nsource = \"set_proxy.sh\"\ntarget = \"setproxy\"\n",
1014 )
1015 .await
1016 .unwrap();
1017 fs::write(
1018 category_root.join("set_proxy.sh"),
1019 b"#!/bin/bash\n# Set proxy.\n",
1020 )
1021 .await
1022 .unwrap();
1023
1024 let mut config = Config::new_for_test(&dir);
1025 config.is_external_presets = true;
1026 let categories = load_installed_categories(&config, Some("custom"))
1027 .await
1028 .unwrap();
1029 assert_eq!(categories.len(), 1);
1030 assert_eq!(categories[0].files[0].command_name, "setproxy");
1031
1032 fs::remove_dir_all(&dir).await.unwrap();
1033 }
1034
1035 #[tokio::test]
1036 async fn external_presets_and_overlay_categories_are_merged() {
1037 let dir = make_temp_dir().await;
1038 let base_root = dir.join("presets/shell/custom");
1039 let overlay = dir.join("overlay");
1040 let overlay_root = overlay.join("shell/custom");
1041 let overlay_only = overlay.join("shell/personal");
1042 fs::create_dir_all(&base_root).await.unwrap();
1043 fs::create_dir_all(&overlay_root).await.unwrap();
1044 fs::create_dir_all(&overlay_only).await.unwrap();
1045 fs::write(
1046 base_root.join("shine.toml"),
1047 b"[[files]]\nsource = \"tool.sh\"\n",
1048 )
1049 .await
1050 .unwrap();
1051 fs::write(base_root.join("tool.sh"), b"#!/bin/bash\n# Base tool.\n")
1052 .await
1053 .unwrap();
1054 fs::write(
1055 overlay_root.join("tool.sh"),
1056 b"#!/bin/bash\n# Overlay tool.\n",
1057 )
1058 .await
1059 .unwrap();
1060 fs::write(
1061 overlay_only.join("personal.sh"),
1062 b"#!/bin/bash\n# Personal tool.\n",
1063 )
1064 .await
1065 .unwrap();
1066
1067 let mut config = Config::new_for_test(&dir);
1068 config.is_external_presets = true;
1069 config.presets_overlay_dir_override = Some(overlay);
1070 let categories = load_installed_categories(&config, None).await.unwrap();
1071
1072 let custom = categories.iter().find(|cat| cat.name == "custom").unwrap();
1073 assert_eq!(custom.files[0].description, vec!["Overlay tool."]);
1074 assert!(categories.iter().any(|cat| cat.name == "personal"));
1075
1076 fs::remove_dir_all(&dir).await.unwrap();
1077 }
1078
1079 #[tokio::test]
1080 async fn installed_category_accepts_powershell_scripts() {
1081 let dir = make_temp_dir().await;
1082 let category_root = dir.join("presets/shell/custom");
1083 fs::create_dir_all(&category_root).await.unwrap();
1084 fs::write(
1085 category_root.join("tool.ps1"),
1086 b"# Tool.\nWrite-Output hi\n",
1087 )
1088 .await
1089 .unwrap();
1090
1091 let mut config = Config::new_for_test(&dir);
1092 config.is_external_presets = true;
1093 let categories = load_installed_categories(&config, Some("custom"))
1094 .await
1095 .unwrap();
1096
1097 assert_eq!(categories.len(), 1);
1098 assert_eq!(categories[0].files[0].source_rel, PathBuf::from("tool.ps1"));
1099 assert_eq!(categories[0].files[0].command_name, "tool");
1100
1101 fs::remove_dir_all(&dir).await.unwrap();
1102 }
1103
1104 #[tokio::test]
1105 async fn installed_metadata_filters_platform_specific_files() {
1106 let dir = make_temp_dir().await;
1107 let category_root = dir.join("presets/shell/custom");
1108 fs::create_dir_all(&category_root).await.unwrap();
1109 fs::write(
1110 category_root.join("shine.toml"),
1111 b"[[files]]\nsource = \"tool.sh\"\ntarget = \"tool\"\nplatforms = [\"unix\"]\n\n[[files]]\nsource = \"tool.ps1\"\ntarget = \"tool\"\nplatforms = [\"windows\"]\n",
1112 )
1113 .await
1114 .unwrap();
1115 fs::write(category_root.join("tool.sh"), b"#!/bin/bash\n")
1116 .await
1117 .unwrap();
1118 fs::write(category_root.join("tool.ps1"), b"Write-Output hi\n")
1119 .await
1120 .unwrap();
1121
1122 let mut config = Config::new_for_test(&dir);
1123 config.is_external_presets = true;
1124 let categories = load_installed_categories(&config, Some("custom"))
1125 .await
1126 .unwrap();
1127
1128 assert_eq!(categories.len(), 1);
1129 assert_eq!(categories[0].files.len(), 1);
1130 let expected = if cfg!(windows) { "tool.ps1" } else { "tool.sh" };
1131 assert_eq!(categories[0].files[0].source_rel, PathBuf::from(expected));
1132 assert_eq!(categories[0].files[0].command_name, "tool");
1133
1134 fs::remove_dir_all(&dir).await.unwrap();
1135 }
1136
1137 #[test]
1138 fn rejects_invalid_command_names() {
1139 let err = validate_command_name("bin/setproxy")
1140 .unwrap_err()
1141 .to_string();
1142 assert!(err.contains("plain filename"));
1143 }
1144
1145 #[test]
1146 fn parse_runtime_accepts_native_and_bun_rejects_others() {
1147 use crate::bin_links::LinkRuntime;
1148 assert_eq!(parse_runtime(None).unwrap(), LinkRuntime::Native);
1149 assert_eq!(parse_runtime(Some("native")).unwrap(), LinkRuntime::Native);
1150 assert_eq!(parse_runtime(Some("bun")).unwrap(), LinkRuntime::Bun);
1151 let err = parse_runtime(Some("deno")).unwrap_err().to_string();
1152 assert!(err.contains("unsupported runtime"));
1153 }
1154
1155 #[test]
1156 fn normalize_source_enforces_extension_per_runtime() {
1157 use crate::bin_links::LinkRuntime;
1158 for ext in ["ts", "js", "mts", "mjs"] {
1159 assert!(
1160 normalize_source(format!("tool.{ext}"), LinkRuntime::Bun).is_ok(),
1161 ".{ext} should be a valid bun source"
1162 );
1163 }
1164 assert!(normalize_source("tool.sh", LinkRuntime::Bun).is_err());
1165 assert!(normalize_source("tool.ts", LinkRuntime::Native).is_err());
1166 assert!(normalize_source("tool.sh", LinkRuntime::Native).is_ok());
1167 assert!(normalize_source("../evil.ts", LinkRuntime::Bun).is_err());
1169 }
1170
1171 async fn write_bun_category(dir: &Path, shine_toml: &[u8]) -> Config {
1172 let category_root = dir.join("presets/shell/custom");
1173 fs::create_dir_all(&category_root).await.unwrap();
1174 fs::write(category_root.join("shine.toml"), shine_toml)
1175 .await
1176 .unwrap();
1177 fs::write(category_root.join("tool.ts"), b"// tool\n")
1178 .await
1179 .unwrap();
1180 let mut config = Config::new_for_test(dir);
1181 config.is_external_presets = true;
1182 config
1183 }
1184
1185 #[tokio::test]
1186 async fn installed_metadata_accepts_bun_runtime_with_transforms() {
1187 let dir = make_temp_dir().await;
1188 let config = write_bun_category(
1189 &dir,
1190 b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ntransforms = [\"template\"]\n",
1191 )
1192 .await;
1193
1194 let categories = load_installed_categories(&config, Some("custom"))
1195 .await
1196 .unwrap();
1197 let file = &categories[0].files[0];
1198 assert_eq!(file.command_name, "mytool");
1199 assert_eq!(file.source_rel, PathBuf::from("tool.ts"));
1200 assert_eq!(file.runtime, crate::bin_links::LinkRuntime::Bun);
1201 assert_eq!(file.transforms, vec!["template".to_string()]);
1202 assert!(!file.needs_source);
1203
1204 fs::remove_dir_all(&dir).await.unwrap();
1205 }
1206
1207 #[tokio::test]
1208 async fn installed_metadata_defaults_bun_command_name_to_stem() {
1209 let dir = make_temp_dir().await;
1210 let config = write_bun_category(
1211 &dir,
1212 b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\n",
1213 )
1214 .await;
1215
1216 let categories = load_installed_categories(&config, Some("custom"))
1217 .await
1218 .unwrap();
1219 assert_eq!(categories[0].files[0].command_name, "tool");
1220
1221 fs::remove_dir_all(&dir).await.unwrap();
1222 }
1223
1224 #[tokio::test]
1225 async fn installed_metadata_rejects_bun_with_needs_source() {
1226 let dir = make_temp_dir().await;
1227 let config = write_bun_category(
1228 &dir,
1229 b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\nneeds_source = true\n",
1230 )
1231 .await;
1232
1233 let err = load_installed_categories(&config, Some("custom"))
1234 .await
1235 .unwrap_err()
1236 .to_string();
1237 assert!(err.contains("needs_source"), "unexpected error: {err}");
1238
1239 fs::remove_dir_all(&dir).await.unwrap();
1240 }
1241
1242 #[tokio::test]
1243 async fn installed_metadata_rejects_unknown_runtime() {
1244 let dir = make_temp_dir().await;
1245 let config = write_bun_category(
1246 &dir,
1247 b"[[files]]\nsource = \"tool.ts\"\nruntime = \"deno\"\n",
1248 )
1249 .await;
1250
1251 let err = format!(
1252 "{:#}",
1253 load_installed_categories(&config, Some("custom"))
1254 .await
1255 .unwrap_err()
1256 );
1257 assert!(
1258 err.contains("unsupported runtime"),
1259 "unexpected error: {err}"
1260 );
1261
1262 fs::remove_dir_all(&dir).await.unwrap();
1263 }
1264
1265 #[tokio::test]
1266 async fn installed_metadata_parses_bun_env_declarations_in_order() {
1267 let dir = make_temp_dir().await;
1268 let config = write_bun_category(
1269 &dir,
1270 b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\nenv = [\"API_URL\", \"SERVICE_TOKEN=API_TOKEN\"]\n",
1271 )
1272 .await;
1273
1274 let categories = load_installed_categories(&config, Some("custom"))
1275 .await
1276 .unwrap();
1277 let env = &categories[0].files[0].env;
1278 assert_eq!(env.len(), 2);
1279 assert_eq!(env[0].to_with_arg(), "API_URL");
1280 assert_eq!(env[1].to_with_arg(), "SERVICE_TOKEN=API_TOKEN");
1281
1282 fs::remove_dir_all(&dir).await.unwrap();
1283 }
1284
1285 #[tokio::test]
1286 async fn installed_metadata_rejects_env_on_native_entry() {
1287 let dir = make_temp_dir().await;
1288 let category_root = dir.join("presets/shell/custom");
1289 fs::create_dir_all(&category_root).await.unwrap();
1290 fs::write(
1291 category_root.join("shine.toml"),
1292 b"[[files]]\nsource = \"tool.sh\"\nenv = [\"API_URL\"]\n",
1293 )
1294 .await
1295 .unwrap();
1296 fs::write(category_root.join("tool.sh"), b"#!/bin/bash\n")
1297 .await
1298 .unwrap();
1299 let mut config = Config::new_for_test(&dir);
1300 config.is_external_presets = true;
1301
1302 let err = format!(
1303 "{:#}",
1304 load_installed_categories(&config, Some("custom"))
1305 .await
1306 .unwrap_err()
1307 );
1308 assert!(
1309 err.contains("`env` is only valid when `runtime = \"bun\"`"),
1310 "unexpected error: {err}"
1311 );
1312
1313 fs::remove_dir_all(&dir).await.unwrap();
1314 }
1315
1316 #[tokio::test]
1317 async fn installed_metadata_rejects_bun_env_invalid_name() {
1318 let dir = make_temp_dir().await;
1319 let config = write_bun_category(
1320 &dir,
1321 b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\nenv = [\"BAD-NAME\"]\n",
1322 )
1323 .await;
1324
1325 let err = format!(
1326 "{:#}",
1327 load_installed_categories(&config, Some("custom"))
1328 .await
1329 .unwrap_err()
1330 );
1331 assert!(
1332 err.contains("invalid environment variable name"),
1333 "unexpected error: {err}"
1334 );
1335
1336 fs::remove_dir_all(&dir).await.unwrap();
1337 }
1338
1339 #[tokio::test]
1340 async fn installed_metadata_rejects_bun_env_duplicate_target() {
1341 let dir = make_temp_dir().await;
1342 let config = write_bun_category(
1343 &dir,
1344 b"[[files]]\nsource = \"tool.ts\"\nruntime = \"bun\"\nenv = [\"A=TOKEN\", \"B=TOKEN\"]\n",
1345 )
1346 .await;
1347
1348 let err = format!(
1349 "{:#}",
1350 load_installed_categories(&config, Some("custom"))
1351 .await
1352 .unwrap_err()
1353 );
1354 assert!(
1355 err.contains("duplicate target variable"),
1356 "unexpected error: {err}"
1357 );
1358
1359 fs::remove_dir_all(&dir).await.unwrap();
1360 }
1361
1362 #[tokio::test]
1363 async fn installed_metadata_bun_description_from_slash_header() {
1364 let dir = make_temp_dir().await;
1365 let category_root = dir.join("presets/shell/custom");
1366 fs::create_dir_all(&category_root).await.unwrap();
1367 fs::write(
1368 category_root.join("shine.toml"),
1369 b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\n",
1370 )
1371 .await
1372 .unwrap();
1373 fs::write(
1374 category_root.join("tool.ts"),
1375 b"// Fetch and print status.\n// Reads Bun.env.API_URL.\nconsole.log('hi')\n",
1376 )
1377 .await
1378 .unwrap();
1379 let mut config = Config::new_for_test(&dir);
1380 config.is_external_presets = true;
1381
1382 let categories = load_installed_categories(&config, Some("custom"))
1383 .await
1384 .unwrap();
1385 assert_eq!(
1386 categories[0].files[0].description,
1387 vec!["Fetch and print status.", "Reads Bun.env.API_URL."]
1388 );
1389
1390 fs::remove_dir_all(&dir).await.unwrap();
1391 }
1392
1393 #[tokio::test]
1394 async fn installed_metadata_file_description_overrides_bun_header() {
1395 let dir = make_temp_dir().await;
1396 let category_root = dir.join("presets/shell/custom");
1397 fs::create_dir_all(&category_root).await.unwrap();
1398 fs::write(
1399 category_root.join("shine.toml"),
1400 b"[[files]]\nsource = \"tool.ts\"\ntarget = \"mytool\"\nruntime = \"bun\"\ndescription = \"Explicit metadata description.\"\n",
1401 )
1402 .await
1403 .unwrap();
1404 fs::write(
1405 category_root.join("tool.ts"),
1406 b"// header that should be overridden\nconsole.log('hi')\n",
1407 )
1408 .await
1409 .unwrap();
1410 let mut config = Config::new_for_test(&dir);
1411 config.is_external_presets = true;
1412
1413 let categories = load_installed_categories(&config, Some("custom"))
1414 .await
1415 .unwrap();
1416 assert_eq!(
1417 categories[0].files[0].description,
1418 vec!["Explicit metadata description."]
1419 );
1420
1421 fs::remove_dir_all(&dir).await.unwrap();
1422 }
1423
1424 #[tokio::test]
1425 async fn installed_metadata_file_description_overrides_native_hash_header() {
1426 let dir = make_temp_dir().await;
1427 let category_root = dir.join("presets/shell/custom");
1428 fs::create_dir_all(&category_root).await.unwrap();
1429 fs::write(
1430 category_root.join("shine.toml"),
1431 b"[[files]]\nsource = \"tool.sh\"\ntarget = \"mytool\"\ndescription = \"From metadata.\"\n",
1432 )
1433 .await
1434 .unwrap();
1435 fs::write(
1436 category_root.join("tool.sh"),
1437 b"#!/bin/bash\n# hash header that should be overridden\necho hi\n",
1438 )
1439 .await
1440 .unwrap();
1441 let mut config = Config::new_for_test(&dir);
1442 config.is_external_presets = true;
1443
1444 let categories = load_installed_categories(&config, Some("custom"))
1445 .await
1446 .unwrap();
1447 assert_eq!(categories[0].files[0].description, vec!["From metadata."]);
1448
1449 fs::remove_dir_all(&dir).await.unwrap();
1450 }
1451
1452 #[tokio::test]
1453 async fn installed_metadata_rejects_bun_source_with_shell_extension() {
1454 let dir = make_temp_dir().await;
1455 let category_root = dir.join("presets/shell/custom");
1456 fs::create_dir_all(&category_root).await.unwrap();
1457 fs::write(
1458 category_root.join("shine.toml"),
1459 b"[[files]]\nsource = \"tool.sh\"\nruntime = \"bun\"\n",
1460 )
1461 .await
1462 .unwrap();
1463 fs::write(category_root.join("tool.sh"), b"#!/bin/bash\n")
1464 .await
1465 .unwrap();
1466 let mut config = Config::new_for_test(&dir);
1467 config.is_external_presets = true;
1468
1469 let err = format!(
1470 "{:#}",
1471 load_installed_categories(&config, Some("custom"))
1472 .await
1473 .unwrap_err()
1474 );
1475 assert!(err.contains("bun source path"), "unexpected error: {err}");
1476
1477 fs::remove_dir_all(&dir).await.unwrap();
1478 }
1479}