Skip to main content

cli/apps/
install.rs

1use super::report::{
2    print_already_managed, print_dry_run_install, print_install_error, print_install_success,
3    print_install_success_with_backup,
4};
5use super::{
6    install_prepared_content, materialize_file_content, materialize_static_file_content, metadata,
7    resolve_install_destination, validate_unique_install_destinations,
8};
9use crate::colors;
10use crate::config::Config;
11use crate::env::EnvConfig;
12use crate::install_core::manifest::{AppEntry, AppManifest};
13use crate::output;
14use anyhow::Result;
15use file_ops::InstallOutcome;
16use std::collections::{BTreeMap, BTreeSet};
17
18use crate::install_core::file_ops;
19
20pub async fn handle_install(
21    config: &Config,
22    category: Option<&str>,
23    dry_run: bool,
24    force: bool,
25) -> Result<()> {
26    crate::config::print_presets_note(config);
27    if dry_run {
28        println!("{}", colors::dim("[dry-run] No files will be modified."));
29    }
30
31    let prefix = match category {
32        Some(cat) => format!("app/{cat}"),
33        None => "app".to_string(),
34    };
35
36    // Resolve platform availability before config initialization or embedded
37    // extraction so a targeted request for an unavailable category has no
38    // lifecycle side effects.
39    let categories = metadata::load_active_categories(config, category).await?;
40    if let Some(category) = category
41        && categories.is_empty()
42    {
43        anyhow::bail!("app preset category not found: {category}");
44    }
45
46    // Load env config once — used by the `template` transform.
47    let env = EnvConfig::load_or_init(config).await?;
48    let env_map = env.as_map();
49
50    // When the user has configured a custom presets directory, the app preset
51    // files are already there — skip the embedded-asset extraction step.
52    if !config.is_external_presets {
53        // Refresh the managed embedded preset cache on each install so metadata
54        // and transformed source updates from the current binary take effect.
55        let _extract_report =
56            crate::presets::extract_prefix(&prefix, config.presets_dir(), true).await?;
57    }
58    validate_unique_install_destinations(&categories, config)?;
59    let total_available: usize = categories.iter().map(|c| c.files.len()).sum();
60    output::summary_line(
61        "App Configs",
62        &[colors::dim(&format!("{total_available} files available"))],
63    );
64
65    let mut manifest = AppManifest::load(config.shine_dir()).await?;
66    let mut generated_content: BTreeMap<String, Vec<u8>> = BTreeMap::new();
67    let mut unavailable_generators: BTreeSet<String> = BTreeSet::new();
68
69    // Run enabled generators before any install writes. A first-time failure
70    // aborts cleanly; an existing managed destination is the last-known-good
71    // snapshot and is kept with a warning.
72    for cat in &categories {
73        for file in &cat.files {
74            if dry_run {
75                continue;
76            }
77            let Some(generator) = &file.generator else {
78                continue;
79            };
80            if !env_map.contains_key(&generator.when_env) {
81                continue;
82            }
83            let key = format!("{}/{}", cat.name, file.source_rel.display());
84            let destination = resolve_install_destination(cat, file, config)?;
85            match materialize_file_content(config, cat, file, env_map).await {
86                Ok(content) => {
87                    generated_content.insert(key, content);
88                }
89                Err(error)
90                    if manifest.find_by_dest(&destination).is_some() && destination.exists() =>
91                {
92                    eprintln!(
93                        "  {} {}/{}: generator unavailable; installed copy kept ({error:#})",
94                        colors::symbol("!"),
95                        cat.name,
96                        file.source_rel.display()
97                    );
98                    unavailable_generators.insert(key);
99                }
100                Err(error) => return Err(error),
101            }
102        }
103    }
104
105    let mut installed = 0usize;
106    let mut skipped = 0usize;
107    let mut backed_up = 0usize;
108    let mut restart_hints = BTreeSet::new();
109    // Categories with at least one file actually written this run — the trigger
110    // set for `post_install` hooks (mirrors `post_upgrade`'s changed-only rule).
111    let mut changed_categories: BTreeSet<String> = BTreeSet::new();
112
113    for cat in &categories {
114        for file in &cat.files {
115            let display_name = format!("{}/{}", cat.name, file.source_rel.display());
116            if unavailable_generators.contains(&display_name) {
117                skipped += 1;
118                continue;
119            }
120            let destination = match resolve_install_destination(cat, file, config) {
121                Ok(d) => d,
122                Err(e) => {
123                    eprintln!(
124                        "  {} {display_name}: bad destination: {e:#}",
125                        colors::symbol("✗")
126                    );
127                    continue;
128                }
129            };
130
131            let is_managed = manifest.find_by_dest(&destination).is_some();
132
133            let file_uses_env =
134                file.transforms.iter().any(|t| t == "template") || file.generator.is_some();
135
136            let content = if let Some(content) = generated_content.remove(&display_name) {
137                content
138            } else {
139                let materialized = if dry_run {
140                    materialize_static_file_content(config, cat, file, env_map).await
141                } else {
142                    materialize_file_content(config, cat, file, env_map).await
143                };
144                match materialized {
145                    Ok(content) => content,
146                    Err(error) => {
147                        eprintln!("  {} {display_name}: {error:#}", colors::symbol_stderr("✗"));
148                        continue;
149                    }
150                }
151            };
152            let outcome =
153                install_prepared_content(file, &content, &destination, is_managed, dry_run, force)
154                    .await;
155
156            let transform_label = if !file.transforms.is_empty() {
157                format!(
158                    "  {}",
159                    colors::dim(&format!("[{}]", file.transforms.join(", ")))
160                )
161            } else {
162                String::new()
163            };
164
165            let file_label = file.source_rel.display().to_string();
166
167            match outcome {
168                Ok(InstallOutcome::Installed { hash }) => {
169                    print_install_success(&file_label, &transform_label, &destination, config);
170                    manifest.upsert(AppEntry {
171                        source: format!("app/{}/{}", cat.name, file.source_rel.display()),
172                        destination,
173                        backup: None,
174                        content_hash: hash,
175                        install_strategy: file.install_strategy.clone(),
176                        uses_env: file_uses_env,
177                        requires_admin: file.requires_admin,
178                    });
179                    installed += 1;
180                    changed_categories.insert(cat.name.clone());
181                    if let Some(hint) = &file.restart_hint {
182                        restart_hints.insert(hint.clone());
183                    }
184                }
185                Ok(InstallOutcome::AlreadyManaged) => {
186                    print_already_managed(&file_label);
187                    skipped += 1;
188                }
189                Ok(InstallOutcome::BackedUpAndInstalled { backup, hash }) => {
190                    print_install_success_with_backup(
191                        &file_label,
192                        &transform_label,
193                        &destination,
194                        &backup,
195                        config,
196                    );
197                    manifest.upsert(AppEntry {
198                        source: format!("app/{}/{}", cat.name, file.source_rel.display()),
199                        destination,
200                        backup: Some(backup),
201                        content_hash: hash,
202                        install_strategy: file.install_strategy.clone(),
203                        uses_env: file_uses_env,
204                        requires_admin: file.requires_admin,
205                    });
206                    installed += 1;
207                    backed_up += 1;
208                    changed_categories.insert(cat.name.clone());
209                    if let Some(hint) = &file.restart_hint {
210                        restart_hints.insert(hint.clone());
211                    }
212                }
213                Ok(InstallOutcome::DryRun) => {
214                    print_dry_run_install(&file_label, &transform_label, &destination, config);
215                    skipped += 1;
216                }
217                Err(e) => {
218                    print_install_error(&display_name, &e);
219                }
220            }
221        }
222    }
223
224    if !dry_run {
225        manifest.save(config.shine_dir()).await?;
226        super::hooks::run_app_hooks(
227            config,
228            |name| categories.iter().find(|c| c.name == name),
229            &changed_categories,
230            super::hooks::HookPhase::PostInstall,
231            true,
232        )
233        .await;
234    }
235
236    let mut summary_parts: Vec<String> = Vec::new();
237    if installed > 0 {
238        let backup_note = if backed_up > 0 {
239            format!(", {backed_up} backed up")
240        } else {
241            String::new()
242        };
243        summary_parts.push(colors::green(&format!(
244            "{installed} installed{backup_note}"
245        )));
246    }
247    if skipped > 0 {
248        summary_parts.push(colors::dim(&format!("{skipped} skipped")));
249    }
250    output::footer("Done", &summary_parts);
251    for hint in restart_hints {
252        println!("  {} {}", colors::symbol("!"), colors::yellow(&hint));
253    }
254
255    Ok(())
256}
257
258#[cfg(test)]
259mod tests {
260    #![allow(clippy::await_holding_lock)]
261    use super::super::uninstall::handle_uninstall;
262    use super::*;
263    use crate::config::Config;
264    use crate::install_core::manifest::AppManifest;
265    #[cfg(unix)]
266    use crate::presets;
267    #[cfg(unix)]
268    use crate::test_support::env_lock;
269    use tokio::fs;
270
271    async fn make_temp_dir() -> std::path::PathBuf {
272        crate::test_support::make_temp_dir("shine-apps").await
273    }
274
275    #[cfg(unix)]
276    #[tokio::test(flavor = "current_thread")]
277    async fn install_then_uninstall_roundtrip() {
278        let _admin_guard = crate::test_support::admin_category_test_lock().await;
279        let _guard = env_lock();
280        let dir = make_temp_dir().await;
281
282        // Point HOME at the temp dir so ~ expands there
283        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
284        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
285
286        let config = Config::new_for_test(&dir);
287        fs::create_dir_all(config.presets_dir()).await.unwrap();
288        fs::create_dir_all(config.shine_dir()).await.unwrap();
289
290        handle_install(&config, None, false, false).await.unwrap();
291
292        // At least the manifest should have entries
293        let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
294        assert!(
295            !manifest.entries.is_empty(),
296            "manifest should have entries after install"
297        );
298
299        // Each installed file should exist
300        for entry in &manifest.entries {
301            assert!(
302                entry.destination.exists(),
303                "installed file should exist: {}",
304                entry.destination.display()
305            );
306        }
307
308        handle_uninstall(&config, None, false, false, false)
309            .await
310            .unwrap();
311
312        let manifest_after = AppManifest::load(config.shine_dir()).await.unwrap();
313        assert!(
314            manifest_after.entries.is_empty(),
315            "manifest should be empty after uninstall"
316        );
317
318        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
319        unsafe { std::env::remove_var("HOME") };
320        fs::remove_dir_all(&dir).await.unwrap();
321    }
322
323    #[cfg(unix)]
324    #[tokio::test(flavor = "current_thread")]
325    async fn install_is_idempotent() {
326        let _admin_guard = crate::test_support::admin_category_test_lock().await;
327        let _guard = env_lock();
328        let dir = make_temp_dir().await;
329        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
330        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
331
332        let config = Config::new_for_test(&dir);
333        fs::create_dir_all(config.presets_dir()).await.unwrap();
334        fs::create_dir_all(config.shine_dir()).await.unwrap();
335
336        handle_install(&config, None, false, false).await.unwrap();
337        let manifest_first = AppManifest::load(config.shine_dir()).await.unwrap();
338        let count_first = manifest_first.entries.len();
339
340        handle_install(&config, None, false, false).await.unwrap();
341        let manifest_second = AppManifest::load(config.shine_dir()).await.unwrap();
342
343        assert_eq!(
344            manifest_second.entries.len(),
345            count_first,
346            "re-install must not duplicate manifest entries"
347        );
348
349        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
350        unsafe { std::env::remove_var("HOME") };
351        fs::remove_dir_all(&dir).await.unwrap();
352    }
353
354    #[cfg(unix)]
355    #[tokio::test(flavor = "current_thread")]
356    async fn post_install_hook_runs_only_when_a_file_changes() {
357        let dir = make_temp_dir().await;
358        let dest_root = dir.join("dest").to_string_lossy().replace('\\', "/");
359        let marker = dir.join("post-install-ran");
360        let category_dir = dir.join("presets/app/hooktest");
361        fs::create_dir_all(&category_dir).await.unwrap();
362        fs::write(
363            category_dir.join("shine.toml"),
364            format!(
365                "description = \"hook test\"\n\
366dest = \"{dest_root}\"\n\
367post_install = {{ command = \"/bin/sh\", args = [\"-c\", \"touch {marker}\"] }}\n\n\
368[[files]]\n\
369source = \"file.conf\"\n",
370                marker = marker.display()
371            ),
372        )
373        .await
374        .unwrap();
375        fs::write(category_dir.join("file.conf"), b"hello\n")
376            .await
377            .unwrap();
378
379        let mut config = Config::new_for_test(&dir);
380        config.is_external_presets = true;
381        config.allow_app_hooks = true;
382        fs::create_dir_all(config.shine_dir()).await.unwrap();
383
384        // First install writes the file → post_install fires.
385        handle_install(&config, Some("hooktest"), false, false)
386            .await
387            .unwrap();
388        assert!(marker.exists(), "post_install must run on first install");
389
390        // Second install changes nothing → hook must not fire again.
391        fs::remove_file(&marker).await.unwrap();
392        handle_install(&config, Some("hooktest"), false, false)
393            .await
394            .unwrap();
395        assert!(
396            !marker.exists(),
397            "post_install must not run when no file changed"
398        );
399
400        // Replacement install (force) rewrites the file → post_install fires again.
401        handle_install(&config, Some("hooktest"), false, true)
402            .await
403            .unwrap();
404        assert!(
405            marker.exists(),
406            "post_install must run on replacement install"
407        );
408
409        fs::remove_dir_all(&dir).await.unwrap();
410    }
411
412    #[cfg(unix)]
413    #[tokio::test]
414    async fn install_dry_run_uses_generator_fallback_without_executing_code() {
415        use std::os::unix::fs::PermissionsExt;
416
417        let dir = make_temp_dir().await;
418        let destination = dir.join("destination");
419        let marker = dir.join("generator-ran");
420        let category = dir.join("presets/app/generated");
421        fs::create_dir_all(&category).await.unwrap();
422        fs::write(
423            category.join("shine.toml"),
424            format!(
425                "dest = {:?}\n[[files]]\nsource = \"fallback.txt\"\ngenerator = {{ script = \"generate.sh\", env = [\"RUN\"], when_env = \"RUN\" }}\n",
426                destination.to_string_lossy()
427            ),
428        )
429        .await
430        .unwrap();
431        fs::write(category.join("fallback.txt"), b"fallback\n")
432            .await
433            .unwrap();
434        let generator = category.join("generate.sh");
435        fs::write(
436            &generator,
437            format!("#!/bin/sh\ntouch {:?}\necho generated\n", marker),
438        )
439        .await
440        .unwrap();
441        let mut permissions = fs::metadata(&generator).await.unwrap().permissions();
442        permissions.set_mode(0o755);
443        fs::set_permissions(&generator, permissions).await.unwrap();
444
445        let mut config = Config::new_for_test(&dir);
446        config.is_external_presets = true;
447        config.env.insert("RUN".to_string(), "yes".to_string());
448        handle_install(&config, Some("generated"), true, false)
449            .await
450            .unwrap();
451
452        assert!(!marker.exists());
453        assert!(!destination.exists());
454        fs::remove_dir_all(&dir).await.unwrap();
455    }
456
457    #[test]
458    fn install_missing_category_errors() {
459        let dir = std::env::temp_dir().join("shine-apps-missing-category");
460        let config = Config::new_for_test(&dir);
461
462        let err = tokio::runtime::Builder::new_current_thread()
463            .enable_all()
464            .build()
465            .unwrap()
466            .block_on(handle_install(&config, Some("docker"), true, false))
467            .unwrap_err();
468
469        assert!(
470            err.to_string()
471                .contains("app preset category not found: docker")
472        );
473    }
474
475    #[cfg(windows)]
476    #[tokio::test(flavor = "current_thread")]
477    async fn docker_desktop_install_and_uninstall_only_manage_proxy_keys() {
478        let dir = make_temp_dir().await;
479        let dest_root = dir
480            .join("desktop-settings")
481            .to_string_lossy()
482            .replace('\\', "/");
483        let category_dir = dir.join("presets/app/docker-desktop-test");
484        fs::create_dir_all(&category_dir).await.unwrap();
485        fs::write(
486            category_dir.join("shine.toml"),
487            format!(
488                "description = \"Docker Desktop proxy settings\"\n\
489dest = \"{dest_root}\"\n\n\
490[[files]]\n\
491source = \"settings-store.jsonc\"\n\
492target = \"settings-store.json\"\n\
493transforms = [\"template\", \"jsonc-to-json\"]\n\
494install_mode = \"json-merge\"\n\
495managed_keys = [\"proxy\", \"containersProxy\"]\n"
496            ),
497        )
498        .await
499        .unwrap();
500        fs::write(
501            category_dir.join("settings-store.jsonc"),
502            br#"{
503  "proxy": {
504    "mode": "manual",
505    "http": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@",
506    "https": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@"
507  },
508  "containersProxy": {
509    "mode": "manual",
510    "http": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@",
511    "https": "http://@@PROXY_HOST@@:@@HTTP_PROXY_PORT@@"
512  }
513}"#,
514        )
515        .await
516        .unwrap();
517
518        let mut config = Config::new_for_test(&dir);
519        config.is_external_presets = true;
520        fs::create_dir_all(config.shine_dir()).await.unwrap();
521
522        let destination = dir.join("desktop-settings").join("settings-store.json");
523        fs::create_dir_all(destination.parent().unwrap())
524            .await
525            .unwrap();
526        fs::write(
527            &destination,
528            br#"{
529  "theme": "dark",
530  "analyticsEnabled": true
531}"#,
532        )
533        .await
534        .unwrap();
535
536        handle_install(&config, Some("docker-desktop-test"), false, false)
537            .await
538            .unwrap();
539
540        let mut installed: serde_json::Value =
541            serde_json::from_slice(&fs::read(&destination).await.unwrap()).unwrap();
542        assert_eq!(installed["theme"], serde_json::json!("dark"));
543        assert_eq!(installed["analyticsEnabled"], serde_json::json!(true));
544        assert_eq!(installed["proxy"]["mode"], serde_json::json!("manual"));
545        assert_eq!(
546            installed["containersProxy"]["mode"],
547            serde_json::json!("manual")
548        );
549
550        installed["theme"] = serde_json::json!("light");
551        fs::write(&destination, serde_json::to_vec_pretty(&installed).unwrap())
552            .await
553            .unwrap();
554
555        handle_uninstall(&config, Some("docker-desktop-test"), false, false, false)
556            .await
557            .unwrap();
558
559        let removed: serde_json::Value =
560            serde_json::from_slice(&fs::read(&destination).await.unwrap()).unwrap();
561        assert_eq!(
562            removed,
563            serde_json::json!({
564                "analyticsEnabled": true,
565                "theme": "light"
566            })
567        );
568
569        let manifest = AppManifest::load(config.shine_dir()).await.unwrap();
570        assert!(
571            manifest.entries.is_empty(),
572            "docker-desktop uninstall should clear manifest entries"
573        );
574
575        fs::remove_dir_all(&dir).await.unwrap();
576    }
577
578    #[cfg(unix)]
579    #[tokio::test(flavor = "current_thread")]
580    async fn install_places_vim_under_directory_root() {
581        let _guard = env_lock();
582        let dir = make_temp_dir().await;
583        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
584        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
585
586        let config = Config::new_for_test(&dir);
587        fs::create_dir_all(config.presets_dir()).await.unwrap();
588        fs::create_dir_all(config.shine_dir()).await.unwrap();
589        presets::extract_prefix("app/vim", config.presets_dir(), false)
590            .await
591            .unwrap();
592
593        let categories = metadata::load_installed_categories(&config, Some("vim"))
594            .await
595            .unwrap();
596        let vim = categories.iter().find(|c| c.name == "vim").unwrap();
597        let vimrc = vim
598            .files
599            .iter()
600            .find(|f| f.source_rel == std::path::Path::new("vimrc"))
601            .unwrap();
602        let destination = resolve_install_destination(vim, vimrc, &config).unwrap();
603        assert_eq!(destination, dir.join(".vim").join("vimrc"));
604
605        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
606        unsafe { std::env::remove_var("HOME") };
607        fs::remove_dir_all(&dir).await.unwrap();
608    }
609
610    #[cfg(unix)]
611    #[tokio::test(flavor = "current_thread")]
612    async fn install_places_ghostty_config_under_config_root() {
613        let _guard = env_lock();
614        let dir = make_temp_dir().await;
615        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
616        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
617
618        let config = Config::new_for_test(&dir);
619        fs::create_dir_all(config.presets_dir()).await.unwrap();
620        fs::create_dir_all(config.shine_dir()).await.unwrap();
621        presets::extract_prefix("app/ghostty", config.presets_dir(), false)
622            .await
623            .unwrap();
624
625        let categories = metadata::load_installed_categories(&config, Some("ghostty"))
626            .await
627            .unwrap();
628        let ghostty = categories.iter().find(|c| c.name == "ghostty").unwrap();
629        let config_file = ghostty
630            .files
631            .iter()
632            .find(|f| f.source_rel == std::path::Path::new("config.ghostty"))
633            .unwrap();
634        let destination = resolve_install_destination(ghostty, config_file, &config).unwrap();
635        assert_eq!(
636            destination,
637            dir.join(".config/ghostty").join("config.ghostty")
638        );
639
640        let light_theme = ghostty
641            .files
642            .iter()
643            .find(|f| f.source_rel == std::path::Path::new("themes/iTerm2 Solarized Light"))
644            .unwrap();
645        let light_destination = resolve_install_destination(ghostty, light_theme, &config).unwrap();
646        assert_eq!(
647            light_destination,
648            dir.join(".config/ghostty")
649                .join("themes/light_iTerm2 Solarized Light")
650        );
651
652        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
653        unsafe { std::env::remove_var("HOME") };
654        fs::remove_dir_all(&dir).await.unwrap();
655    }
656
657    #[cfg(unix)]
658    #[tokio::test(flavor = "current_thread")]
659    async fn install_renders_ghostty_light_and_dark_background_images() {
660        let _guard = env_lock();
661        let dir = make_temp_dir().await;
662        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
663        unsafe { std::env::set_var("HOME", dir.to_str().unwrap()) };
664
665        let mut config = Config::new_for_test(&dir);
666        config.env.insert(
667            "GHOSTTY_BG_LIGHT".into(),
668            "/tmp/shine-light-wallpaper.png".into(),
669        );
670        config.env.insert(
671            "GHOSTTY_BG_DARK".into(),
672            "/tmp/shine-dark-wallpaper.png".into(),
673        );
674        fs::create_dir_all(config.presets_dir()).await.unwrap();
675        fs::create_dir_all(config.shine_dir()).await.unwrap();
676
677        handle_install(&config, Some("ghostty"), false, false)
678            .await
679            .unwrap();
680
681        let config_text = fs::read_to_string(dir.join(".config/ghostty/config.ghostty"))
682            .await
683            .unwrap();
684        assert!(config_text.contains("theme = light:Shine Light,dark:dark_Alien Blood"));
685
686        let default_light_theme =
687            fs::read_to_string(dir.join(".config/ghostty/themes/Shine Light"))
688                .await
689                .unwrap();
690        assert!(default_light_theme.contains("background-image = /tmp/shine-light-wallpaper.png"));
691
692        let light_theme =
693            fs::read_to_string(dir.join(".config/ghostty/themes/light_Github Light Default"))
694                .await
695                .unwrap();
696        assert!(light_theme.contains("background = #ffffff"));
697        assert!(light_theme.contains("palette = 4=#0969da"));
698        assert!(light_theme.contains("cursor-color = #0969da"));
699        assert!(light_theme.contains("background-image = /tmp/shine-light-wallpaper.png"));
700
701        let dark_theme = fs::read_to_string(dir.join(".config/ghostty/themes/dark_Alien Blood"))
702            .await
703            .unwrap();
704        assert!(dark_theme.contains("background = #0f1610"));
705        assert!(dark_theme.contains("palette = 10=#18e000"));
706        assert!(dark_theme.contains("cursor-color = #73fa91"));
707        assert!(dark_theme.contains("background-image = /tmp/shine-dark-wallpaper.png"));
708
709        // SAFETY: `_guard` holds `env_lock()`, serialising HOME mutations across test threads.
710        unsafe { std::env::remove_var("HOME") };
711        fs::remove_dir_all(&dir).await.unwrap();
712    }
713}