Skip to main content

cli/
preset_commands.rs

1use anyhow::{Result, bail};
2use std::path::PathBuf;
3
4use crate::commands::OverlayLinkCommand;
5use crate::config::{self, Config};
6use crate::{colors, presets};
7
8pub async fn handle_preset_copy(target: &str, force: bool) -> Result<()> {
9    use anyhow::Context as _;
10
11    let current_dir = std::env::current_dir().context("reading current directory")?;
12    println!(
13        "Copying built-in preset {target} to {} ...",
14        current_dir.display()
15    );
16
17    let report = copy_embedded_preset(target, &current_dir, force).await?;
18    print_extract_report(&report);
19
20    println!();
21    println!(
22        "Tip: delete files you do not plan to customize so they continue to follow built-in updates."
23    );
24    println!(
25        "Tip: run `shine preset overlay link {}` to activate this directory.",
26        current_dir.display()
27    );
28
29    Ok(())
30}
31
32async fn copy_embedded_preset(
33    target: &str,
34    target_dir: &std::path::Path,
35    force: bool,
36) -> Result<presets::ExtractReport> {
37    crate::commands::parse_copy_target(target).map_err(anyhow::Error::msg)?;
38    if presets::embedded_asset_paths(target).is_empty() {
39        bail!("built-in preset not found: {target}");
40    }
41    presets::extract_embedded_prefix(target, target_dir, force).await
42}
43
44fn print_extract_report(report: &presets::ExtractReport) {
45    let created = report.created.len();
46    let overwritten = report.overwritten.len();
47    let skipped = report.skipped.len();
48
49    if created > 0 {
50        println!("{}", colors::green(&format!("  {created} file(s) created")));
51    }
52    if overwritten > 0 {
53        println!(
54            "{}",
55            colors::yellow(&format!("  {overwritten} file(s) updated (overwritten)"))
56        );
57    }
58    if skipped > 0 {
59        println!("  {skipped} file(s) skipped (already exist; use --force to overwrite)");
60    }
61}
62
63pub async fn handle_preset_export(
64    config: &Config,
65    dir: Option<PathBuf>,
66    force: bool,
67) -> Result<()> {
68    use anyhow::Context as _;
69
70    let target = dir.unwrap_or_else(|| config.presets_dir().to_owned());
71    tokio::fs::create_dir_all(&target)
72        .await
73        .with_context(|| format!("creating export directory: {}", target.display()))?;
74
75    println!("Exporting built-in presets to {} ...", target.display());
76
77    let report = presets::extract_all(&target, force).await?;
78
79    let created = report.created.len();
80    let overwritten = report.overwritten.len();
81    let skipped = report.skipped.len();
82    print_extract_report(&report);
83    if created == 0 && overwritten == 0 && skipped == 0 {
84        println!("  No files exported (empty embedded asset set).");
85    }
86
87    if !config.is_external_presets {
88        println!();
89        println!(
90            "Tip: run `shine preset link {}` to activate this directory.",
91            target.display()
92        );
93    }
94
95    Ok(())
96}
97
98/// Which override a `handle_link`-style command is pointing at. The two link
99/// commands share the same expand/create/stat/canonicalize prelude but differ
100/// in which config field they touch and what they print afterward.
101enum LinkKind {
102    Presets,
103    Overlay,
104}
105
106async fn handle_link(
107    config: &Config,
108    path: PathBuf,
109    create: bool,
110    kind: LinkKind,
111    live: bool,
112) -> Result<()> {
113    use anyhow::Context as _;
114
115    let raw = path.to_string_lossy();
116    let expanded = config::full_expand(&raw).with_context(|| format!("expanding path: {raw}"))?;
117    let expanded = PathBuf::from(expanded);
118
119    if create {
120        tokio::fs::create_dir_all(&expanded)
121            .await
122            .with_context(|| format!("creating directory: {}", expanded.display()))?;
123    }
124
125    let meta = tokio::fs::metadata(&expanded).await.with_context(|| {
126        if create {
127            format!("accessing directory: {}", expanded.display())
128        } else {
129            format!(
130                "path does not exist: {} (use --create to create it)",
131                expanded.display()
132            )
133        }
134    })?;
135
136    if !meta.is_dir() {
137        bail!("path is not a directory: {}", expanded.display());
138    }
139
140    let absolute = tokio::fs::canonicalize(&expanded).await.unwrap_or(expanded);
141
142    if matches!(kind, LinkKind::Overlay) {
143        config::validate_env_override_file(&absolute.join("shine.env.toml")).await?;
144    }
145
146    let wanted_mode = if live {
147        config::ExternalShellMode::Live
148    } else {
149        config::ExternalShellMode::Snapshot
150    };
151    let already_linked = match kind {
152        LinkKind::Presets => {
153            config
154                .presets_dir_override
155                .as_deref()
156                .is_some_and(|p| p == absolute)
157                && config.external_shell_mode == wanted_mode
158        }
159        LinkKind::Overlay => config
160            .presets_overlay_dir_override
161            .as_deref()
162            .is_some_and(|p| p == absolute),
163    };
164    if already_linked {
165        let message = match kind {
166            LinkKind::Presets => format!("already linked: {}", absolute.display()),
167            LinkKind::Overlay => format!("overlay already linked: {}", absolute.display()),
168        };
169        println!("{}", colors::dim(&message));
170        return Ok(());
171    }
172
173    let updated = match kind {
174        LinkKind::Presets => config
175            .clone()
176            .with_presets_dir_override(Some(absolute.clone()))
177            .with_external_shell_mode(wanted_mode),
178        LinkKind::Overlay => config
179            .clone()
180            // Linking a local path clears any shine-managed Git overlay so the
181            // two overlay modes never coexist.
182            .with_presets_overlay_git(None, None)
183            .with_presets_overlay_dir_override(Some(absolute.clone())),
184    };
185    updated.save().await?;
186
187    match kind {
188        LinkKind::Presets => {
189            if std::env::var("SHINE_CONFIG_DIR")
190                .map(|v| !v.trim().is_empty())
191                .unwrap_or(false)
192                || std::env::var("SHINE_PRESETS")
193                    .map(|v| !v.trim().is_empty())
194                    .unwrap_or(false)
195            {
196                println!(
197                    "{}",
198                    colors::yellow(
199                        "Warning: SHINE_CONFIG_DIR or SHINE_PRESETS is set and takes priority over \
200                         the active config at runtime. Unset the env var for this setting to take effect."
201                    )
202                );
203            }
204
205            println!("{}", colors::external_presets_note(&absolute));
206            println!(
207                "{}",
208                colors::dim(match wanted_mode {
209                    config::ExternalShellMode::Snapshot => {
210                        "Shell mode: snapshot (run `shine upgrade` to apply source changes)."
211                    }
212                    config::ExternalShellMode::Live => {
213                        "Shell mode: live (content changes apply on the next invocation)."
214                    }
215                })
216            );
217            println!(
218                "{}",
219                colors::dim(
220                    "Run `shine preset export` to populate the directory with built-in presets."
221                )
222            );
223        }
224        LinkKind::Overlay => {
225            println!("{}", colors::presets_overlay_note(&absolute));
226            println!(
227                "{}",
228                colors::dim("Overlay files override the active presets source by matching path.")
229            );
230        }
231    }
232
233    Ok(())
234}
235
236pub async fn handle_preset_link(
237    config: &Config,
238    path: PathBuf,
239    create: bool,
240    live: bool,
241) -> Result<()> {
242    handle_link(config, path, create, LinkKind::Presets, live).await
243}
244
245pub async fn handle_preset_unlink(config: &Config) -> Result<()> {
246    if config.presets_dir_override.is_none() {
247        println!(
248            "{}",
249            colors::dim("No external presets directory is configured.")
250        );
251        return Ok(());
252    }
253
254    let updated = config
255        .clone()
256        .with_presets_dir_override(None)
257        .with_external_shell_mode(config::ExternalShellMode::Snapshot);
258    updated.save().await?;
259
260    println!(
261        "{}",
262        colors::green("External presets directory removed from the active config.")
263    );
264    println!(
265        "{}",
266        colors::dim("Built-in embedded presets will be used on the next run.")
267    );
268
269    Ok(())
270}
271
272pub async fn handle_overlay_link(config: &Config, cmd: OverlayLinkCommand) -> Result<()> {
273    if let Some(url) = cmd.git {
274        return handle_overlay_link_git(config, url, cmd.branch).await;
275    }
276    match cmd.path {
277        Some(path) => handle_link(config, path, cmd.create, LinkKind::Overlay, false).await,
278        None => bail!("provide a local PATH or --git <URL> for the overlay"),
279    }
280}
281
282/// Point the overlay at a shine-managed Git source: record the URL (clearing any
283/// manual overlay path) and clone/mirror it immediately so it's ready to use.
284async fn handle_overlay_link_git(
285    config: &Config,
286    url: String,
287    branch: Option<String>,
288) -> Result<()> {
289    let url = url.trim().to_string();
290    if url.is_empty() {
291        bail!("overlay Git URL must not be empty");
292    }
293    let branch = branch
294        .map(|b| b.trim().to_string())
295        .filter(|b| !b.is_empty());
296
297    let updated = config.clone().with_presets_overlay_git(Some(url), branch);
298    updated.save().await?;
299
300    let (url, branch, dir) = updated
301        .overlay_git_source()
302        .expect("overlay Git source was just set");
303    println!(
304        "{}",
305        colors::green(&format!("Overlay Git source set: {url}"))
306    );
307    if let Some(branch) = branch {
308        println!("  {} {branch}", colors::dim("branch:"));
309    }
310    println!("  {} {}", colors::dim("managed dir:"), dir.display());
311
312    // Clone (or mirror, if already present) now so the overlay is usable right
313    // away instead of waiting for the next `shine preset pull`.
314    crate::git_pull::sync_managed_overlay(url, branch, dir, false).await?;
315    Ok(())
316}
317
318pub async fn handle_overlay_unlink(config: &Config) -> Result<()> {
319    if config.presets_overlay_dir_override.is_none() && config.presets_overlay_git.is_none() {
320        println!("{}", colors::dim("No presets overlay is configured."));
321        return Ok(());
322    }
323
324    let managed_dir = config
325        .overlay_git_source()
326        .map(|(_, _, dir)| dir.to_path_buf());
327
328    let updated = config
329        .clone()
330        .with_presets_overlay_git(None, None)
331        .with_presets_overlay_dir_override(None);
332    updated.save().await?;
333
334    println!(
335        "{}",
336        colors::green("Presets overlay removed from the active config.")
337    );
338    println!(
339        "{}",
340        colors::dim("Built-in embedded presets will be used without overlay on the next run.")
341    );
342    if let Some(dir) = managed_dir.filter(|dir| dir.exists()) {
343        println!(
344            "{}",
345            colors::dim(&format!(
346                "The managed overlay checkout remains at {}. Remove it manually if unwanted.",
347                dir.display()
348            ))
349        );
350    }
351
352    Ok(())
353}
354
355pub fn handle_overlay_info(config: &Config) -> Result<()> {
356    if let Some((url, branch, dir)) = config.overlay_git_source() {
357        println!("{}", colors::green(&format!("Overlay Git source: {url}")));
358        if let Some(branch) = branch {
359            println!("  {} {branch}", colors::dim("branch:"));
360        }
361        println!("  {} {}", colors::dim("managed dir:"), dir.display());
362        if dir.exists() {
363            println!("{}", colors::green("Cloned"));
364        } else {
365            println!(
366                "{}",
367                colors::dim("Not cloned yet — run `shine preset pull` to fetch it.")
368            );
369        }
370        return Ok(());
371    }
372
373    if let Some(dir) = &config.presets_overlay_dir_override {
374        println!("{}", colors::presets_overlay_note(dir));
375        println!("{}", colors::green("Active"));
376    } else {
377        println!("{}", colors::dim("No presets overlay is configured."));
378    }
379    Ok(())
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::test_support::env_lock;
386    use std::time::{SystemTime, UNIX_EPOCH};
387    use tokio::fs;
388
389    async fn make_temp_dir() -> PathBuf {
390        crate::test_support::make_temp_dir("shine-preset-commands-test").await
391    }
392
393    fn config_in(dir: &std::path::Path) -> Config {
394        crate::test_support::test_config(dir)
395    }
396
397    #[test]
398    fn copy_target_requires_canonical_safe_category() {
399        for valid in ["app/surge", "shell/proxy", "sys/macos"] {
400            crate::commands::parse_copy_target(valid).unwrap();
401        }
402        for invalid in [
403            "surge",
404            "app",
405            "app/",
406            "/app/surge",
407            "app/../surge",
408            "app/.",
409            "app/surge/extra",
410            "other/surge",
411            "app\\surge",
412        ] {
413            assert!(
414                crate::commands::parse_copy_target(invalid).is_err(),
415                "target should be rejected: {invalid}"
416            );
417        }
418    }
419
420    #[tokio::test]
421    async fn copy_one_builtin_preset_preserves_prefix_and_collision_policy() {
422        let dir = make_temp_dir().await;
423        let first = copy_embedded_preset("app/clash-verge", &dir, false)
424            .await
425            .unwrap();
426        assert!(!first.created.is_empty());
427        assert!(dir.join("app/clash-verge/shine.toml").is_file());
428        assert!(dir.join("app/clash-verge/merge.yaml").is_file());
429        assert!(!dir.join("app/surge/shine.toml").exists());
430
431        let marker_path = dir.join("app/clash-verge/merge.yaml");
432        fs::write(&marker_path, "user customization").await.unwrap();
433        let second = copy_embedded_preset("app/clash-verge", &dir, false)
434            .await
435            .unwrap();
436        assert!(second.skipped.contains(&marker_path));
437        assert_eq!(
438            fs::read_to_string(&marker_path).await.unwrap(),
439            "user customization"
440        );
441
442        let third = copy_embedded_preset("app/clash-verge", &dir, true)
443            .await
444            .unwrap();
445        assert!(third.overwritten.contains(&marker_path));
446        assert_ne!(
447            fs::read_to_string(&marker_path).await.unwrap(),
448            "user customization"
449        );
450        fs::remove_dir_all(dir).await.unwrap();
451    }
452
453    #[tokio::test]
454    async fn copy_unknown_builtin_preset_creates_nothing() {
455        let dir = make_temp_dir().await;
456        let error = match copy_embedded_preset("app/not-a-real-preset", &dir, false).await {
457            Ok(_) => panic!("unknown preset should fail"),
458            Err(error) => error,
459        };
460        assert!(error.to_string().contains("built-in preset not found"));
461        assert!(
462            fs::read_dir(&dir)
463                .await
464                .unwrap()
465                .next_entry()
466                .await
467                .unwrap()
468                .is_none()
469        );
470        fs::remove_dir_all(dir).await.unwrap();
471    }
472
473    #[allow(clippy::await_holding_lock)]
474    #[tokio::test(flavor = "current_thread")]
475    async fn overlay_link_rejects_invalid_env_without_saving_link() {
476        let _guard = env_lock();
477        let suffix = SystemTime::now()
478            .duration_since(UNIX_EPOCH)
479            .unwrap()
480            .as_nanos();
481        let root = std::env::temp_dir().join(format!("shine-overlay-link-{suffix}"));
482        let state_dir = root.join("state");
483        let overlay_dir = root.join("overlay");
484        tokio::fs::create_dir_all(&overlay_dir).await.unwrap();
485        tokio::fs::write(
486            overlay_dir.join("shine.env.toml"),
487            "INVALID = \"unterminated\n",
488        )
489        .await
490        .unwrap();
491
492        // SAFETY: env_lock serializes process-global environment changes in tests.
493        unsafe {
494            std::env::set_var("SHINE_CONFIG_DIR", &state_dir);
495            std::env::remove_var("SHINE_PRESETS");
496        }
497        let config = Config::load_or_init().await.unwrap();
498        let error = handle_overlay_link(
499            &config,
500            OverlayLinkCommand {
501                path: Some(overlay_dir.clone()),
502                git: None,
503                branch: None,
504                create: false,
505            },
506        )
507        .await
508        .unwrap_err();
509        assert!(error.to_string().contains("shine.env.toml"));
510
511        let saved = tokio::fs::read_to_string(state_dir.join("config.toml"))
512            .await
513            .unwrap();
514        assert!(!saved.contains("presets_overlay_dir"));
515
516        // SAFETY: env_lock serializes process-global environment changes in tests.
517        unsafe { std::env::remove_var("SHINE_CONFIG_DIR") };
518        tokio::fs::remove_dir_all(root).await.unwrap();
519    }
520
521    #[tokio::test]
522    async fn link_writes_presets_dir_to_config() {
523        let dir = make_temp_dir().await;
524        let presets = make_temp_dir().await;
525        let config = config_in(&dir);
526
527        handle_preset_link(&config, presets.clone(), false, false)
528            .await
529            .unwrap();
530
531        let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
532        assert!(
533            content.contains(presets.to_str().unwrap()),
534            "config.toml should contain the linked path"
535        );
536
537        fs::remove_dir_all(&dir).await.unwrap();
538        fs::remove_dir_all(&presets).await.unwrap();
539    }
540
541    #[tokio::test]
542    async fn live_link_persists_external_shell_mode() {
543        let dir = make_temp_dir().await;
544        let presets = make_temp_dir().await;
545        let config = config_in(&dir);
546
547        handle_preset_link(&config, presets.clone(), false, true)
548            .await
549            .unwrap();
550
551        let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
552        assert!(content.contains("external_shell_mode = \"live\""));
553        fs::remove_dir_all(&dir).await.unwrap();
554        fs::remove_dir_all(&presets).await.unwrap();
555    }
556
557    #[tokio::test]
558    async fn link_creates_dir_when_create_flag_set() {
559        let dir = make_temp_dir().await;
560        let config = config_in(&dir);
561        let new_dir = dir.join("new-presets");
562
563        handle_preset_link(&config, new_dir.clone(), true, false)
564            .await
565            .unwrap();
566
567        assert!(new_dir.exists(), "directory should have been created");
568        fs::remove_dir_all(&dir).await.unwrap();
569    }
570
571    #[tokio::test]
572    async fn link_fails_when_path_missing_and_no_create() {
573        let dir = make_temp_dir().await;
574        let config = config_in(&dir);
575        let missing = dir.join("does-not-exist");
576
577        let err = handle_preset_link(&config, missing, false, false).await;
578        assert!(err.is_err());
579        let msg = err.unwrap_err().to_string();
580        assert!(
581            msg.contains("--create") || msg.contains("does not exist"),
582            "error should mention --create: {msg}"
583        );
584
585        fs::remove_dir_all(&dir).await.unwrap();
586    }
587
588    #[tokio::test]
589    async fn link_fails_when_path_is_a_file() {
590        let dir = make_temp_dir().await;
591        let config = config_in(&dir);
592        let file = dir.join("not-a-dir.txt");
593        fs::write(&file, b"hello").await.unwrap();
594
595        let err = handle_preset_link(&config, file, false, false).await;
596        assert!(err.is_err());
597        assert!(
598            err.unwrap_err().to_string().contains("not a directory"),
599            "error should mention 'not a directory'"
600        );
601
602        fs::remove_dir_all(&dir).await.unwrap();
603    }
604
605    #[tokio::test]
606    async fn link_is_noop_when_already_linked_to_same_path() {
607        let dir = make_temp_dir().await;
608        let presets = make_temp_dir().await;
609        let abs = tokio::fs::canonicalize(&presets)
610            .await
611            .unwrap_or(presets.clone());
612        let config = config_in(&dir).with_presets_dir_override(Some(abs.clone()));
613
614        // Should return Ok without error
615        handle_preset_link(&config, presets.clone(), false, false)
616            .await
617            .unwrap();
618
619        // Config file should not be written (config_in has no pre-existing file)
620        assert!(!dir.join("config.toml").exists());
621
622        fs::remove_dir_all(&dir).await.unwrap();
623        fs::remove_dir_all(&presets).await.unwrap();
624    }
625
626    #[allow(clippy::await_holding_lock)]
627    #[tokio::test(flavor = "current_thread")]
628    async fn link_warns_when_env_var_overrides() {
629        let _guard = env_lock();
630        let dir = make_temp_dir().await;
631        let presets = make_temp_dir().await;
632        let config = config_in(&dir);
633
634        // SAFETY: `_guard` holds `env_lock()`, serialising SHINE_PRESETS mutations across test threads.
635        unsafe { std::env::set_var("SHINE_PRESETS", "/some/override") };
636        // Should succeed even with env var set
637        handle_preset_link(&config, presets.clone(), false, false)
638            .await
639            .unwrap();
640        // SAFETY: `_guard` holds `env_lock()`, serialising SHINE_PRESETS mutations across test threads.
641        unsafe { std::env::remove_var("SHINE_PRESETS") };
642
643        fs::remove_dir_all(&dir).await.unwrap();
644        fs::remove_dir_all(&presets).await.unwrap();
645    }
646
647    #[tokio::test]
648    async fn unlink_removes_presets_dir_key() {
649        let dir = make_temp_dir().await;
650        let presets = make_temp_dir().await;
651        let config = config_in(&dir).with_presets_dir_override(Some(presets.clone()));
652        // Write initial config with presets_dir set
653        config.save().await.unwrap();
654
655        handle_preset_unlink(&config).await.unwrap();
656
657        let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
658        let parsed: toml::Table = toml::from_str(&content).unwrap();
659        assert!(
660            !parsed.contains_key("presets_dir"),
661            "presets_dir key must be absent after unlink"
662        );
663
664        fs::remove_dir_all(&dir).await.unwrap();
665        fs::remove_dir_all(&presets).await.unwrap();
666    }
667
668    #[tokio::test]
669    async fn unlink_is_noop_when_no_override_set() {
670        let dir = make_temp_dir().await;
671        let config = config_in(&dir);
672
673        // Should return Ok, no file written
674        handle_preset_unlink(&config).await.unwrap();
675        assert!(!dir.join("config.toml").exists());
676
677        fs::remove_dir_all(&dir).await.unwrap();
678    }
679}