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