Skip to main content

cli/apps/
mod.rs

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
63/// Hash the effective install content for `file` — applies transforms if declared.
64///
65/// Returns `None` when the source cannot be read (e.g. not yet extracted).
66pub 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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
410        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
437        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
447        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
479        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
489        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
531        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
541        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
582        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
592        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
621        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
631        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
670        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
680        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
720        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
730        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
770        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
780        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
810        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
820        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
850        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
860        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
890        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
900        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
932        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
942        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
986        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
996        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        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
1022        unsafe { std::env::remove_var("HOME") };
1023        fs::remove_dir_all(&dir).await.unwrap();
1024    }
1025}