Skip to main content

cli/apps/
mod.rs

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