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