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