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                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
152/// Extract a `shine-dest:` annotation from a single comment line.
153///
154/// Recognises `# shine-dest:` (shell/TOML/INI) and `" shine-dest:` (VimScript).
155pub 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
168/// Parse the `shine-dest:` annotation from the first (or second, if shebang) line.
169pub 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
181/// Return `true` if the script opts into env-variable substitution.
182///
183/// Looks for `# shine-template: true` in the shebang or leading comment block.
184pub 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        // Stop scanning once we leave the leading comment block.
198        if !trimmed.starts_with('#') && !trimmed.is_empty() {
199            break;
200        }
201    }
202    false
203}
204
205/// Parse the leading comment block from a shell script, skipping the shebang line
206/// and any `shine-dest:` annotation line.
207///
208/// Collects consecutive lines starting with `# ` or bare `#` until the first
209/// non-comment, non-shebang line. Trailing empty description lines are trimmed.
210pub 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
242/// Parse a leading `//` comment block from a bun source (`.ts`/`.js`/`.mts`/`.mjs`)
243/// as its description — the JS/TS-comment mirror of `parse_script_description`'s
244/// `#` handling for `.sh`/`.ps1`. An optional `#!` shebang is skipped; `// ` lines
245/// are collected (a bare `//` is a blank line); the first non-comment line ends the
246/// block; trailing blank lines are trimmed. There are no `shine-dest`/`shine-template`
247/// annotations to skip here — those are `#`/`.sh`-only.
248pub 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/// List all preset categories under `prefix/` and their scripts with descriptions.
275///
276/// Categories are the immediate subdirectories of `prefix/`. Scripts within each
277/// category are sorted by name. Returns categories in alphabetical order.
278#[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/// List shell preset categories by scanning the filesystem under `presets_dir/shell/`.
324///
325/// Each immediate subdirectory of `presets_dir/shell/` is a category; `.sh` files within
326/// it are the scripts. Descriptions are parsed from each script's leading comment block.
327/// Returns categories in alphabetical order.
328#[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/// Collect full paths to `.sh` files under `presets_dir/<prefix>/`.
386///
387/// Used by `shell install` when `is_external_presets` is true, to link scripts
388/// that already exist on disk without extracting embedded assets.
389#[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
418/// Remove embedded-asset files under `prefix/` from `target_dir`.
419///
420/// Only files known to `PresetAssets` are candidates — user-added files are
421/// never touched. Empty subdirectories within the prefix root are cleaned up
422/// after file removal. Missing files are recorded in `skipped`.
423/// When `dry_run` is true, nothing is removed.
424pub 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        // Walk directories deepest-first (BTreeSet sorts lexicographically;
453        // reversing gives deepest paths first).
454        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; // ignore error if non-empty
458            }
459        }
460        let _ = fs::remove_dir(&prefix_root).await;
461    }
462
463    Ok(report)
464}
465
466/// Extract only assets whose path starts with `prefix/`.
467pub 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
484/// Extract one prefix from the binary's embedded preset bundle only.
485pub 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
502/// Extract all embedded assets.
503pub 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-test variant: holds the lock across `.await` points safely.
594    async fn overlay_lock() -> tokio::sync::MutexGuard<'static, ()> {
595        overlay_lock_mutex().lock().await
596    }
597
598    /// Sync-test variant: used by plain `#[test]` functions with no Tokio runtime.
599    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    // --- remove_prefix tests ---
916
917    #[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    // --- list_fs_shell_categories tests ---
993
994    #[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        // Create presets/shell/myplugin/hello.sh
1006        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    // --- collect_fs_shell_scripts tests ---
1066
1067    #[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    // --- extract_all (presets export) tests ---
1104
1105    #[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        // First export — populates the dir
1131        let first = extract_all(&dir, false).await.unwrap();
1132        assert!(!first.created.is_empty());
1133
1134        // Overwrite one file with marker content
1135        let marker = b"do-not-overwrite";
1136        let target_path = &first.created[0];
1137        fs::write(target_path, marker).await.unwrap();
1138
1139        // Second export without --force — should skip the modified file
1140        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        // First export
1161        let first = extract_all(&dir, false).await.unwrap();
1162        assert!(!first.created.is_empty());
1163
1164        // Overwrite one file with marker content
1165        let marker = b"old-content";
1166        let target_path = &first.created[0];
1167        fs::write(target_path, marker).await.unwrap();
1168
1169        // Re-export with force
1170        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}