1mod annotation;
2mod build;
3mod generator;
4mod hooks;
5mod info;
6mod install;
7mod json_merge;
8mod metadata;
9mod refresh;
10mod report;
11mod uninstall;
12mod upgrade;
13
14pub use build::{handle_build, handle_unbuild};
15#[doc(hidden)]
16pub use info::handle_list_with_presets_note;
17pub use info::{handle_info, handle_list};
18pub use install::handle_install;
19#[cfg(test)]
20pub(crate) use metadata::built_in_platform_availability;
21pub(crate) use metadata::validate_preset_category;
22pub use metadata::{
23 AppCategory, AppDestinationRoot, AppFile, AppGenerator, AppHook, AppListMode,
24 load_active_categories, load_embedded_categories, load_installed_categories,
25};
26pub use refresh::handle_refresh;
27pub use uninstall::handle_uninstall;
28pub use upgrade::{AppUpgradeReport, handle_upgrade_installed};
29pub(crate) use upgrade::{handle_upgrade_installed_target, handle_upgrade_installed_with_output};
30
31use crate::config::Config;
32use crate::install_core::manifest::{self, AppEntry, AppInstallStrategy, hash_content};
33use crate::install_core::{file_ops, transforms};
34use anyhow::{Context, Result};
35use file_ops::{InstallOutcome, UninstallOutcome};
36use std::collections::BTreeMap;
37use std::path::{Path, PathBuf};
38const APP_TEMPLATE: &str = r#"# App preset metadata for shine.
39description = "My app configuration."
40dest = "~/.config/my-app"
41# Optional category platform destination. Exact OS keys override the Unix fallback:
42# dest = { macos = "~/Library/Application Support/My App", linux = "~/.config/my-app", windows = "~/AppData/Roaming/My App", unix = "~/.config/my-app" }
43
44[[files]]
45source = "config.toml"
46target = "config.toml"
47# Optional: platforms = ["macos"] # exact: macos/linux/windows; unix groups macOS + Linux
48# Optional per-file override:
49# dest = { base = "data-dir", path = "com.example.my-app" }
50description = "Main application config"
51display_name = "config.toml"
52# Known transforms: "template", "jsonc-to-json".
53transforms = []
54# Optional generated source. The static `source` above is the fallback.
55# `auto = false` disables implicit status/upgrade runs; use `app refresh`.
56# generator = { script = "generate.ts", runtime = "bun", env = ["SOURCE_URL"], when_env = "SOURCE_URL", auto = false }
57"#;
58
59pub async fn handle_init_template(force: bool) -> Result<()> {
60 let dir = std::env::current_dir().context("reading current directory")?;
61 let (path, overwritten) =
62 utils::init_template::write_shine_toml_template(&dir, force, APP_TEMPLATE)?;
63 if overwritten {
64 println!("Updated app preset template: {}", path.display());
65 } else {
66 println!("Created app preset template: {}", path.display());
67 }
68 Ok(())
69}
70
71pub async fn materialize_file_content(
75 config: &Config,
76 cat: &metadata::AppCategory,
77 file: &metadata::AppFile,
78 env: &BTreeMap<String, String>,
79) -> Result<Vec<u8>> {
80 if let Some(generated) = generator::generate(config, cat, file, env).await? {
81 return apply_file_transforms(file, generated, env);
82 }
83 materialize_static_file_content(config, cat, file, env).await
84}
85
86async fn materialize_static_file_content(
89 config: &Config,
90 cat: &metadata::AppCategory,
91 file: &metadata::AppFile,
92 env: &BTreeMap<String, String>,
93) -> Result<Vec<u8>> {
94 let raw = if config.is_external_presets {
95 let path = config.preset_path(Path::new("app").join(&cat.name).join(&file.source_rel));
96 tokio::fs::read(&path)
97 .await
98 .with_context(|| format!("reading {}", path.display()))?
99 } else {
100 let key = format!("app/{}/{}", cat.name, file.source_rel.display());
101 crate::presets::read_asset_bytes(&key)
102 .with_context(|| format!("embedded source not found: {key}"))?
103 };
104
105 apply_file_transforms(file, raw, env)
106}
107
108fn apply_file_transforms(
109 file: &metadata::AppFile,
110 raw: Vec<u8>,
111 env: &BTreeMap<String, String>,
112) -> Result<Vec<u8>> {
113 if file.transforms.is_empty() {
114 Ok(raw)
115 } else {
116 transforms::apply(&file.transforms, &raw, env)
117 .with_context(|| format!("transform failed: {}", file.transforms.join(", ")))
118 }
119}
120
121pub async fn source_bytes_for_file(
122 config: &Config,
123 cat: &metadata::AppCategory,
124 file: &metadata::AppFile,
125 env: &BTreeMap<String, String>,
126) -> Option<Vec<u8>> {
127 materialize_file_content(config, cat, file, env).await.ok()
128}
129
130pub async fn source_hash_for_file(
131 config: &Config,
132 cat: &metadata::AppCategory,
133 file: &metadata::AppFile,
134 env: &BTreeMap<String, String>,
135) -> Option<u64> {
136 let effective = match materialize_file_content(config, cat, file, env).await {
137 Ok(content) => content,
138 Err(error) => {
139 eprintln!(
140 " {} {}/{}: source unavailable; no changes applied ({error:#})",
141 crate::colors::symbol("!"),
142 cat.name,
143 file.source_rel.display()
144 );
145 return None;
146 }
147 };
148 desired_content_hash(file, &effective).ok()
149}
150
151pub fn desired_content_hash(file: &metadata::AppFile, bytes: &[u8]) -> Result<u64> {
152 match &file.install_strategy {
153 AppInstallStrategy::Copy => Ok(hash_content(bytes)),
154 AppInstallStrategy::JsonMerge { managed_keys } => {
155 json_merge::managed_hash(bytes, managed_keys)
156 }
157 }
158}
159
160pub fn installed_content_hash(file: &metadata::AppFile, bytes: &[u8]) -> Result<Option<u64>> {
161 match &file.install_strategy {
162 AppInstallStrategy::Copy => Ok(Some(hash_content(bytes))),
163 AppInstallStrategy::JsonMerge { managed_keys } => {
164 json_merge::installed_hash(bytes, managed_keys)
165 }
166 }
167}
168
169async fn install_prepared_content(
170 file: &metadata::AppFile,
171 content: &[u8],
172 destination: &Path,
173 is_managed: bool,
174 dry_run: bool,
175 force: bool,
176) -> Result<InstallOutcome> {
177 match &file.install_strategy {
178 AppInstallStrategy::Copy => {
179 if file.requires_admin {
180 file_ops::install_bytes_admin(content, destination, is_managed, dry_run, force)
181 .await
182 } else {
183 file_ops::install_bytes(content, destination, is_managed, dry_run, force).await
184 }
185 }
186 AppInstallStrategy::JsonMerge { managed_keys } => {
187 json_merge::install(content, destination, dry_run, managed_keys).await
188 }
189 }
190}
191
192async fn uninstall_app_entry(
193 entry: &AppEntry,
194 dry_run: bool,
195 force: bool,
196) -> Result<UninstallOutcome> {
197 match &entry.install_strategy {
198 AppInstallStrategy::Copy if entry.requires_admin => {
199 file_ops::uninstall_entry_admin(entry, dry_run, force).await
200 }
201 AppInstallStrategy::Copy => file_ops::uninstall_entry(entry, dry_run, force).await,
202 AppInstallStrategy::JsonMerge { managed_keys } => {
203 json_merge::uninstall(entry, dry_run, force, managed_keys).await
204 }
205 }
206}
207
208fn app_category_from_source(source: &str) -> Option<String> {
209 app_source_parts(source).map(|(category, _)| category.to_string())
210}
211
212fn app_source_parts(source: &str) -> Option<(&str, &str)> {
213 let mut parts = source.splitn(3, '/');
214 match (parts.next(), parts.next(), parts.next()) {
215 (Some("app"), Some(category), Some(file)) => Some((category, file)),
216 _ => None,
217 }
218}
219
220pub fn resolve_install_destination(
221 category: &metadata::AppCategory,
222 file: &metadata::AppFile,
223 config: &Config,
224) -> Result<PathBuf> {
225 if let Some(file_root) = &file.destination_root {
226 let root = match file_root {
227 metadata::AppDestinationRoot::Path(dest_root) => {
228 expand_destination_root(dest_root, config)?
229 }
230 metadata::AppDestinationRoot::DataDir(relative) => {
231 data_dir_for_config(config)?.join(relative)
232 }
233 };
234 return Ok(root.join(&file.target_rel));
235 }
236 if let Some(dest_root) = category.destination_root.as_ref() {
237 let root = expand_destination_root(dest_root, config)?;
238 return Ok(root.join(&file.target_rel));
239 }
240
241 annotation::resolve_destination(
242 file.legacy_dest_annotation.as_deref(),
243 &category.name,
244 &file.target_rel.display().to_string(),
245 config,
246 )
247}
248
249fn expand_destination_root(dest_root: &str, config: &Config) -> Result<PathBuf> {
250 let expanded = crate::config::full_expand_with_home(dest_root, &config.home_dir)
251 .with_context(|| format!("failed to expand destination root: {dest_root}"))?;
252 let root = PathBuf::from(&expanded);
253 if !is_install_destination_root_absolute(&expanded, &root) {
254 anyhow::bail!("destination root must be absolute after expansion");
255 }
256 if root
257 .components()
258 .any(|c| c == std::path::Component::ParentDir)
259 {
260 anyhow::bail!("destination root must not contain '..'");
261 }
262 Ok(root)
263}
264
265fn data_dir_for_config(config: &Config) -> Result<PathBuf> {
266 if config.home_dir == crate::home::effective_home_dir() {
267 return directories::BaseDirs::new()
268 .context("resolving system data directory")
269 .map(|dirs| dirs.data_dir().to_path_buf());
270 }
271 if cfg!(windows) {
272 Ok(config.home_dir.join("AppData/Roaming"))
273 } else if cfg!(target_os = "macos") {
274 Ok(config.home_dir.join("Library/Application Support"))
275 } else {
276 Ok(config.home_dir.join(".local/share"))
277 }
278}
279
280fn validate_unique_install_destinations<'a>(
281 categories: impl IntoIterator<Item = &'a metadata::AppCategory>,
282 config: &Config,
283) -> Result<()> {
284 let mut destinations = BTreeMap::<String, String>::new();
285 for category in categories {
286 for file in &category.files {
287 let destination = resolve_install_destination(category, file, config)?;
288 let mut key = destination.to_string_lossy().into_owned();
289 if cfg!(windows) {
290 key.make_ascii_lowercase();
291 }
292 let source = format!("app/{}/{}", category.name, file.source_rel.display());
293 if let Some(existing) = destinations.insert(key, source.clone()) {
294 anyhow::bail!(
295 "app preset destinations collide: '{existing}' and '{source}' both resolve to {}",
296 destination.display()
297 );
298 }
299 }
300 }
301 Ok(())
302}
303
304#[cfg(windows)]
305fn is_install_destination_root_absolute(_expanded: &str, root: &Path) -> bool {
306 root.is_absolute()
307}
308
309#[cfg(not(windows))]
310fn is_install_destination_root_absolute(expanded: &str, root: &Path) -> bool {
311 root.is_absolute() || expanded.starts_with('/')
312}
313
314#[cfg(test)]
315mod tests {
316 #![allow(clippy::await_holding_lock)]
317 use super::*;
318 use crate::config::Config;
319 #[cfg(unix)]
320 use crate::install_core::manifest::AppManifest;
321 #[cfg(unix)]
322 use crate::test_support::env_lock;
323 use tokio::fs;
324
325 async fn make_temp_dir() -> std::path::PathBuf {
326 crate::test_support::make_temp_dir("shine-apps").await
327 }
328
329 #[cfg(not(target_os = "macos"))]
330 #[tokio::test]
331 async fn surge_runtime_actions_are_unavailable_outside_macos() {
332 let dir = make_temp_dir().await;
333 let config = Config::new_for_test(&dir);
334
335 for error in [
336 info::handle_info(&config, "surge").await.unwrap_err(),
337 build::handle_build(&config, "surge").await.unwrap_err(),
338 refresh::handle_refresh(&config, "surge", None, false)
339 .await
340 .unwrap_err(),
341 install::handle_install(&config, Some("surge"), false, false)
342 .await
343 .unwrap_err(),
344 ] {
345 assert!(
346 error
347 .to_string()
348 .contains("app preset category not found: surge"),
349 "unexpected error: {error:#}"
350 );
351 }
352
353 assert!(
354 !dir.join("Library/Application Support/Surge/Profiles")
355 .exists()
356 );
357 assert!(!dir.join("presets/app/surge").exists());
358 assert!(!dir.join("env.toml").exists());
359 fs::remove_dir_all(&dir).await.unwrap();
360 }
361
362 #[cfg(unix)]
363 async fn write_external_sample_app(dir: &std::path::Path, body: &[u8]) {
364 write_external_sample_app_with_extra(dir, body, None).await;
365 }
366
367 #[cfg(unix)]
368 async fn write_external_sample_app_with_extra(
369 dir: &std::path::Path,
370 body: &[u8],
371 extra_body: Option<&[u8]>,
372 ) {
373 let cat_dir = dir.join("presets/app/sample");
374 fs::create_dir_all(&cat_dir).await.unwrap();
375 let mut manifest = "description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[[files]]\nsource = \"daemon.jsonc\"\ntarget = \"daemon.json\"\ntransforms = [\"template\", \"jsonc-to-json\"]\n".to_string();
376 if extra_body.is_some() {
377 manifest.push_str(
378 "\n[[files]]\nsource = \"theme.conf\"\ntarget = \"themes/theme.conf\"\ntransforms = [\"template\"]\n",
379 );
380 }
381 fs::write(cat_dir.join("shine.toml"), manifest)
382 .await
383 .unwrap();
384 fs::write(cat_dir.join("daemon.jsonc"), body).await.unwrap();
385 if let Some(extra_body) = extra_body {
386 fs::write(cat_dir.join("theme.conf"), extra_body)
387 .await
388 .unwrap();
389 }
390 }
391
392 #[cfg(target_os = "linux")]
393 #[tokio::test]
394 async fn uninstall_remains_manifest_driven_after_category_becomes_macos_only() {
395 let dir = make_temp_dir().await;
396 write_external_sample_app(&dir, b"{\"enabled\":true}\n").await;
397 let mut config = Config::new_for_test(&dir);
398 config.is_external_presets = true;
399
400 install::handle_install(&config, Some("sample"), false, false)
401 .await
402 .unwrap();
403 let destination = dir.join(".config/sample/daemon.json");
404 assert!(destination.exists());
405
406 fs::write(
407 dir.join("presets/app/sample/shine.toml"),
408 b"dest = { macos = \"~/Library/Sample\" }\n[[files]]\nsource = \"daemon.jsonc\"\n",
409 )
410 .await
411 .unwrap();
412 uninstall::handle_uninstall(&config, Some("sample"), false, false, false)
413 .await
414 .unwrap();
415
416 assert!(!destination.exists());
417 assert!(
418 AppManifest::load(config.shine_dir())
419 .await
420 .unwrap()
421 .entries
422 .is_empty()
423 );
424 fs::remove_dir_all(&dir).await.unwrap();
425 }
426
427 #[cfg(unix)]
428 async fn write_external_sample_app_with_post_upgrade(
429 dir: &std::path::Path,
430 body: &[u8],
431 script_path: &std::path::Path,
432 marker_path: &std::path::Path,
433 ) {
434 let cat_dir = dir.join("presets/app/sample");
435 fs::create_dir_all(&cat_dir).await.unwrap();
436 let manifest = format!(
437 "description = \"Sample app\"\ndest = \"~/.config/sample\"\npost_upgrade = {{ command = \"/bin/sh\", args = [\"{}\", \"{}\"] }}\n\n[[files]]\nsource = \"daemon.jsonc\"\ntarget = \"daemon.json\"\ntransforms = [\"template\", \"jsonc-to-json\"]\n",
438 script_path.display(),
439 marker_path.display()
440 );
441 fs::write(cat_dir.join("shine.toml"), manifest)
442 .await
443 .unwrap();
444 fs::write(cat_dir.join("daemon.jsonc"), body).await.unwrap();
445 }
446
447 #[cfg(unix)]
448 async fn write_hook_script(path: &std::path::Path) {
449 fs::write(path, "#!/bin/sh\nprintf x >> \"$1\"\n")
450 .await
451 .unwrap();
452 }
453
454 #[tokio::test]
455 async fn init_template_creates_parseable_app_metadata() {
456 let dir = make_temp_dir().await;
457 let cat_dir = dir.join("presets/app/sample");
458 fs::create_dir_all(&cat_dir).await.unwrap();
459
460 let (path, overwritten) =
461 utils::init_template::write_shine_toml_template(&cat_dir, false, APP_TEMPLATE).unwrap();
462 fs::write(cat_dir.join("config.toml"), b"name = \"sample\"\n")
463 .await
464 .unwrap();
465
466 let config = Config::new_for_test(&dir);
467 let categories = metadata::load_installed_categories(&config, Some("sample"))
468 .await
469 .unwrap();
470
471 assert_eq!(path, cat_dir.join("shine.toml"));
472 assert!(!overwritten);
473 assert_eq!(categories.len(), 1);
474 assert_eq!(
475 categories[0].description.as_deref(),
476 Some("My app configuration.")
477 );
478 assert_eq!(
479 categories[0].destination_root.as_deref(),
480 Some("~/.config/my-app")
481 );
482 assert_eq!(
483 categories[0].files[0].source_rel,
484 PathBuf::from("config.toml")
485 );
486 assert_eq!(
487 categories[0].files[0].target_rel,
488 PathBuf::from("config.toml")
489 );
490
491 fs::remove_dir_all(&dir).await.unwrap();
492 }
493
494 #[tokio::test]
495 async fn init_template_refuses_existing_file_unless_forced() {
496 let dir = make_temp_dir().await;
497 fs::write(dir.join("shine.toml"), b"old").await.unwrap();
498
499 let err =
500 utils::init_template::write_shine_toml_template(&dir, false, APP_TEMPLATE).unwrap_err();
501 assert!(
502 err.to_string().contains("use --force to overwrite"),
503 "unexpected error: {err:#}"
504 );
505 assert_eq!(fs::read(dir.join("shine.toml")).await.unwrap(), b"old");
506
507 let (_path, overwritten) =
508 utils::init_template::write_shine_toml_template(&dir, true, APP_TEMPLATE).unwrap();
509 assert!(overwritten);
510 let content = fs::read_to_string(dir.join("shine.toml")).await.unwrap();
511 assert!(content.contains("dest = \"~/.config/my-app\""));
512
513 fs::remove_dir_all(&dir).await.unwrap();
514 }
515
516 #[cfg(windows)]
517 #[test]
518 fn install_resolves_windows_docker_engine_destination_on_windows() {
519 let dir = std::env::temp_dir().join("shine-apps-win-dest");
520 let config = Config::new_for_test(&dir);
521 let categories = metadata::load_embedded_categories(Some("docker-engine")).unwrap();
522 let docker = categories
523 .iter()
524 .find(|c| c.name == "docker-engine")
525 .unwrap();
526 let file = docker.files.first().unwrap();
527
528 let destination = resolve_install_destination(docker, file, &config).unwrap();
529
530 assert_eq!(destination, dir.join(".docker").join("daemon.json"));
531 }
532
533 #[cfg(unix)]
534 #[test]
535 fn install_accepts_unix_metadata_destination_on_unix() {
536 let dir = std::env::temp_dir().join("shine-apps-unix-dest");
537 let config = Config::new_for_test(&dir);
538 let categories = metadata::load_embedded_categories(Some("docker-engine")).unwrap();
539 let docker = categories
540 .iter()
541 .find(|c| c.name == "docker-engine")
542 .unwrap();
543 let file = docker.files.first().unwrap();
544
545 let destination = resolve_install_destination(docker, file, &config).unwrap();
546
547 assert_eq!(
548 destination,
549 PathBuf::from("/etc/docker").join("daemon.json")
550 );
551 }
552
553 #[test]
554 fn per_file_destination_overrides_category_root() {
555 let dir = std::env::temp_dir().join("shine-apps-file-dest");
556 let config = Config::new_for_test(&dir);
557 let categories = metadata::load_embedded_categories(Some("clash-verge")).unwrap();
558 let clash = categories.first().unwrap();
559 let merge = clash
560 .files
561 .iter()
562 .find(|file| file.source_rel == Path::new("merge.yaml"))
563 .unwrap();
564 let local_rule = clash
565 .files
566 .iter()
567 .find(|file| file.source_rel == Path::new("rules/lan.list"))
568 .unwrap();
569
570 assert_eq!(
571 resolve_install_destination(clash, merge, &config).unwrap(),
572 dir.join(".shine/clash-verge/merge.yaml")
573 );
574 assert_eq!(
575 resolve_install_destination(clash, local_rule, &config).unwrap(),
576 data_dir_for_config(&config)
577 .unwrap()
578 .join("io.github.clash-verge-rev.clash-verge-rev")
579 .join("ruleset/shine-source/lan.list")
580 );
581 }
582
583 #[test]
584 fn duplicate_effective_destinations_are_rejected() {
585 let dir = std::env::temp_dir().join("shine-apps-collision");
586 let config = Config::new_for_test(&dir);
587 let mut category = metadata::load_embedded_categories(Some("clash-verge"))
588 .unwrap()
589 .remove(0);
590 let mut duplicate = category.files[0].clone();
591 duplicate.source_rel = PathBuf::from("duplicate.yaml");
592 category.files.push(duplicate);
593
594 let error = validate_unique_install_destinations([&category], &config).unwrap_err();
595 assert!(error.to_string().contains("destinations collide"));
596 }
597
598 #[cfg(unix)]
599 #[tokio::test(flavor = "current_thread")]
600 async fn upgrade_skips_up_to_date_app_config() {
601 let _guard = env_lock();
602 let dir = make_temp_dir().await;
603 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
605
606 write_external_sample_app(
607 &dir,
608 b"{\n // proxy\n \"proxy\": \"@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@\"\n}\n",
609 )
610 .await;
611 let mut config = Config::new_for_test(&dir);
612 config.is_external_presets = true;
613 fs::create_dir_all(config.shine_dir()).await.unwrap();
614
615 handle_install(&config, Some("sample"), false, false)
616 .await
617 .unwrap();
618 let dest = dir.join(".config/sample/daemon.json");
619 let before = fs::read(&dest).await.unwrap();
620
621 let mut sep = crate::output::SectionSeparator::new();
622 let report = handle_upgrade_installed(&config, false, &mut sep)
623 .await
624 .unwrap();
625
626 assert_eq!(report.updated, 0, "up-to-date app config must not update");
627 assert_eq!(report.skipped, 1, "up-to-date app config should be skipped");
628 assert_eq!(fs::read(&dest).await.unwrap(), before);
629
630 unsafe { std::env::remove_var("HOME") };
632 fs::remove_dir_all(&dir).await.unwrap();
633 }
634
635 #[cfg(unix)]
636 #[tokio::test(flavor = "current_thread")]
637 async fn upgrade_updates_app_config_when_source_changes() {
638 let _guard = env_lock();
639 let dir = make_temp_dir().await;
640 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
642
643 write_external_sample_app(&dir, b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n").await;
644 let mut config = Config::new_for_test(&dir);
645 config.is_external_presets = true;
646 fs::create_dir_all(config.shine_dir()).await.unwrap();
647
648 handle_install(&config, Some("sample"), false, false)
649 .await
650 .unwrap();
651 let dest = dir.join(".config/sample/daemon.json");
652 let before = fs::read(&dest).await.unwrap();
653 let manifest_before = AppManifest::load(config.shine_dir()).await.unwrap();
654 let hash_before = manifest_before.entries[0].content_hash;
655
656 write_external_sample_app(
657 &dir,
658 b"{\n \"proxy\": \"@@PROXY_HOST@@\",\n \"updated\": true\n}\n",
659 )
660 .await;
661 let mut sep = crate::output::SectionSeparator::new();
662 let report = handle_upgrade_installed(&config, false, &mut sep)
663 .await
664 .unwrap();
665
666 assert_eq!(report.updated, 1, "changed source should update");
667 assert_eq!(report.skipped, 0);
668 assert_ne!(fs::read(&dest).await.unwrap(), before);
669 let manifest_after = AppManifest::load(config.shine_dir()).await.unwrap();
670 assert_ne!(manifest_after.entries[0].content_hash, hash_before);
671
672 unsafe { std::env::remove_var("HOME") };
674 fs::remove_dir_all(&dir).await.unwrap();
675 }
676
677 #[cfg(unix)]
678 #[tokio::test(flavor = "current_thread")]
679 async fn targeted_upgrade_does_not_mutate_other_app_categories() {
680 let _guard = env_lock();
681 let dir = make_temp_dir().await;
682 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
684
685 write_external_sample_app(&dir, b"{\n \"proxy\": \"one\"\n}\n").await;
686 let other_dir = dir.join("presets/app/other");
687 fs::create_dir_all(&other_dir).await.unwrap();
688 fs::write(
689 other_dir.join("shine.toml"),
690 "description = \"Other app\"\ndest = \"~/.config/other\"\n\n[[files]]\nsource = \"config.json\"\ntarget = \"config.json\"\n",
691 )
692 .await
693 .unwrap();
694 fs::write(other_dir.join("config.json"), b"{\"value\":1}\n")
695 .await
696 .unwrap();
697
698 let mut config = Config::new_for_test(&dir);
699 config.is_external_presets = true;
700 fs::create_dir_all(config.shine_dir()).await.unwrap();
701 handle_install(&config, Some("sample"), false, false)
702 .await
703 .unwrap();
704 handle_install(&config, Some("other"), false, false)
705 .await
706 .unwrap();
707
708 write_external_sample_app(&dir, b"{\n \"proxy\": \"two\"\n}\n").await;
709 fs::write(other_dir.join("config.json"), b"{\"value\":2}\n")
710 .await
711 .unwrap();
712 let other_dest = dir.join(".config/other/config.json");
713 let other_before = fs::read(&other_dest).await.unwrap();
714
715 let mut sep = crate::output::SectionSeparator::new();
716 let report =
717 handle_upgrade_installed_target(&config, Some("sample"), false, false, &mut sep)
718 .await
719 .unwrap();
720
721 assert_eq!(report.updated, 1);
722 assert_eq!(fs::read(&other_dest).await.unwrap(), other_before);
723
724 unsafe { std::env::remove_var("HOME") };
726 fs::remove_dir_all(&dir).await.unwrap();
727 }
728
729 #[cfg(unix)]
730 #[tokio::test(flavor = "current_thread")]
731 async fn upgrade_runs_post_upgrade_hook_after_file_update_when_allowed() {
732 let _guard = env_lock();
733 let dir = make_temp_dir().await;
734 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
736
737 let script = dir.join("hook.sh");
738 let marker = dir.join("hook-ran");
739 write_hook_script(&script).await;
740 write_external_sample_app_with_post_upgrade(
741 &dir,
742 b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n",
743 &script,
744 &marker,
745 )
746 .await;
747 let mut config = Config::new_for_test(&dir);
748 config.is_external_presets = true;
749 config.allow_app_hooks = true;
750 fs::create_dir_all(config.shine_dir()).await.unwrap();
751
752 handle_install(&config, Some("sample"), false, false)
753 .await
754 .unwrap();
755 assert!(
756 !marker.exists(),
757 "post-upgrade hook must not run during install"
758 );
759 write_external_sample_app_with_post_upgrade(
760 &dir,
761 b"{\n \"proxy\": \"@@PROXY_HOST@@\",\n \"updated\": true\n}\n",
762 &script,
763 &marker,
764 )
765 .await;
766
767 let mut sep = crate::output::SectionSeparator::new();
768 let report = handle_upgrade_installed(&config, false, &mut sep)
769 .await
770 .unwrap();
771
772 assert_eq!(report.updated, 1);
773 assert_eq!(fs::read_to_string(&marker).await.unwrap(), "x");
774
775 unsafe { std::env::remove_var("HOME") };
777 fs::remove_dir_all(&dir).await.unwrap();
778 }
779
780 #[cfg(unix)]
781 #[tokio::test(flavor = "current_thread")]
782 async fn upgrade_does_not_run_post_upgrade_hook_when_unchanged() {
783 let _guard = env_lock();
784 let dir = make_temp_dir().await;
785 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
787
788 let script = dir.join("hook.sh");
789 let marker = dir.join("hook-ran");
790 write_hook_script(&script).await;
791 write_external_sample_app_with_post_upgrade(
792 &dir,
793 b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n",
794 &script,
795 &marker,
796 )
797 .await;
798 let mut config = Config::new_for_test(&dir);
799 config.is_external_presets = true;
800 config.allow_app_hooks = true;
801 fs::create_dir_all(config.shine_dir()).await.unwrap();
802
803 handle_install(&config, Some("sample"), false, false)
804 .await
805 .unwrap();
806 let mut sep = crate::output::SectionSeparator::new();
807 let report = handle_upgrade_installed(&config, false, &mut sep)
808 .await
809 .unwrap();
810
811 assert_eq!(report.updated, 0);
812 assert!(!marker.exists(), "unchanged config must not run hook");
813
814 unsafe { std::env::remove_var("HOME") };
816 fs::remove_dir_all(&dir).await.unwrap();
817 }
818
819 #[cfg(unix)]
820 #[tokio::test(flavor = "current_thread")]
821 async fn external_post_upgrade_hook_is_skipped_without_opt_in() {
822 let _guard = env_lock();
823 let dir = make_temp_dir().await;
824 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
826
827 let script = dir.join("hook.sh");
828 let marker = dir.join("hook-ran");
829 write_hook_script(&script).await;
830 write_external_sample_app_with_post_upgrade(
831 &dir,
832 b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n",
833 &script,
834 &marker,
835 )
836 .await;
837 let mut config = Config::new_for_test(&dir);
838 config.is_external_presets = true;
839 fs::create_dir_all(config.shine_dir()).await.unwrap();
840
841 handle_install(&config, Some("sample"), false, false)
842 .await
843 .unwrap();
844 write_external_sample_app_with_post_upgrade(
845 &dir,
846 b"{\n \"proxy\": \"@@PROXY_HOST@@\",\n \"updated\": true\n}\n",
847 &script,
848 &marker,
849 )
850 .await;
851
852 let mut sep = crate::output::SectionSeparator::new();
853 let report = handle_upgrade_installed(&config, false, &mut sep)
854 .await
855 .unwrap();
856
857 assert_eq!(report.updated, 1);
858 assert!(
859 !marker.exists(),
860 "external hook must be skipped unless allow_app_hooks is enabled"
861 );
862
863 unsafe { std::env::remove_var("HOME") };
865 fs::remove_dir_all(&dir).await.unwrap();
866 }
867
868 #[cfg(unix)]
869 #[tokio::test(flavor = "current_thread")]
870 async fn upgrade_installs_new_app_file_from_installed_category() {
871 let _guard = env_lock();
872 let dir = make_temp_dir().await;
873 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
875
876 write_external_sample_app(&dir, b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n").await;
877 let mut config = Config::new_for_test(&dir);
878 config.is_external_presets = true;
879 fs::create_dir_all(config.shine_dir()).await.unwrap();
880
881 handle_install(&config, Some("sample"), false, false)
882 .await
883 .unwrap();
884 write_external_sample_app_with_extra(
885 &dir,
886 b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n",
887 Some(b"background = @@GHOSTTY_BG_LIGHT@@\n"),
888 )
889 .await;
890
891 let mut sep = crate::output::SectionSeparator::new();
892 let report = handle_upgrade_installed(&config, false, &mut sep)
893 .await
894 .unwrap();
895
896 let new_dest = dir.join(".config/sample/themes/theme.conf");
897 assert_eq!(report.updated, 1, "new app file should be installed");
898 assert_eq!(report.updated_categories, 1);
899 assert_eq!(
900 report.skipped, 1,
901 "existing up-to-date file should be skipped"
902 );
903 assert_eq!(
904 fs::read(&new_dest).await.unwrap(),
905 b"background = \n",
906 "new file should be transformed before install"
907 );
908 let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
909 assert!(
910 manifest.find_by_dest(&new_dest).is_some(),
911 "new app file should be tracked in manifest"
912 );
913
914 unsafe { std::env::remove_var("HOME") };
916 fs::remove_dir_all(&dir).await.unwrap();
917 }
918
919 #[cfg(unix)]
920 #[tokio::test(flavor = "current_thread")]
921 async fn upgrade_skips_new_app_file_when_destination_is_unmanaged() {
922 let _guard = env_lock();
923 let dir = make_temp_dir().await;
924 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
926
927 write_external_sample_app(&dir, b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n").await;
928 let mut config = Config::new_for_test(&dir);
929 config.is_external_presets = true;
930 fs::create_dir_all(config.shine_dir()).await.unwrap();
931
932 handle_install(&config, Some("sample"), false, false)
933 .await
934 .unwrap();
935 write_external_sample_app_with_extra(
936 &dir,
937 b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n",
938 Some(b"background = @@GHOSTTY_BG_LIGHT@@\n"),
939 )
940 .await;
941 let new_dest = dir.join(".config/sample/themes/theme.conf");
942 fs::create_dir_all(new_dest.parent().unwrap())
943 .await
944 .unwrap();
945 fs::write(&new_dest, b"user-owned\n").await.unwrap();
946
947 let mut sep = crate::output::SectionSeparator::new();
948 let report = handle_upgrade_installed(&config, false, &mut sep)
949 .await
950 .unwrap();
951
952 assert_eq!(report.updated, 0, "unmanaged existing file must not update");
953 assert_eq!(
954 report.skipped, 2,
955 "existing managed file and unmanaged new file should be skipped"
956 );
957 assert_eq!(fs::read(&new_dest).await.unwrap(), b"user-owned\n");
958 let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
959 assert!(
960 manifest.find_by_dest(&new_dest).is_none(),
961 "unmanaged destination should not be added to manifest"
962 );
963
964 unsafe { std::env::remove_var("HOME") };
966 fs::remove_dir_all(&dir).await.unwrap();
967 }
968
969 #[cfg(unix)]
970 #[tokio::test(flavor = "current_thread")]
971 async fn upgrade_prune_stale_removes_unmodified_file_and_manifest_entry() {
972 let _guard = env_lock();
973 let dir = make_temp_dir().await;
974 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
976
977 write_external_sample_app(&dir, b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n").await;
978 let mut config = Config::new_for_test(&dir);
979 config.is_external_presets = true;
980 fs::create_dir_all(config.shine_dir()).await.unwrap();
981
982 handle_install(&config, Some("sample"), false, false)
983 .await
984 .unwrap();
985 let dest = dir.join(".config/sample/daemon.json");
986 fs::remove_dir_all(dir.join("presets/app/sample"))
987 .await
988 .unwrap();
989
990 let mut sep = crate::output::SectionSeparator::new();
991 let report = handle_upgrade_installed(&config, true, &mut sep)
992 .await
993 .unwrap();
994
995 assert_eq!(report.updated, 1, "stale cleanup should count as a change");
996 assert_eq!(report.skipped, 0);
997 assert!(!dest.exists(), "unmodified stale file should be removed");
998 let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
999 assert!(
1000 manifest.find_by_dest(&dest).is_none(),
1001 "stale manifest entry should be removed"
1002 );
1003
1004 unsafe { std::env::remove_var("HOME") };
1006 fs::remove_dir_all(&dir).await.unwrap();
1007 }
1008
1009 #[cfg(unix)]
1010 #[tokio::test(flavor = "current_thread")]
1011 async fn upgrade_prune_stale_removes_manifest_entry_when_destination_is_missing() {
1012 let _guard = env_lock();
1013 let dir = make_temp_dir().await;
1014 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1016
1017 write_external_sample_app(&dir, b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n").await;
1018 let mut config = Config::new_for_test(&dir);
1019 config.is_external_presets = true;
1020 fs::create_dir_all(config.shine_dir()).await.unwrap();
1021
1022 handle_install(&config, Some("sample"), false, false)
1023 .await
1024 .unwrap();
1025 let dest = dir.join(".config/sample/daemon.json");
1026 fs::remove_file(&dest).await.unwrap();
1027 fs::remove_dir_all(dir.join("presets/app/sample"))
1028 .await
1029 .unwrap();
1030
1031 let mut sep = crate::output::SectionSeparator::new();
1032 let report = handle_upgrade_installed(&config, true, &mut sep)
1033 .await
1034 .unwrap();
1035
1036 assert_eq!(report.updated, 1, "manifest cleanup should count as change");
1037 assert_eq!(report.skipped, 0);
1038 let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
1039 assert!(
1040 manifest.find_by_dest(&dest).is_none(),
1041 "missing stale destination should be removed from manifest"
1042 );
1043
1044 unsafe { std::env::remove_var("HOME") };
1046 fs::remove_dir_all(&dir).await.unwrap();
1047 }
1048
1049 #[cfg(unix)]
1050 #[tokio::test(flavor = "current_thread")]
1051 async fn upgrade_without_prune_keeps_stale_file_and_manifest_entry() {
1052 let _guard = env_lock();
1053 let dir = make_temp_dir().await;
1054 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1056
1057 write_external_sample_app(&dir, b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n").await;
1058 let mut config = Config::new_for_test(&dir);
1059 config.is_external_presets = true;
1060 fs::create_dir_all(config.shine_dir()).await.unwrap();
1061
1062 handle_install(&config, Some("sample"), false, false)
1063 .await
1064 .unwrap();
1065 let dest = dir.join(".config/sample/daemon.json");
1066 fs::remove_dir_all(dir.join("presets/app/sample"))
1067 .await
1068 .unwrap();
1069
1070 let mut sep = crate::output::SectionSeparator::new();
1071 let report = handle_upgrade_installed(&config, false, &mut sep)
1072 .await
1073 .unwrap();
1074
1075 assert_eq!(report.updated, 0);
1076 assert_eq!(report.skipped, 1);
1077 assert!(dest.exists(), "stale file should be left in place");
1078 let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
1079 assert!(
1080 manifest.find_by_dest(&dest).is_some(),
1081 "stale manifest entry should remain without prune"
1082 );
1083
1084 unsafe { std::env::remove_var("HOME") };
1086 fs::remove_dir_all(&dir).await.unwrap();
1087 }
1088
1089 #[cfg(unix)]
1090 #[tokio::test(flavor = "current_thread")]
1091 async fn upgrade_prune_stale_keeps_user_modified_file() {
1092 let _guard = env_lock();
1093 let dir = make_temp_dir().await;
1094 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1096
1097 write_external_sample_app(&dir, b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n").await;
1098 let mut config = Config::new_for_test(&dir);
1099 config.is_external_presets = true;
1100 fs::create_dir_all(config.shine_dir()).await.unwrap();
1101
1102 handle_install(&config, Some("sample"), false, false)
1103 .await
1104 .unwrap();
1105 let dest = dir.join(".config/sample/daemon.json");
1106 fs::write(&dest, b"{\"user\":true}\n").await.unwrap();
1107 fs::remove_dir_all(dir.join("presets/app/sample"))
1108 .await
1109 .unwrap();
1110
1111 let mut sep = crate::output::SectionSeparator::new();
1112 let report = handle_upgrade_installed(&config, true, &mut sep)
1113 .await
1114 .unwrap();
1115
1116 assert_eq!(report.updated, 0);
1117 assert_eq!(report.skipped, 1);
1118 assert_eq!(report.user_modified, 1);
1119 assert_eq!(fs::read(&dest).await.unwrap(), b"{\"user\":true}\n");
1120 let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
1121 assert!(
1122 manifest.find_by_dest(&dest).is_some(),
1123 "user-modified stale entry should remain tracked"
1124 );
1125
1126 unsafe { std::env::remove_var("HOME") };
1128 fs::remove_dir_all(&dir).await.unwrap();
1129 }
1130
1131 #[cfg(unix)]
1132 #[tokio::test(flavor = "current_thread")]
1133 async fn upgrade_prune_stale_allows_renamed_source_to_reinstall_same_destination() {
1134 let _guard = env_lock();
1135 let dir = make_temp_dir().await;
1136 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1138
1139 write_external_sample_app(&dir, b"{\n \"proxy\": \"old\"\n}\n").await;
1140 let mut config = Config::new_for_test(&dir);
1141 config.is_external_presets = true;
1142 fs::create_dir_all(config.shine_dir()).await.unwrap();
1143
1144 handle_install(&config, Some("sample"), false, false)
1145 .await
1146 .unwrap();
1147 let cat_dir = dir.join("presets/app/sample");
1148 fs::write(
1149 cat_dir.join("shine.toml"),
1150 b"description = \"Sample app\"\ndest = \"~/.config/sample\"\n\n[[files]]\nsource = \"daemon-renamed.jsonc\"\ntarget = \"daemon.json\"\ntransforms = [\"jsonc-to-json\"]\n",
1151 )
1152 .await
1153 .unwrap();
1154 fs::write(
1155 cat_dir.join("daemon-renamed.jsonc"),
1156 b"{\n \"proxy\": \"new\"\n}\n",
1157 )
1158 .await
1159 .unwrap();
1160
1161 let mut sep = crate::output::SectionSeparator::new();
1162 let report = handle_upgrade_installed(&config, true, &mut sep)
1163 .await
1164 .unwrap();
1165
1166 let dest = dir.join(".config/sample/daemon.json");
1167 assert_eq!(
1168 report.updated, 2,
1169 "cleanup plus reinstall should change state"
1170 );
1171 assert_eq!(report.updated_categories, 1);
1172 assert_eq!(report.skipped, 0);
1173 assert_eq!(
1174 fs::read(&dest).await.unwrap(),
1175 b"{\n \"proxy\": \"new\"\n}\n"
1176 );
1177 let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
1178 let entry = manifest.find_by_dest(&dest).unwrap();
1179 assert_eq!(entry.source, "app/sample/daemon-renamed.jsonc");
1180
1181 unsafe { std::env::remove_var("HOME") };
1183 fs::remove_dir_all(&dir).await.unwrap();
1184 }
1185
1186 #[cfg(unix)]
1187 #[tokio::test(flavor = "current_thread")]
1188 async fn upgrade_skips_user_modified_app_config() {
1189 let _guard = env_lock();
1190 let dir = make_temp_dir().await;
1191 unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
1193
1194 write_external_sample_app(&dir, b"{\n \"proxy\": \"@@PROXY_HOST@@\"\n}\n").await;
1195 let mut config = Config::new_for_test(&dir);
1196 config.is_external_presets = true;
1197 fs::create_dir_all(config.shine_dir()).await.unwrap();
1198
1199 handle_install(&config, Some("sample"), false, false)
1200 .await
1201 .unwrap();
1202 let dest = dir.join(".config/sample/daemon.json");
1203 fs::write(&dest, b"{\"user\":true}\n").await.unwrap();
1204
1205 let mut sep = crate::output::SectionSeparator::new();
1206 let report = handle_upgrade_installed(&config, false, &mut sep)
1207 .await
1208 .unwrap();
1209
1210 assert_eq!(
1211 report.updated, 0,
1212 "user-modified app config must not update"
1213 );
1214 assert_eq!(report.skipped, 1);
1215 assert_eq!(fs::read(&dest).await.unwrap(), b"{\"user\":true}\n");
1216
1217 unsafe { std::env::remove_var("HOME") };
1219 fs::remove_dir_all(&dir).await.unwrap();
1220 }
1221}