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