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