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