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