Skip to main content

cli/
presets.rs

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
64/// Return paths from the binary's embedded preset bundle only.
65///
66/// Unlike [`asset_paths`], this deliberately excludes the active overlay. It is
67/// used when users ask for a pristine copy of what the current binary ships.
68pub 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
100/// Read a file from the binary's embedded preset bundle, ignoring overlays.
101pub 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
149/// Extract a `shine-dest:` annotation from a single comment line.
150///
151/// Recognises `# shine-dest:` (shell/TOML/INI) and `" shine-dest:` (VimScript).
152pub 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
165/// Parse the `shine-dest:` annotation from the first (or second, if shebang) line.
166pub 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
178/// Return `true` if the script opts into env-variable substitution.
179///
180/// Looks for `# shine-template: true` in the shebang or leading comment block.
181pub 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        // Stop scanning once we leave the leading comment block.
195        if !trimmed.starts_with('#') && !trimmed.is_empty() {
196            break;
197        }
198    }
199    false
200}
201
202/// Parse the leading comment block from a shell script, skipping the shebang line
203/// and any `shine-dest:` annotation line.
204///
205/// Collects consecutive lines starting with `# ` or bare `#` until the first
206/// non-comment, non-shebang line. Trailing empty description lines are trimmed.
207pub 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
239/// Parse a leading `//` comment block from a bun source (`.ts`/`.js`/`.mts`/`.mjs`)
240/// as its description — the JS/TS-comment mirror of `parse_script_description`'s
241/// `#` handling for `.sh`/`.ps1`. An optional `#!` shebang is skipped; `// ` lines
242/// are collected (a bare `//` is a blank line); the first non-comment line ends the
243/// block; trailing blank lines are trimmed. There are no `shine-dest`/`shine-template`
244/// annotations to skip here — those are `#`/`.sh`-only.
245pub 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/// List all preset categories under `prefix/` and their scripts with descriptions.
272///
273/// Categories are the immediate subdirectories of `prefix/`. Scripts within each
274/// category are sorted by name. Returns categories in alphabetical order.
275#[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/// List shell preset categories by scanning the filesystem under `presets_dir/shell/`.
321///
322/// Each immediate subdirectory of `presets_dir/shell/` is a category; `.sh` files within
323/// it are the scripts. Descriptions are parsed from each script's leading comment block.
324/// Returns categories in alphabetical order.
325#[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/// Collect full paths to `.sh` files under `presets_dir/<prefix>/`.
383///
384/// Used by `shell install` when `is_external_presets` is true, to link scripts
385/// that already exist on disk without extracting embedded assets.
386#[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
415/// Remove embedded-asset files under `prefix/` from `target_dir`.
416///
417/// Only files known to `PresetAssets` are candidates — user-added files are
418/// never touched. Empty subdirectories within the prefix root are cleaned up
419/// after file removal. Missing files are recorded in `skipped`.
420/// When `dry_run` is true, nothing is removed.
421pub 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        // Walk directories deepest-first (BTreeSet sorts lexicographically;
450        // reversing gives deepest paths first).
451        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; // ignore error if non-empty
455            }
456        }
457        let _ = fs::remove_dir(&prefix_root).await;
458    }
459
460    Ok(report)
461}
462
463/// Extract only assets whose path starts with `prefix/`.
464pub 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
481/// Extract one prefix from the binary's embedded preset bundle only.
482pub 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
499/// Extract all embedded assets.
500pub 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-test variant: holds the lock across `.await` points safely.
591    async fn overlay_lock() -> tokio::sync::MutexGuard<'static, ()> {
592        overlay_lock_mutex().lock().await
593    }
594
595    /// Sync-test variant: used by plain `#[test]` functions with no Tokio runtime.
596    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    // --- remove_prefix tests ---
879
880    #[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    // --- list_fs_shell_categories tests ---
956
957    #[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        // Create presets/shell/myplugin/hello.sh
969        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    // --- collect_fs_shell_scripts tests ---
1029
1030    #[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    // --- extract_all (presets export) tests ---
1067
1068    #[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        // First export — populates the dir
1094        let first = extract_all(&dir, false).await.unwrap();
1095        assert!(!first.created.is_empty());
1096
1097        // Overwrite one file with marker content
1098        let marker = b"do-not-overwrite";
1099        let target_path = &first.created[0];
1100        fs::write(target_path, marker).await.unwrap();
1101
1102        // Second export without --force — should skip the modified file
1103        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        // First export
1124        let first = extract_all(&dir, false).await.unwrap();
1125        assert!(!first.created.is_empty());
1126
1127        // Overwrite one file with marker content
1128        let marker = b"old-content";
1129        let target_path = &first.created[0];
1130        fs::write(target_path, marker).await.unwrap();
1131
1132        // Re-export with force
1133        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}