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, ¤t_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
98enum 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 .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
282async 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 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 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 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 let parsed: toml::Table = toml::from_str(&content).unwrap();
533 let saved = parsed
534 .get("presets_dir")
535 .and_then(toml::Value::as_str)
536 .map(PathBuf::from);
537 assert_eq!(
538 saved.as_deref(),
539 Some(fs::canonicalize(&presets).await.unwrap().as_path()),
540 "config.toml should contain the canonical linked path"
541 );
542
543 fs::remove_dir_all(&dir).await.unwrap();
544 fs::remove_dir_all(&presets).await.unwrap();
545 }
546
547 #[tokio::test]
548 async fn live_link_persists_external_shell_mode() {
549 let dir = make_temp_dir().await;
550 let presets = make_temp_dir().await;
551 let config = config_in(&dir);
552
553 handle_preset_link(&config, presets.clone(), false, true)
554 .await
555 .unwrap();
556
557 let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
558 assert!(content.contains("external_shell_mode = \"live\""));
559 fs::remove_dir_all(&dir).await.unwrap();
560 fs::remove_dir_all(&presets).await.unwrap();
561 }
562
563 #[tokio::test]
564 async fn link_creates_dir_when_create_flag_set() {
565 let dir = make_temp_dir().await;
566 let config = config_in(&dir);
567 let new_dir = dir.join("new-presets");
568
569 handle_preset_link(&config, new_dir.clone(), true, false)
570 .await
571 .unwrap();
572
573 assert!(new_dir.exists(), "directory should have been created");
574 fs::remove_dir_all(&dir).await.unwrap();
575 }
576
577 #[tokio::test]
578 async fn link_fails_when_path_missing_and_no_create() {
579 let dir = make_temp_dir().await;
580 let config = config_in(&dir);
581 let missing = dir.join("does-not-exist");
582
583 let err = handle_preset_link(&config, missing, false, false).await;
584 assert!(err.is_err());
585 let msg = err.unwrap_err().to_string();
586 assert!(
587 msg.contains("--create") || msg.contains("does not exist"),
588 "error should mention --create: {msg}"
589 );
590
591 fs::remove_dir_all(&dir).await.unwrap();
592 }
593
594 #[tokio::test]
595 async fn link_fails_when_path_is_a_file() {
596 let dir = make_temp_dir().await;
597 let config = config_in(&dir);
598 let file = dir.join("not-a-dir.txt");
599 fs::write(&file, b"hello").await.unwrap();
600
601 let err = handle_preset_link(&config, file, false, false).await;
602 assert!(err.is_err());
603 assert!(
604 err.unwrap_err().to_string().contains("not a directory"),
605 "error should mention 'not a directory'"
606 );
607
608 fs::remove_dir_all(&dir).await.unwrap();
609 }
610
611 #[tokio::test]
612 async fn link_is_noop_when_already_linked_to_same_path() {
613 let dir = make_temp_dir().await;
614 let presets = make_temp_dir().await;
615 let abs = tokio::fs::canonicalize(&presets)
616 .await
617 .unwrap_or(presets.clone());
618 let config = config_in(&dir).with_presets_dir_override(Some(abs.clone()));
619
620 handle_preset_link(&config, presets.clone(), false, false)
622 .await
623 .unwrap();
624
625 assert!(!dir.join("config.toml").exists());
627
628 fs::remove_dir_all(&dir).await.unwrap();
629 fs::remove_dir_all(&presets).await.unwrap();
630 }
631
632 #[allow(clippy::await_holding_lock)]
633 #[tokio::test(flavor = "current_thread")]
634 async fn link_warns_when_env_var_overrides() {
635 let _guard = env_lock();
636 let dir = make_temp_dir().await;
637 let presets = make_temp_dir().await;
638 let config = config_in(&dir);
639
640 unsafe { std::env::set_var("SHINE_PRESETS", "/some/override") };
642 handle_preset_link(&config, presets.clone(), false, false)
644 .await
645 .unwrap();
646 unsafe { std::env::remove_var("SHINE_PRESETS") };
648
649 fs::remove_dir_all(&dir).await.unwrap();
650 fs::remove_dir_all(&presets).await.unwrap();
651 }
652
653 #[tokio::test]
654 async fn unlink_removes_presets_dir_key() {
655 let dir = make_temp_dir().await;
656 let presets = make_temp_dir().await;
657 let config = config_in(&dir).with_presets_dir_override(Some(presets.clone()));
658 config.save().await.unwrap();
660
661 handle_preset_unlink(&config).await.unwrap();
662
663 let content = fs::read_to_string(dir.join("config.toml")).await.unwrap();
664 let parsed: toml::Table = toml::from_str(&content).unwrap();
665 assert!(
666 !parsed.contains_key("presets_dir"),
667 "presets_dir key must be absent after unlink"
668 );
669
670 fs::remove_dir_all(&dir).await.unwrap();
671 fs::remove_dir_all(&presets).await.unwrap();
672 }
673
674 #[tokio::test]
675 async fn unlink_is_noop_when_no_override_set() {
676 let dir = make_temp_dir().await;
677 let config = config_in(&dir);
678
679 handle_preset_unlink(&config).await.unwrap();
681 assert!(!dir.join("config.toml").exists());
682
683 fs::remove_dir_all(&dir).await.unwrap();
684 }
685}