1use anyhow::{Context, Result, bail};
2#[cfg(test)]
3use std::collections::BTreeMap;
4use std::collections::BTreeSet;
5use std::path::{Path, PathBuf};
6use std::sync::{Mutex, OnceLock};
7use tokio::fs;
8
9#[derive(rust_embed::RustEmbed)]
10#[folder = "$CARGO_MANIFEST_DIR/presets"]
11struct PresetAssets;
12
13fn overlay_dir_cell() -> &'static Mutex<Option<PathBuf>> {
14 static OVERLAY_DIR: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
15 OVERLAY_DIR.get_or_init(|| Mutex::new(None))
16}
17
18pub fn set_overlay_dir(dir: Option<&Path>) {
19 let mut guard = overlay_dir_cell()
20 .lock()
21 .unwrap_or_else(|poisoned| poisoned.into_inner());
22 *guard = dir.map(Path::to_path_buf);
23}
24
25fn overlay_dir() -> Option<PathBuf> {
26 overlay_dir_cell()
27 .lock()
28 .unwrap_or_else(|poisoned| poisoned.into_inner())
29 .clone()
30}
31
32pub struct ExtractReport {
33 pub created: Vec<PathBuf>,
34 pub skipped: Vec<PathBuf>,
35 pub overwritten: Vec<PathBuf>,
36}
37
38pub struct RemoveReport {
39 pub removed: Vec<PathBuf>,
40 pub skipped: Vec<PathBuf>,
41}
42
43#[cfg(test)]
44pub struct ScriptInfo {
45 pub name: String,
46 pub description: Vec<String>,
47}
48
49#[cfg(test)]
50pub struct CategoryInfo {
51 pub name: String,
52 pub scripts: Vec<ScriptInfo>,
53}
54
55pub fn asset_paths(prefix: &str) -> Vec<String> {
56 let normalized = prefix.trim_end_matches('/');
57 let mut paths: BTreeSet<_> = embedded_asset_paths(normalized).into_iter().collect();
58 if let Some(dir) = overlay_dir() {
59 collect_overlay_paths(&dir, normalized, &mut paths);
60 }
61 paths.into_iter().collect()
62}
63
64pub fn embedded_asset_paths(prefix: &str) -> Vec<String> {
69 let normalized = prefix.trim_end_matches('/');
70 let filter = if normalized.is_empty() {
71 String::new()
72 } else {
73 format!("{normalized}/")
74 };
75 let mut paths = BTreeSet::new();
76 for asset_path in PresetAssets::iter() {
77 let relative: &str = asset_path.as_ref();
78 if filter.is_empty() || relative.starts_with(filter.as_str()) {
79 paths.insert(relative.to_string());
80 }
81 }
82 paths.into_iter().collect()
83}
84
85pub fn read_asset_bytes(path: &str) -> Option<Vec<u8>> {
86 if !is_safe_asset_path(path) {
87 return None;
88 }
89 if let Some(dir) = overlay_dir() {
90 let overlay_path = dir.join(path);
91 if overlay_path.is_file()
92 && let Ok(bytes) = std::fs::read(&overlay_path)
93 {
94 return Some(bytes);
95 }
96 }
97 read_embedded_asset_bytes(path)
98}
99
100pub fn read_embedded_asset_bytes(path: &str) -> Option<Vec<u8>> {
102 if !is_safe_asset_path(path) {
103 return None;
104 }
105 PresetAssets::get(path).map(|file| file.data.as_ref().to_vec())
106}
107
108fn collect_overlay_paths(root: &Path, prefix: &str, out: &mut BTreeSet<String>) {
109 let prefix_path = root.join(prefix);
110 if !prefix_path.is_dir() {
111 return;
112 }
113
114 let mut stack = vec![prefix_path];
115 while let Some(dir) = stack.pop() {
116 let Ok(entries) = std::fs::read_dir(&dir) else {
117 continue;
118 };
119 for entry in entries.flatten() {
120 let path = entry.path();
121 let Ok(file_type) = entry.file_type() else {
122 continue;
123 };
124 if file_type.is_dir() {
125 if entry.file_name() == "node_modules" {
126 continue;
127 }
128 stack.push(path);
129 continue;
130 }
131 if !file_type.is_file() {
132 continue;
133 }
134 let Ok(rel) = path.strip_prefix(root) else {
135 continue;
136 };
137 let Some(rel) = rel.to_str() else {
138 continue;
139 };
140 let rel = rel.replace('\\', "/");
141 if is_safe_asset_path(&rel) {
142 out.insert(rel);
143 }
144 }
145 }
146}
147
148fn is_safe_asset_path(path: &str) -> bool {
149 !path.contains("..") && !Path::new(path).is_absolute()
150}
151
152pub fn extract_annotation_from_line(line: &str) -> Option<String> {
156 const PREFIXES: &[&str] = &["# shine-dest:", "\" shine-dest:"];
157 for &prefix in PREFIXES {
158 if let Some(rest) = line.trim_start().strip_prefix(prefix) {
159 let dest = rest.trim().to_string();
160 if !dest.is_empty() {
161 return Some(dest);
162 }
163 }
164 }
165 None
166}
167
168pub fn parse_dest_annotation(content: &[u8]) -> Option<String> {
170 let text = std::str::from_utf8(content).ok()?;
171 let mut lines = text.lines();
172 let first = lines.next()?;
173 let candidate = if first.starts_with("#!") {
174 lines.next()?
175 } else {
176 first
177 };
178 extract_annotation_from_line(candidate)
179}
180
181pub fn parse_template_annotation(content: &[u8]) -> bool {
185 let text = match std::str::from_utf8(content) {
186 Ok(t) => t,
187 Err(_) => return false,
188 };
189 for line in text.lines() {
190 if line.starts_with("#!") {
191 continue;
192 }
193 let trimmed = line.trim_start();
194 if trimmed == "# shine-template: true" {
195 return true;
196 }
197 if !trimmed.starts_with('#') && !trimmed.is_empty() {
199 break;
200 }
201 }
202 false
203}
204
205pub fn parse_script_description(content: &[u8]) -> Vec<String> {
211 let Ok(text) = std::str::from_utf8(content) else {
212 return vec![];
213 };
214 let mut desc = Vec::new();
215
216 for line in text.lines() {
217 if line.starts_with("#!") {
218 continue;
219 }
220 if extract_annotation_from_line(line).is_some() {
221 continue;
222 }
223 if line.trim_start() == "# shine-template: true" {
224 continue;
225 }
226 if let Some(rest) = line.strip_prefix("# ") {
227 desc.push(rest.to_string());
228 } else if line == "#" {
229 desc.push(String::new());
230 } else {
231 break;
232 }
233 }
234
235 while desc.last().is_some_and(|l: &String| l.is_empty()) {
236 desc.pop();
237 }
238
239 desc
240}
241
242pub fn parse_bun_description(content: &[u8]) -> Vec<String> {
249 let Ok(text) = std::str::from_utf8(content) else {
250 return vec![];
251 };
252 let mut desc = Vec::new();
253
254 for line in text.lines() {
255 if line.starts_with("#!") {
256 continue;
257 }
258 if let Some(rest) = line.strip_prefix("// ") {
259 desc.push(rest.to_string());
260 } else if line.trim_end() == "//" {
261 desc.push(String::new());
262 } else {
263 break;
264 }
265 }
266
267 while desc.last().is_some_and(|l: &String| l.is_empty()) {
268 desc.pop();
269 }
270
271 desc
272}
273
274#[cfg(test)]
279pub fn list_categories(prefix: &str) -> Vec<CategoryInfo> {
280 let normalized = prefix.trim_end_matches('/');
281 let filter = format!("{normalized}/");
282
283 let mut map: BTreeMap<String, Vec<ScriptInfo>> = BTreeMap::new();
284
285 for asset_path in PresetAssets::iter() {
286 let relative: &str = asset_path.as_ref();
287 if !relative.starts_with(filter.as_str()) {
288 continue;
289 }
290 let rest = &relative[filter.len()..];
291 let slash = match rest.find('/') {
292 Some(p) => p,
293 None => continue,
294 };
295 let category = &rest[..slash];
296 let file_name = &rest[slash + 1..];
297
298 if file_name.is_empty() || !file_name.ends_with(".sh") {
299 continue;
300 }
301
302 let asset_data = PresetAssets::get(relative);
303 let description = asset_data
304 .as_ref()
305 .map(|f| parse_script_description(f.data.as_ref()))
306 .unwrap_or_default();
307 map.entry(category.to_string())
308 .or_default()
309 .push(ScriptInfo {
310 name: file_name.to_string(),
311 description,
312 });
313 }
314
315 map.into_iter()
316 .map(|(name, mut scripts)| {
317 scripts.sort_by(|a, b| a.name.cmp(&b.name));
318 CategoryInfo { name, scripts }
319 })
320 .collect()
321}
322
323#[cfg(test)]
329pub async fn list_fs_shell_categories(presets_dir: &Path) -> Vec<CategoryInfo> {
330 let shell_root = presets_dir.join("shell");
331 if !shell_root.is_dir() {
332 return Vec::new();
333 }
334
335 let mut categories: std::collections::BTreeMap<String, Vec<ScriptInfo>> =
336 std::collections::BTreeMap::new();
337
338 let Ok(mut cat_entries) = fs::read_dir(&shell_root).await else {
339 return Vec::new();
340 };
341
342 while let Ok(Some(cat_entry)) = cat_entries.next_entry().await {
343 let Ok(ft) = cat_entry.file_type().await else {
344 continue;
345 };
346 if !ft.is_dir() {
347 continue;
348 }
349 let category = cat_entry.file_name().to_string_lossy().to_string();
350 let cat_dir = shell_root.join(&category);
351
352 let Ok(mut script_entries) = fs::read_dir(&cat_dir).await else {
353 continue;
354 };
355 let mut scripts: Vec<ScriptInfo> = Vec::new();
356 while let Ok(Some(script_entry)) = script_entries.next_entry().await {
357 let Ok(sft) = script_entry.file_type().await else {
358 continue;
359 };
360 if !sft.is_file() {
361 continue;
362 }
363 let name = script_entry.file_name().to_string_lossy().to_string();
364 if !name.ends_with(".sh") {
365 continue;
366 }
367 let description = fs::read(script_entry.path())
368 .await
369 .map(|b| parse_script_description(&b))
370 .unwrap_or_default();
371 scripts.push(ScriptInfo { name, description });
372 }
373 scripts.sort_by(|a, b| a.name.cmp(&b.name));
374 if !scripts.is_empty() {
375 categories.insert(category, scripts);
376 }
377 }
378
379 categories
380 .into_iter()
381 .map(|(name, scripts)| CategoryInfo { name, scripts })
382 .collect()
383}
384
385#[cfg(test)]
390pub async fn collect_fs_shell_scripts(presets_dir: &Path, prefix: &str) -> Result<Vec<PathBuf>> {
391 let root = presets_dir.join(prefix);
392 if !root.is_dir() {
393 return Ok(Vec::new());
394 }
395
396 let mut scripts = Vec::new();
397 let mut stack = vec![root.clone()];
398
399 while let Some(dir) = stack.pop() {
400 let mut entries = fs::read_dir(&dir)
401 .await
402 .with_context(|| format!("reading directory: {}", dir.display()))?;
403 while let Some(entry) = entries.next_entry().await? {
404 let path = entry.path();
405 let ft = entry.file_type().await?;
406 if ft.is_dir() {
407 stack.push(path);
408 } else if ft.is_file() && path.extension().is_some_and(|e| e == "sh") {
409 scripts.push(path);
410 }
411 }
412 }
413
414 scripts.sort();
415 Ok(scripts)
416}
417
418pub async fn remove_prefix(prefix: &str, target_dir: &Path, dry_run: bool) -> Result<RemoveReport> {
425 let normalized = prefix.trim_end_matches('/');
426
427 let mut report = RemoveReport {
428 removed: Vec::new(),
429 skipped: Vec::new(),
430 };
431
432 let mut dirs_to_check: std::collections::BTreeSet<PathBuf> = Default::default();
433
434 for relative in asset_paths(normalized) {
435 let dest = target_dir.join(relative);
436 if dest.exists() {
437 if let Some(parent) = dest.parent() {
438 dirs_to_check.insert(parent.to_path_buf());
439 }
440 if !dry_run {
441 fs::remove_file(&dest)
442 .await
443 .with_context(|| format!("removing preset file: {dest:?}"))?;
444 }
445 report.removed.push(dest);
446 } else {
447 report.skipped.push(dest);
448 }
449 }
450
451 if !dry_run {
452 let prefix_root = target_dir.join(normalized);
455 for dir in dirs_to_check.into_iter().rev() {
456 if dir.starts_with(&prefix_root) && dir != prefix_root {
457 let _ = fs::remove_dir(&dir).await; }
459 }
460 let _ = fs::remove_dir(&prefix_root).await;
461 }
462
463 Ok(report)
464}
465
466pub async fn extract_prefix(
468 prefix: &str,
469 target_dir: &Path,
470 overwrite: bool,
471) -> Result<ExtractReport> {
472 let normalized = prefix.trim_end_matches('/');
473 let filter = format!("{normalized}/");
474 extract_matching(
475 asset_paths(""),
476 |p| p.starts_with(filter.as_str()),
477 read_asset_bytes,
478 target_dir,
479 overwrite,
480 )
481 .await
482}
483
484pub async fn extract_embedded_prefix(
486 prefix: &str,
487 target_dir: &Path,
488 overwrite: bool,
489) -> Result<ExtractReport> {
490 let normalized = prefix.trim_end_matches('/');
491 let filter = format!("{normalized}/");
492 extract_matching(
493 embedded_asset_paths(""),
494 |p| p.starts_with(filter.as_str()),
495 read_embedded_asset_bytes,
496 target_dir,
497 overwrite,
498 )
499 .await
500}
501
502pub async fn extract_all(target_dir: &Path, overwrite: bool) -> Result<ExtractReport> {
504 extract_matching(
505 asset_paths(""),
506 |_| true,
507 read_asset_bytes,
508 target_dir,
509 overwrite,
510 )
511 .await
512}
513
514async fn extract_matching(
515 paths: impl IntoIterator<Item = String>,
516 predicate: impl Fn(&str) -> bool,
517 read: impl Fn(&str) -> Option<Vec<u8>>,
518 target_dir: &Path,
519 overwrite: bool,
520) -> Result<ExtractReport> {
521 let mut report = ExtractReport {
522 created: Vec::new(),
523 skipped: Vec::new(),
524 overwritten: Vec::new(),
525 };
526
527 for relative in paths {
528 let relative = relative.as_str();
529
530 if !is_safe_asset_path(relative) {
531 bail!("Unsafe asset path rejected: {relative}");
532 }
533
534 if !predicate(relative) {
535 continue;
536 }
537
538 let dest = target_dir.join(relative);
539
540 if let Some(parent) = dest.parent() {
541 fs::create_dir_all(parent)
542 .await
543 .with_context(|| format!("creating directory: {parent:?}"))?;
544 }
545
546 if dest.exists() && !overwrite {
547 report.skipped.push(dest);
548 continue;
549 }
550
551 let file = read(relative).with_context(|| format!("preset asset missing: {relative}"))?;
552
553 let existed = dest.exists();
554
555 fs::write(&dest, &file)
556 .await
557 .with_context(|| format!("writing preset: {dest:?}"))?;
558
559 #[cfg(unix)]
560 if relative.ends_with(".sh") {
561 use std::os::unix::fs::PermissionsExt;
562 let mut perms = fs::metadata(&dest)
563 .await
564 .with_context(|| format!("reading metadata: {dest:?}"))?
565 .permissions();
566 perms.set_mode(perms.mode() | 0o111);
567 fs::set_permissions(&dest, perms)
568 .await
569 .with_context(|| format!("setting permissions: {dest:?}"))?;
570 }
571
572 if existed {
573 report.overwritten.push(dest);
574 } else {
575 report.created.push(dest);
576 }
577 }
578
579 Ok(report)
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use std::sync::OnceLock;
586 use tokio::fs;
587
588 fn overlay_lock_mutex() -> &'static tokio::sync::Mutex<()> {
589 static OVERLAY_LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
590 OVERLAY_LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
591 }
592
593 async fn overlay_lock() -> tokio::sync::MutexGuard<'static, ()> {
595 overlay_lock_mutex().lock().await
596 }
597
598 fn overlay_lock_sync() -> tokio::sync::MutexGuard<'static, ()> {
600 overlay_lock_mutex().blocking_lock()
601 }
602
603 async fn make_temp_dir() -> PathBuf {
604 crate::test_support::make_temp_dir("shine-presets").await
605 }
606
607 #[test]
608 fn embedded_assets_not_empty() {
609 assert!(PresetAssets::iter().count() > 0, "no assets embedded");
610 }
611
612 #[tokio::test]
613 async fn overlay_asset_paths_include_new_files() {
614 let dir = make_temp_dir().await;
615 fs::create_dir_all(dir.join("shell/personal"))
616 .await
617 .unwrap();
618 fs::write(dir.join("shell/personal/hello.sh"), b"#!/bin/bash\n")
619 .await
620 .unwrap();
621
622 let guard = overlay_lock().await;
623 set_overlay_dir(Some(&dir));
624 let paths = asset_paths("shell");
625 set_overlay_dir(None);
626 drop(guard);
627
628 assert!(paths.contains(&"shell/personal/hello.sh".to_string()));
629 fs::remove_dir_all(&dir).await.unwrap();
630 }
631
632 #[tokio::test]
633 async fn overlay_asset_paths_skip_node_modules() {
634 let dir = make_temp_dir().await;
635 fs::create_dir_all(dir.join("shell/personal/node_modules/zod"))
636 .await
637 .unwrap();
638 fs::write(dir.join("shell/personal/package.json"), b"{}")
639 .await
640 .unwrap();
641 fs::write(
642 dir.join("shell/personal/bun.lock"),
643 b"lockfileVersion = 1\n",
644 )
645 .await
646 .unwrap();
647 fs::write(
648 dir.join("shell/personal/node_modules/zod/index.js"),
649 b"export {}",
650 )
651 .await
652 .unwrap();
653
654 let guard = overlay_lock().await;
655 set_overlay_dir(Some(&dir));
656 let paths = asset_paths("shell/personal");
657 set_overlay_dir(None);
658 drop(guard);
659
660 assert!(paths.contains(&"shell/personal/package.json".to_string()));
661 assert!(paths.contains(&"shell/personal/bun.lock".to_string()));
662 assert!(paths.iter().all(|path| !path.contains("node_modules")));
663 fs::remove_dir_all(&dir).await.unwrap();
664 }
665
666 #[tokio::test]
667 async fn overlay_read_asset_bytes_overrides_embedded_file() {
668 let dir = make_temp_dir().await;
669 fs::create_dir_all(dir.join("shell/proxy")).await.unwrap();
670 fs::write(dir.join("shell/proxy/set_proxy.sh"), b"overlay\n")
671 .await
672 .unwrap();
673
674 let guard = overlay_lock().await;
675 set_overlay_dir(Some(&dir));
676 let bytes = read_asset_bytes("shell/proxy/set_proxy.sh").unwrap();
677 set_overlay_dir(None);
678 drop(guard);
679
680 assert_eq!(bytes, b"overlay\n");
681 fs::remove_dir_all(&dir).await.unwrap();
682 }
683
684 #[test]
685 fn parse_description_extracts_comment_block() {
686 let script = b"#!/bin/bash\n# First line.\n# Second line.\n\nsome_command\n";
687 let desc = parse_script_description(script);
688 assert_eq!(desc, vec!["First line.", "Second line."]);
689 }
690
691 #[test]
692 fn parse_description_skips_shebang_only() {
693 let script = b"#!/bin/bash\nsome_command\n";
694 let desc = parse_script_description(script);
695 assert!(desc.is_empty());
696 }
697
698 #[test]
699 fn parse_description_handles_bare_hash_as_empty_line() {
700 let script = b"#!/bin/bash\n# First.\n#\n# Third.\n";
701 let desc = parse_script_description(script);
702 assert_eq!(desc, vec!["First.", "", "Third."]);
703 }
704
705 #[test]
706 fn parse_description_trims_trailing_empty_lines() {
707 let script = b"#!/bin/bash\n# First.\n#\n#\n";
708 let desc = parse_script_description(script);
709 assert_eq!(desc, vec!["First."]);
710 }
711
712 #[test]
713 fn parse_bun_description_extracts_slash_comment_block() {
714 let script = b"// First line.\n// Second line.\nconsole.log('hi')\n";
715 let desc = parse_bun_description(script);
716 assert_eq!(desc, vec!["First line.", "Second line."]);
717 }
718
719 #[test]
720 fn parse_bun_description_skips_shebang_and_stops_at_code() {
721 let script = b"#!/usr/bin/env bun\n// Only line.\nexport const x = 1\n";
722 let desc = parse_bun_description(script);
723 assert_eq!(desc, vec!["Only line."]);
724 }
725
726 #[test]
727 fn parse_bun_description_handles_bare_slash_as_empty_line() {
728 let script = b"// First.\n//\n// Third.\n";
729 let desc = parse_bun_description(script);
730 assert_eq!(desc, vec!["First.", "", "Third."]);
731 }
732
733 #[test]
734 fn parse_bun_description_empty_when_starts_with_code() {
735 let script = b"import { foo } from './foo'\n// not a header\n";
736 let desc = parse_bun_description(script);
737 assert!(desc.is_empty());
738 }
739
740 #[test]
741 fn parse_description_empty_content() {
742 let desc = parse_script_description(b"");
743 assert!(desc.is_empty());
744 }
745
746 #[test]
747 fn list_categories_returns_proxy_and_utils() {
748 let _guard = overlay_lock_sync();
749 let cats = list_categories("shell");
750 let names: Vec<&str> = cats.iter().map(|c| c.name.as_str()).collect();
751 assert!(
752 names.contains(&"proxy"),
753 "proxy category missing: {names:?}"
754 );
755 assert!(
756 names.contains(&"utils"),
757 "utils category missing: {names:?}"
758 );
759 }
760
761 #[test]
762 fn list_categories_proxy_scripts_have_descriptions() {
763 let _guard = overlay_lock_sync();
764 let cats = list_categories("shell");
765 let proxy = cats.iter().find(|c| c.name == "proxy").unwrap();
766 for script in &proxy.scripts {
767 assert!(
768 !script.description.is_empty(),
769 "{} should have a description",
770 script.name
771 );
772 }
773 }
774
775 #[test]
776 fn list_categories_empty_prefix_returns_empty() {
777 let _guard = overlay_lock_sync();
778 let cats = list_categories("nonexistent");
779 assert!(cats.is_empty());
780 }
781
782 #[tokio::test]
783 async fn extract_prefix_only_extracts_matching_files() {
784 let _guard = overlay_lock().await;
785 let dir = make_temp_dir().await;
786 let report = extract_prefix("shell/proxy", &dir, false).await.unwrap();
787
788 assert!(!report.created.is_empty());
789 for path in &report.created {
790 assert!(
791 path.starts_with(dir.join("shell/proxy")),
792 "{path:?} should be under shell/proxy/"
793 );
794 }
795
796 fs::remove_dir_all(&dir).await.unwrap();
797 }
798
799 #[tokio::test]
800 async fn extract_prefix_shell_only_gets_shell_files() {
801 let _guard = overlay_lock().await;
802 let dir = make_temp_dir().await;
803 let report = extract_prefix("shell", &dir, false).await.unwrap();
804
805 assert!(!report.created.is_empty());
806 for path in &report.created {
807 assert!(
808 path.starts_with(dir.join("shell")),
809 "{path:?} should be under shell/"
810 );
811 }
812
813 fs::remove_dir_all(&dir).await.unwrap();
814 }
815
816 #[tokio::test]
817 async fn extracts_all_files_into_empty_dir() {
818 let _guard = overlay_lock().await;
819 let dir = make_temp_dir().await;
820 let report = extract_all(&dir, false).await.unwrap();
821
822 assert!(!report.created.is_empty());
823 assert!(report.skipped.is_empty());
824 assert!(report.overwritten.is_empty());
825
826 for path in &report.created {
827 assert!(path.exists(), "{path:?} should exist");
828 let content = fs::read(path).await.unwrap();
829 assert!(!content.is_empty(), "{path:?} should not be empty");
830 }
831
832 fs::remove_dir_all(&dir).await.unwrap();
833 }
834
835 #[tokio::test]
836 async fn skips_existing_files_when_overwrite_false() {
837 let _guard = overlay_lock().await;
838 let dir = make_temp_dir().await;
839 let marker = b"original content";
840
841 extract_prefix("shell/proxy", &dir, false).await.unwrap();
842
843 let first_file = PresetAssets::iter()
844 .find(|p| p.starts_with("shell/proxy/"))
845 .unwrap();
846 let dest = dir.join(first_file.as_ref());
847 fs::write(&dest, marker).await.unwrap();
848
849 let report = extract_prefix("shell/proxy", &dir, false).await.unwrap();
850 assert!(!report.skipped.is_empty());
851
852 let content = fs::read(&dest).await.unwrap();
853 assert_eq!(content, marker, "existing file should not be overwritten");
854
855 fs::remove_dir_all(&dir).await.unwrap();
856 }
857
858 #[tokio::test]
859 async fn overwrites_when_overwrite_true() {
860 let _guard = overlay_lock().await;
861 let dir = make_temp_dir().await;
862 let marker = b"marker";
863
864 extract_prefix("shell/proxy", &dir, false).await.unwrap();
865
866 let first_file = PresetAssets::iter()
867 .find(|p| p.starts_with("shell/proxy/"))
868 .unwrap();
869 let dest = dir.join(first_file.as_ref());
870 fs::write(&dest, marker).await.unwrap();
871
872 let report = extract_prefix("shell/proxy", &dir, true).await.unwrap();
873 assert!(!report.overwritten.is_empty());
874
875 let content = fs::read(&dest).await.unwrap();
876 assert_ne!(content, marker, "file should have been overwritten");
877
878 fs::remove_dir_all(&dir).await.unwrap();
879 }
880
881 #[tokio::test]
882 async fn creates_nested_directories() {
883 let _guard = overlay_lock().await;
884 let dir = make_temp_dir().await;
885 extract_prefix("shell", &dir, false).await.unwrap();
886
887 let nested = dir.join("shell").join("proxy");
888 assert!(
889 nested.is_dir(),
890 "shell/proxy/ subdirectory should be created"
891 );
892
893 fs::remove_dir_all(&dir).await.unwrap();
894 }
895
896 #[cfg(unix)]
897 #[tokio::test]
898 async fn sets_executable_bit_on_sh_files() {
899 let _guard = overlay_lock().await;
900 use std::os::unix::fs::PermissionsExt;
901
902 let dir = make_temp_dir().await;
903 let report = extract_prefix("shell", &dir, false).await.unwrap();
904
905 for path in &report.created {
906 if path.extension().and_then(|e| e.to_str()) == Some("sh") {
907 let mode = fs::metadata(path).await.unwrap().permissions().mode();
908 assert!(mode & 0o111 != 0, "{path:?} should be executable");
909 }
910 }
911
912 fs::remove_dir_all(&dir).await.unwrap();
913 }
914
915 #[tokio::test]
918 async fn remove_prefix_removes_extracted_files() {
919 let _guard = overlay_lock().await;
920 let dir = make_temp_dir().await;
921 let extract = extract_prefix("shell", &dir, false).await.unwrap();
922 assert!(!extract.created.is_empty());
923
924 let remove = remove_prefix("shell", &dir, false).await.unwrap();
925
926 assert_eq!(remove.removed.len(), extract.created.len());
927 for path in &remove.removed {
928 assert!(!path.exists(), "{path:?} should be gone");
929 }
930
931 fs::remove_dir_all(&dir).await.unwrap();
932 }
933
934 #[tokio::test]
935 async fn remove_prefix_leaves_user_added_files() {
936 let _guard = overlay_lock().await;
937 let dir = make_temp_dir().await;
938 extract_prefix("shell", &dir, false).await.unwrap();
939
940 let user_file = dir.join("shell").join("my_custom.sh");
941 fs::write(&user_file, b"custom").await.unwrap();
942
943 remove_prefix("shell", &dir, false).await.unwrap();
944
945 assert!(user_file.exists(), "user file must survive remove_prefix");
946
947 fs::remove_dir_all(&dir).await.unwrap();
948 }
949
950 #[tokio::test]
951 async fn remove_prefix_is_idempotent() {
952 let _guard = overlay_lock().await;
953 let dir = make_temp_dir().await;
954 extract_prefix("shell", &dir, false).await.unwrap();
955
956 remove_prefix("shell", &dir, false).await.unwrap();
957 let r2 = remove_prefix("shell", &dir, false).await.unwrap();
958
959 assert!(r2.removed.is_empty());
960
961 fs::remove_dir_all(&dir).await.unwrap();
962 }
963
964 #[tokio::test]
965 async fn remove_prefix_dry_run_mutates_nothing() {
966 let _guard = overlay_lock().await;
967 let dir = make_temp_dir().await;
968 let extract = extract_prefix("shell", &dir, false).await.unwrap();
969
970 let report = remove_prefix("shell", &dir, true).await.unwrap();
971
972 assert_eq!(report.removed.len(), extract.created.len());
973 for path in &extract.created {
974 assert!(path.exists(), "{path:?} should still exist after dry-run");
975 }
976
977 fs::remove_dir_all(&dir).await.unwrap();
978 }
979
980 #[tokio::test]
981 async fn remove_prefix_returns_empty_when_target_dir_missing() {
982 let _guard = overlay_lock().await;
983 let missing =
984 std::env::temp_dir().join(format!("shine-presets-miss-{}", uuid::Uuid::new_v4()));
985
986 let report = remove_prefix("shell", &missing, false).await.unwrap();
987
988 assert!(report.removed.is_empty());
989 assert!(!missing.exists());
990 }
991
992 #[tokio::test]
995 async fn list_fs_shell_categories_returns_empty_for_missing_dir() {
996 let missing =
997 std::env::temp_dir().join(format!("shine-presets-no-{}", uuid::Uuid::new_v4()));
998 let cats = list_fs_shell_categories(&missing).await;
999 assert!(cats.is_empty());
1000 }
1001
1002 #[tokio::test]
1003 async fn list_fs_shell_categories_finds_categories_from_disk() {
1004 let dir = make_temp_dir().await;
1005 let cat_dir = dir.join("shell/myplugin");
1007 fs::create_dir_all(&cat_dir).await.unwrap();
1008 fs::write(
1009 cat_dir.join("hello.sh"),
1010 b"#!/bin/bash\n# Says hello.\necho hello\n",
1011 )
1012 .await
1013 .unwrap();
1014
1015 let cats = list_fs_shell_categories(&dir).await;
1016
1017 assert_eq!(cats.len(), 1, "should find exactly one category");
1018 assert_eq!(cats[0].name, "myplugin");
1019 assert_eq!(cats[0].scripts.len(), 1);
1020 assert_eq!(cats[0].scripts[0].name, "hello.sh");
1021 assert_eq!(cats[0].scripts[0].description, vec!["Says hello."]);
1022
1023 fs::remove_dir_all(&dir).await.unwrap();
1024 }
1025
1026 #[tokio::test]
1027 async fn list_fs_shell_categories_ignores_non_sh_files() {
1028 let dir = make_temp_dir().await;
1029 let cat_dir = dir.join("shell/extras");
1030 fs::create_dir_all(&cat_dir).await.unwrap();
1031 fs::write(cat_dir.join("readme.md"), b"# readme\n")
1032 .await
1033 .unwrap();
1034 fs::write(cat_dir.join("script.sh"), b"#!/bin/bash\n# A script.\n")
1035 .await
1036 .unwrap();
1037
1038 let cats = list_fs_shell_categories(&dir).await;
1039
1040 assert_eq!(cats.len(), 1);
1041 assert_eq!(cats[0].scripts.len(), 1, "only .sh files should be listed");
1042 assert_eq!(cats[0].scripts[0].name, "script.sh");
1043
1044 fs::remove_dir_all(&dir).await.unwrap();
1045 }
1046
1047 #[tokio::test]
1048 async fn list_fs_shell_categories_returns_alphabetical_order() {
1049 let dir = make_temp_dir().await;
1050 for cat in ["zzz", "aaa", "mmm"] {
1051 let cat_dir = dir.join("shell").join(cat);
1052 fs::create_dir_all(&cat_dir).await.unwrap();
1053 fs::write(cat_dir.join("s.sh"), b"#!/bin/bash\n")
1054 .await
1055 .unwrap();
1056 }
1057
1058 let cats = list_fs_shell_categories(&dir).await;
1059 let names: Vec<&str> = cats.iter().map(|c| c.name.as_str()).collect();
1060 assert_eq!(names, vec!["aaa", "mmm", "zzz"]);
1061
1062 fs::remove_dir_all(&dir).await.unwrap();
1063 }
1064
1065 #[tokio::test]
1068 async fn collect_fs_shell_scripts_returns_empty_for_missing_dir() {
1069 let missing =
1070 std::env::temp_dir().join(format!("shine-presets-noscr-{}", uuid::Uuid::new_v4()));
1071 let scripts = collect_fs_shell_scripts(&missing, "shell").await.unwrap();
1072 assert!(scripts.is_empty());
1073 }
1074
1075 #[tokio::test]
1076 async fn collect_fs_shell_scripts_finds_sh_files_recursively() {
1077 let dir = make_temp_dir().await;
1078 let cat_dir = dir.join("shell/myplugin");
1079 fs::create_dir_all(&cat_dir).await.unwrap();
1080 fs::write(cat_dir.join("a.sh"), b"#!/bin/bash\n")
1081 .await
1082 .unwrap();
1083 fs::write(cat_dir.join("b.sh"), b"#!/bin/bash\n")
1084 .await
1085 .unwrap();
1086 fs::write(cat_dir.join("readme.txt"), b"ignore me\n")
1087 .await
1088 .unwrap();
1089
1090 let scripts = collect_fs_shell_scripts(&dir, "shell").await.unwrap();
1091
1092 let names: Vec<_> = scripts
1093 .iter()
1094 .map(|p| p.file_name().unwrap().to_str().unwrap())
1095 .collect();
1096 assert!(names.contains(&"a.sh"), "a.sh missing: {names:?}");
1097 assert!(names.contains(&"b.sh"), "b.sh missing: {names:?}");
1098 assert!(!names.contains(&"readme.txt"), "non-.sh should be excluded");
1099
1100 fs::remove_dir_all(&dir).await.unwrap();
1101 }
1102
1103 #[tokio::test]
1106 async fn extract_all_creates_files_in_target_dir() {
1107 let _guard = overlay_lock().await;
1108 let dir = make_temp_dir().await;
1109 let report = extract_all(&dir, false).await.unwrap();
1110
1111 assert!(
1112 !report.created.is_empty(),
1113 "should create at least one file"
1114 );
1115 assert!(report.skipped.is_empty());
1116 assert!(report.overwritten.is_empty());
1117
1118 for path in &report.created {
1119 assert!(path.exists(), "{path:?} should exist after export");
1120 }
1121
1122 fs::remove_dir_all(&dir).await.unwrap();
1123 }
1124
1125 #[tokio::test]
1126 async fn extract_all_skips_existing_by_default() {
1127 let _guard = overlay_lock().await;
1128 let dir = make_temp_dir().await;
1129
1130 let first = extract_all(&dir, false).await.unwrap();
1132 assert!(!first.created.is_empty());
1133
1134 let marker = b"do-not-overwrite";
1136 let target_path = &first.created[0];
1137 fs::write(target_path, marker).await.unwrap();
1138
1139 let second = extract_all(&dir, false).await.unwrap();
1141 assert!(
1142 second.skipped.contains(target_path),
1143 "modified file should be skipped on re-export without --force"
1144 );
1145
1146 let content = fs::read(target_path).await.unwrap();
1147 assert_eq!(
1148 content, marker,
1149 "file content must not change without --force"
1150 );
1151
1152 fs::remove_dir_all(&dir).await.unwrap();
1153 }
1154
1155 #[tokio::test]
1156 async fn extract_all_force_overwrites_existing() {
1157 let _guard = overlay_lock().await;
1158 let dir = make_temp_dir().await;
1159
1160 let first = extract_all(&dir, false).await.unwrap();
1162 assert!(!first.created.is_empty());
1163
1164 let marker = b"old-content";
1166 let target_path = &first.created[0];
1167 fs::write(target_path, marker).await.unwrap();
1168
1169 let second = extract_all(&dir, true).await.unwrap();
1171 assert!(
1172 second.overwritten.contains(target_path),
1173 "modified file should appear in overwritten list with --force"
1174 );
1175
1176 let content = fs::read(target_path).await.unwrap();
1177 assert_ne!(
1178 content, marker,
1179 "file content should be overwritten with --force"
1180 );
1181
1182 fs::remove_dir_all(&dir).await.unwrap();
1183 }
1184
1185 #[tokio::test]
1186 async fn extract_embedded_prefix_ignores_active_overlay() {
1187 let _guard = overlay_lock().await;
1188 let overlay = make_temp_dir().await;
1189 let target = make_temp_dir().await;
1190 let overlay_file = overlay.join("app/clash-verge/merge.yaml");
1191 fs::create_dir_all(overlay_file.parent().unwrap())
1192 .await
1193 .unwrap();
1194 fs::write(&overlay_file, "overlay-only marker")
1195 .await
1196 .unwrap();
1197 set_overlay_dir(Some(&overlay));
1198
1199 let report = extract_embedded_prefix("app/clash-verge", &target, false)
1200 .await
1201 .unwrap();
1202 set_overlay_dir(None);
1203
1204 assert!(!report.created.is_empty());
1205 let copied = fs::read_to_string(target.join("app/clash-verge/merge.yaml"))
1206 .await
1207 .unwrap();
1208 assert_ne!(copied, "overlay-only marker");
1209 fs::remove_dir_all(overlay).await.unwrap();
1210 fs::remove_dir_all(target).await.unwrap();
1211 }
1212}