1use anyhow::Result;
2use dialoguer::Confirm;
3use std::collections::{BTreeMap, BTreeSet};
4use std::io::IsTerminal;
5use std::path::{Path, PathBuf};
6
7use crate::colors;
8use crate::config::Config;
9use crate::env::EnvConfig;
10use crate::output;
11use crate::path_display;
12
13use super::file_ops::{InstallOutcome, UninstallOutcome};
14use super::manifest::{AppEntry, AppManifest};
15use super::metadata;
16use super::report::{
17 print_install_error, print_install_success, print_stale_not_found, print_stale_removed,
18};
19use super::{
20 app_category_from_source, app_source_parts, desired_content_hash, install_prepared_content,
21 installed_content_hash, resolve_install_destination, uninstall_app_entry,
22 validate_unique_install_destinations,
23};
24
25#[derive(Debug, Default)]
26pub struct AppUpgradeReport {
27 pub updated: usize,
29 pub updated_categories: usize,
31 pub skipped: usize,
32 pub failed: usize,
33 pub user_modified: usize,
34 pub restart_hints: BTreeSet<String>,
35}
36
37struct UpgradeSection<'a> {
38 sep: &'a mut crate::output::SectionSeparator,
39 verbose: bool,
40 installed_count: usize,
41 started: bool,
42}
43
44impl<'a> UpgradeSection<'a> {
45 fn new(
46 sep: &'a mut crate::output::SectionSeparator,
47 verbose: bool,
48 installed_count: usize,
49 ) -> Self {
50 Self {
51 sep,
52 verbose,
53 installed_count,
54 started: false,
55 }
56 }
57
58 fn begin(&mut self) {
59 if self.started {
60 return;
61 }
62 self.sep.begin();
63 if self.verbose {
64 output::summary_line(
65 "App Configs",
66 &[colors::dim(&format!(
67 "{} installed file(s)",
68 self.installed_count
69 ))],
70 );
71 } else {
72 println!("{}", colors::bold("App Configs"));
73 }
74 self.started = true;
75 }
76
77 fn print_up_to_date(&mut self, source: &str) {
78 if self.verbose {
79 self.begin();
80 println!(" {} {source}: up to date", colors::symbol("✓"));
81 }
82 }
83
84 fn print_manual_refresh(&mut self, source: &str, category: &str, file: &str) {
85 if self.verbose {
86 self.begin();
87 println!(
88 " {} {source}: manual refresh only (shine app refresh {category} {file})",
89 colors::symbol("•")
90 );
91 }
92 }
93
94 fn print_file_updated(&mut self, display_name: &str, destination: &Path, config: &Config) {
95 if self.verbose {
96 self.begin();
97 print_install_success(display_name, "", destination, config);
98 }
99 }
100
101 fn print_category_updates(&mut self, updated_files: &BTreeMap<String, usize>) {
102 if self.verbose || updated_files.is_empty() {
103 return;
104 }
105 self.begin();
106 for (category, count) in updated_files {
107 let noun = if *count == 1 { "file" } else { "files" };
108 println!(
109 " {} {category} {}",
110 colors::symbol("✓"),
111 colors::dim(&format!("{count} {noun} updated"))
112 );
113 }
114 }
115}
116
117pub async fn handle_upgrade_installed(
118 config: &Config,
119 prune_stale: bool,
120 sep: &mut crate::output::SectionSeparator,
121) -> Result<AppUpgradeReport> {
122 handle_upgrade_installed_with_output(config, prune_stale, false, sep).await
123}
124
125pub(crate) async fn handle_upgrade_installed_with_output(
126 config: &Config,
127 prune_stale: bool,
128 verbose: bool,
129 sep: &mut crate::output::SectionSeparator,
130) -> Result<AppUpgradeReport> {
131 handle_upgrade_installed_target(config, None, prune_stale, verbose, sep).await
132}
133
134pub(crate) async fn handle_upgrade_installed_target(
135 config: &Config,
136 category_filter: Option<&str>,
137 prune_stale: bool,
138 verbose: bool,
139 sep: &mut crate::output::SectionSeparator,
140) -> Result<AppUpgradeReport> {
141 let mut manifest = AppManifest::load(config.shine_dir()).await?;
142 if manifest.entries.is_empty() {
143 return Ok(AppUpgradeReport::default());
144 }
145
146 let selected_entries = manifest
147 .entries
148 .iter()
149 .filter(|entry| {
150 category_filter.is_none_or(|filter| {
151 app_category_from_source(&entry.source).as_deref() == Some(filter)
152 })
153 })
154 .collect::<Vec<_>>();
155 if let Some(category) = category_filter
156 && selected_entries.is_empty()
157 {
158 anyhow::bail!("app preset is not installed: {category}");
159 }
160
161 let env = EnvConfig::load_or_init(config).await?;
162 let env_map = env.as_map();
163 let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
164 let installed_categories: BTreeSet<String> = selected_entries
165 .iter()
166 .filter_map(|entry| app_category_from_source(&entry.source))
167 .collect();
168
169 if !config.is_external_presets {
170 for category in &installed_categories {
171 let prefix = format!("app/{category}");
172 let _ = crate::presets::extract_prefix(&prefix, config.presets_dir(), true).await?;
173 }
174 }
175
176 let mut categories_by_name: BTreeMap<String, metadata::AppCategory> = BTreeMap::new();
177 for cat_name in &installed_categories {
178 if config.is_external_presets
179 && !config.preset_path(Path::new("app").join(cat_name)).exists()
180 {
181 continue;
182 }
183 let categories = metadata::load_active_categories(config, Some(cat_name)).await?;
184 if let Some(cat) = categories.into_iter().find(|cat| cat.name == *cat_name) {
185 categories_by_name.insert(cat_name.clone(), cat);
186 }
187 }
188 validate_unique_install_destinations(categories_by_name.values(), config)?;
189
190 let mut section = UpgradeSection::new(sep, verbose, selected_entries.len());
191 if verbose {
192 section.begin();
193 }
194
195 let mut updated = 0usize;
196 let mut skipped = 0usize;
197 let mut failed = 0usize;
198 let mut user_modified = 0usize;
199 let mut pending_upserts: Vec<AppEntry> = Vec::new();
200 let mut restart_hints = BTreeSet::new();
201 let mut pending_removals: Vec<PathBuf> = Vec::new();
202 let mut updated_categories = BTreeSet::new();
203 let mut updated_files_by_category = BTreeMap::<String, usize>::new();
204
205 for entry in selected_entries {
206 let Some((cat_name, file_rel)) = app_source_parts(&entry.source) else {
207 section.begin();
208 eprintln!(
209 " {} {}: invalid source, skipped",
210 colors::symbol("!"),
211 entry.source
212 );
213 skipped += 1;
214 continue;
215 };
216
217 let Some(cat) = categories_by_name.get(cat_name) else {
218 section.begin();
219 let previous_updated = updated;
220 handle_stale_entry(
221 config,
222 entry,
223 prune_stale,
224 interactive,
225 &mut StaleEntryCounters {
226 pending_removals: &mut pending_removals,
227 updated: &mut updated,
228 user_modified: &mut user_modified,
229 skipped: &mut skipped,
230 },
231 )
232 .await?;
233 if updated > previous_updated {
234 updated_categories.insert(cat_name.to_string());
235 *updated_files_by_category
236 .entry(cat_name.to_string())
237 .or_default() += updated - previous_updated;
238 }
239 continue;
240 };
241 let Some(file) = cat
242 .files
243 .iter()
244 .find(|file| file.source_rel.to_string_lossy().as_ref() == file_rel)
245 else {
246 section.begin();
247 let previous_updated = updated;
248 handle_stale_entry(
249 config,
250 entry,
251 prune_stale,
252 interactive,
253 &mut StaleEntryCounters {
254 pending_removals: &mut pending_removals,
255 updated: &mut updated,
256 user_modified: &mut user_modified,
257 skipped: &mut skipped,
258 },
259 )
260 .await?;
261 if updated > previous_updated {
262 updated_categories.insert(cat_name.to_string());
263 *updated_files_by_category
264 .entry(cat_name.to_string())
265 .or_default() += updated - previous_updated;
266 }
267 continue;
268 };
269
270 if file
271 .generator
272 .as_ref()
273 .is_some_and(|generator| !generator.auto)
274 {
275 section.print_manual_refresh(&entry.source, cat_name, file_rel);
276 skipped += 1;
277 continue;
278 }
279
280 match try_upgrade_entry(config, &manifest, entry, cat, file, env_map, &mut section).await {
281 EntryUpgradeResult::Updated(new_entry) => {
282 updated_categories.insert(cat.name.clone());
283 *updated_files_by_category
284 .entry(cat.name.clone())
285 .or_default() += 1;
286 pending_upserts.push(new_entry);
287 updated += 1;
288 if let Some(hint) = &file.restart_hint {
289 restart_hints.insert(hint.clone());
290 }
291 }
292 EntryUpgradeResult::UserModified => {
293 user_modified += 1;
294 skipped += 1;
295 }
296 EntryUpgradeResult::Skipped => {
297 section.print_up_to_date(&entry.source);
298 skipped += 1;
299 }
300 EntryUpgradeResult::Failed => {
301 skipped += 1;
302 }
303 EntryUpgradeResult::FatalGenerator => {
304 failed += 1;
305 }
306 }
307 }
308
309 for destination in pending_removals {
310 manifest.remove_by_dest(&destination);
311 }
312
313 let (new_updated, new_skipped, new_failed, new_upserts, new_restart_hints) =
314 install_new_category_files(
315 config,
316 &categories_by_name,
317 &manifest,
318 env_map,
319 &mut section,
320 )
321 .await?;
322 updated += new_updated;
323 skipped += new_skipped;
324 for entry in &new_upserts {
325 if let Some(category) = app_category_from_source(&entry.source) {
326 updated_categories.insert(category.to_string());
327 *updated_files_by_category
328 .entry(category.to_string())
329 .or_default() += 1;
330 }
331 }
332 pending_upserts.extend(new_upserts);
333 restart_hints.extend(new_restart_hints);
334
335 for upsert in pending_upserts {
336 manifest.upsert(upsert);
337 }
338 manifest.save(config.shine_dir()).await?;
339
340 section.print_category_updates(&updated_files_by_category);
341
342 super::hooks::run_app_hooks(
343 config,
344 |name| categories_by_name.get(name),
345 &updated_categories,
346 super::hooks::HookPhase::PostUpgrade,
347 verbose,
348 )
349 .await;
350
351 Ok(AppUpgradeReport {
352 updated,
353 updated_categories: updated_categories.len(),
354 skipped,
355 failed: failed + new_failed,
356 user_modified,
357 restart_hints,
358 })
359}
360
361enum EntryUpgradeResult {
362 Updated(AppEntry),
363 UserModified,
364 Skipped,
365 Failed,
366 FatalGenerator,
367}
368
369async fn try_upgrade_entry(
370 config: &Config,
371 manifest: &AppManifest,
372 entry: &AppEntry,
373 cat: &metadata::AppCategory,
374 file: &metadata::AppFile,
375 env_map: &BTreeMap<String, String>,
376 section: &mut UpgradeSection<'_>,
377) -> EntryUpgradeResult {
378 let desired_destination = match resolve_install_destination(cat, file, config) {
379 Ok(destination) => destination,
380 Err(error) => {
381 section.begin();
382 print_install_error(&entry.source, &error);
383 return EntryUpgradeResult::Failed;
384 }
385 };
386
387 if desired_destination != entry.destination {
388 return relocate_upgrade_entry(
389 config,
390 manifest,
391 entry,
392 cat,
393 file,
394 env_map,
395 desired_destination,
396 section,
397 )
398 .await;
399 }
400
401 let content = match upgrade_file_content(config, cat, file, env_map).await {
402 Ok(c) => c,
403 Err(e) => {
404 section.begin();
405 print_install_error(&entry.source, &e);
406 return if file
407 .generator
408 .as_ref()
409 .is_some_and(|generator| env_map.contains_key(&generator.when_env))
410 && !entry.destination.exists()
411 {
412 EntryUpgradeResult::FatalGenerator
413 } else {
414 EntryUpgradeResult::Failed
415 };
416 }
417 };
418
419 let new_hash = match desired_content_hash(file, &content) {
420 Ok(h) => h,
421 Err(e) => {
422 section.begin();
423 print_install_error(&entry.source, &e);
424 return EntryUpgradeResult::Failed;
425 }
426 };
427
428 match tokio::fs::read(&entry.destination).await {
429 Ok(current) => {
430 let current_hash = match installed_content_hash(file, ¤t) {
431 Ok(Some(h)) => h,
432 Ok(None) => {
433 section.begin();
434 eprintln!(
435 " {} {}: managed keys missing, skipped",
436 colors::symbol("!"),
437 entry.source
438 );
439 return EntryUpgradeResult::UserModified;
440 }
441 Err(e) => {
442 section.begin();
443 print_install_error(&entry.source, &e);
444 return EntryUpgradeResult::Failed;
445 }
446 };
447 if current_hash != entry.content_hash {
448 section.begin();
449 eprintln!(
450 " {} {}: user-modified, skipped",
451 colors::symbol("!"),
452 entry.source
453 );
454 return EntryUpgradeResult::UserModified;
455 }
456 if new_hash == entry.content_hash {
457 return EntryUpgradeResult::Skipped;
458 }
459 }
460 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
461 Err(e) => {
462 section.begin();
463 print_install_error(&entry.source, &anyhow::Error::from(e));
464 return EntryUpgradeResult::Failed;
465 }
466 }
467
468 match install_prepared_content(file, &content, &entry.destination, true, false, true).await {
469 Ok(InstallOutcome::Installed { hash })
470 | Ok(InstallOutcome::BackedUpAndInstalled { hash, .. }) => {
471 let display_name = file
472 .display_name
473 .as_deref()
474 .map(|s| s.to_string())
475 .unwrap_or_else(|| format!("{}/{}", cat.name, file.source_rel.display()));
476 section.print_file_updated(&display_name, &entry.destination, config);
477 EntryUpgradeResult::Updated(AppEntry {
478 source: entry.source.clone(),
479 destination: entry.destination.clone(),
480 backup: entry.backup.clone(),
481 content_hash: hash,
482 install_strategy: file.install_strategy.clone(),
483 uses_env: file.transforms.iter().any(|t| t == "template"),
484 requires_admin: file.requires_admin,
485 })
486 }
487 Ok(InstallOutcome::AlreadyManaged) | Ok(InstallOutcome::DryRun) => {
488 EntryUpgradeResult::Skipped
489 }
490 Err(e) => {
491 section.begin();
492 print_install_error(&entry.source, &e);
493 EntryUpgradeResult::Failed
494 }
495 }
496}
497
498#[allow(clippy::too_many_arguments)]
499async fn relocate_upgrade_entry(
500 config: &Config,
501 manifest: &AppManifest,
502 entry: &AppEntry,
503 cat: &metadata::AppCategory,
504 file: &metadata::AppFile,
505 env_map: &BTreeMap<String, String>,
506 desired_destination: PathBuf,
507 section: &mut UpgradeSection<'_>,
508) -> EntryUpgradeResult {
509 if let Some(conflict) = manifest.find_by_dest(&desired_destination) {
510 section.begin();
511 eprintln!(
512 " {} {}: destination move blocked; {} is already managed by {}",
513 colors::symbol("!"),
514 entry.source,
515 path_display::format_home(&desired_destination, &config.home_dir),
516 conflict.source
517 );
518 return EntryUpgradeResult::UserModified;
519 }
520 if desired_destination.exists() {
521 section.begin();
522 eprintln!(
523 " {} {}: destination move blocked; {} already exists and is not managed",
524 colors::symbol("!"),
525 entry.source,
526 path_display::format_home(&desired_destination, &config.home_dir)
527 );
528 return EntryUpgradeResult::UserModified;
529 }
530
531 match tokio::fs::read(&entry.destination).await {
532 Ok(current) => match installed_content_hash(file, ¤t) {
533 Ok(Some(hash)) if hash == entry.content_hash => {}
534 Ok(_) | Err(_) => {
535 section.begin();
536 eprintln!(
537 " {} {}: destination move blocked; installed file is user-modified",
538 colors::symbol("!"),
539 entry.source
540 );
541 return EntryUpgradeResult::UserModified;
542 }
543 },
544 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
545 Err(error) => {
546 section.begin();
547 print_install_error(&entry.source, &anyhow::Error::from(error));
548 return EntryUpgradeResult::Failed;
549 }
550 }
551
552 let content = match upgrade_file_content(config, cat, file, env_map).await {
553 Ok(content) => content,
554 Err(error) => {
555 section.begin();
556 print_install_error(&entry.source, &error);
557 return EntryUpgradeResult::Failed;
558 }
559 };
560 let outcome =
561 match install_prepared_content(file, &content, &desired_destination, false, false, false)
562 .await
563 {
564 Ok(outcome @ InstallOutcome::Installed { .. })
565 | Ok(outcome @ InstallOutcome::BackedUpAndInstalled { .. }) => outcome,
566 Ok(InstallOutcome::AlreadyManaged | InstallOutcome::DryRun) => {
567 return EntryUpgradeResult::Skipped;
568 }
569 Err(error) => {
570 section.begin();
571 print_install_error(&entry.source, &error);
572 return EntryUpgradeResult::Failed;
573 }
574 };
575 let (backup, hash) = match outcome {
576 InstallOutcome::Installed { hash } => (None, hash),
577 InstallOutcome::BackedUpAndInstalled { backup, hash } => (Some(backup), hash),
578 InstallOutcome::AlreadyManaged | InstallOutcome::DryRun => unreachable!(),
579 };
580
581 match uninstall_app_entry(entry, false, false).await {
582 Ok(
583 UninstallOutcome::Removed
584 | UninstallOutcome::RestoredBackup { .. }
585 | UninstallOutcome::NotFound,
586 ) => {}
587 Ok(_) | Err(_) => {
588 let rollback = AppEntry {
589 source: entry.source.clone(),
590 destination: desired_destination.clone(),
591 backup: backup.clone(),
592 content_hash: hash,
593 install_strategy: file.install_strategy.clone(),
594 uses_env: file
595 .transforms
596 .iter()
597 .any(|transform| transform == "template")
598 || file.generator.is_some(),
599 requires_admin: file.requires_admin,
600 };
601 let _ = uninstall_app_entry(&rollback, false, true).await;
602 section.begin();
603 eprintln!(
604 " {} {}: destination move could not remove the old managed file; new copy rolled back",
605 colors::symbol("!"),
606 entry.source
607 );
608 return EntryUpgradeResult::Failed;
609 }
610 }
611
612 let display_name = file
613 .display_name
614 .as_deref()
615 .map(str::to_owned)
616 .unwrap_or_else(|| format!("{}/{}", cat.name, file.source_rel.display()));
617 section.print_file_updated(&display_name, &desired_destination, config);
618 EntryUpgradeResult::Updated(AppEntry {
619 source: entry.source.clone(),
620 destination: desired_destination,
621 backup,
622 content_hash: hash,
623 install_strategy: file.install_strategy.clone(),
624 uses_env: file
625 .transforms
626 .iter()
627 .any(|transform| transform == "template")
628 || file.generator.is_some(),
629 requires_admin: file.requires_admin,
630 })
631}
632
633enum StaleCleanupOutcome {
634 Removed,
635 NotFound,
636 UserModified,
637 Skipped,
638}
639
640fn apply_stale_outcome(
641 outcome: StaleCleanupOutcome,
642 destination: PathBuf,
643 pending_removals: &mut Vec<PathBuf>,
644 updated: &mut usize,
645 user_modified: &mut usize,
646 skipped: &mut usize,
647) {
648 match outcome {
649 StaleCleanupOutcome::Removed | StaleCleanupOutcome::NotFound => {
650 pending_removals.push(destination);
651 *updated += 1;
652 }
653 StaleCleanupOutcome::UserModified => {
654 *user_modified += 1;
655 *skipped += 1;
656 }
657 StaleCleanupOutcome::Skipped => {
658 *skipped += 1;
659 }
660 }
661}
662
663struct StaleEntryCounters<'a> {
666 pending_removals: &'a mut Vec<PathBuf>,
667 updated: &'a mut usize,
668 user_modified: &'a mut usize,
669 skipped: &'a mut usize,
670}
671
672async fn handle_stale_entry(
673 config: &Config,
674 entry: &AppEntry,
675 prune_stale: bool,
676 interactive: bool,
677 counters: &mut StaleEntryCounters<'_>,
678) -> Result<()> {
679 let outcome = cleanup_stale_entry(config, entry, prune_stale, interactive).await?;
680 apply_stale_outcome(
681 outcome,
682 entry.destination.clone(),
683 counters.pending_removals,
684 counters.updated,
685 counters.user_modified,
686 counters.skipped,
687 );
688 Ok(())
689}
690
691async fn install_new_category_files(
692 config: &Config,
693 categories_by_name: &BTreeMap<String, metadata::AppCategory>,
694 manifest: &AppManifest,
695 env_map: &BTreeMap<String, String>,
696 section: &mut UpgradeSection<'_>,
697) -> Result<(usize, usize, usize, Vec<AppEntry>, BTreeSet<String>)> {
698 let mut updated = 0usize;
699 let mut skipped = 0usize;
700 let mut failed = 0usize;
701 let mut new_upserts: Vec<AppEntry> = Vec::new();
702 let mut restart_hints = BTreeSet::new();
703
704 for cat in categories_by_name.values() {
705 for file in &cat.files {
706 if file
707 .generator
708 .as_ref()
709 .is_some_and(|generator| !generator.auto)
710 {
711 continue;
712 }
713 let destination = match resolve_install_destination(cat, file, config) {
714 Ok(d) => d,
715 Err(e) => {
716 section.begin();
717 eprintln!(
718 " {} {}/{}: bad destination: {e:#}",
719 colors::symbol("✗"),
720 cat.name,
721 file.source_rel.display()
722 );
723 skipped += 1;
724 continue;
725 }
726 };
727 if manifest.find_by_dest(&destination).is_some() {
728 continue;
729 }
730
731 let source = format!("app/{}/{}", cat.name, file.source_rel.display());
732
733 if manifest.find_by_source(&source).is_some() {
734 continue;
735 }
736
737 if destination.exists() && file.install_strategy.is_copy() {
738 section.begin();
739 eprintln!(
740 " {} {}: destination exists and is not managed, skipped",
741 colors::symbol("!"),
742 source
743 );
744 skipped += 1;
745 continue;
746 }
747
748 let content = match upgrade_file_content(config, cat, file, env_map).await {
749 Ok(content) => content,
750 Err(e) => {
751 section.begin();
752 eprintln!(" {} {}: {e:#}", colors::symbol_stderr("✗"), source);
753 if file
754 .generator
755 .as_ref()
756 .is_some_and(|generator| env_map.contains_key(&generator.when_env))
757 {
758 failed += 1;
759 } else {
760 skipped += 1;
761 }
762 continue;
763 }
764 };
765
766 let outcome =
767 install_prepared_content(file, &content, &destination, false, false, true).await;
768
769 match outcome {
770 Ok(InstallOutcome::Installed { hash })
771 | Ok(InstallOutcome::BackedUpAndInstalled { hash, .. }) => {
772 let display_name = file
773 .display_name
774 .as_deref()
775 .map(|s| s.to_string())
776 .unwrap_or_else(|| format!("{}/{}", cat.name, file.source_rel.display()));
777 section.print_file_updated(&display_name, &destination, config);
778 new_upserts.push(AppEntry {
779 source,
780 destination,
781 backup: None,
782 content_hash: hash,
783 install_strategy: file.install_strategy.clone(),
784 uses_env: file.transforms.iter().any(|t| t == "template")
785 || file.generator.is_some(),
786 requires_admin: file.requires_admin,
787 });
788 updated += 1;
789 if let Some(hint) = &file.restart_hint {
790 restart_hints.insert(hint.clone());
791 }
792 }
793 Ok(InstallOutcome::AlreadyManaged) => {
794 section.begin();
795 eprintln!(
796 " {} {}: destination exists and is not managed, skipped",
797 colors::symbol("!"),
798 source
799 );
800 skipped += 1;
801 }
802 Ok(InstallOutcome::DryRun) => {
803 skipped += 1;
804 }
805 Err(e) => {
806 section.begin();
807 eprintln!(" {} {}: {e:#}", colors::symbol_stderr("✗"), source);
808 skipped += 1;
809 }
810 }
811 }
812 }
813
814 Ok((updated, skipped, failed, new_upserts, restart_hints))
815}
816
817async fn cleanup_stale_entry(
818 config: &Config,
819 entry: &AppEntry,
820 prune_stale: bool,
821 interactive: bool,
822) -> Result<StaleCleanupOutcome> {
823 let should_remove = if prune_stale {
824 true
825 } else if interactive {
826 let prompt = format!(
827 "Preset source '{}' no longer exists. Remove managed file {}?",
828 entry.source,
829 path_display::format_home(&entry.destination, &config.home_dir)
830 );
831 Confirm::new()
832 .with_prompt(prompt)
833 .default(false)
834 .interact()?
835 } else {
836 eprintln!(
837 " {} {}: stale source, skipped (use --prune-stale to clean)",
838 colors::symbol("!"),
839 entry.source
840 );
841 return Ok(StaleCleanupOutcome::Skipped);
842 };
843
844 if !should_remove {
845 eprintln!(
846 " {} {}: stale source, skipped",
847 colors::symbol("!"),
848 entry.source
849 );
850 return Ok(StaleCleanupOutcome::Skipped);
851 }
852
853 match uninstall_app_entry(entry, false, false).await? {
854 UninstallOutcome::Removed => {
855 print_stale_removed(config, &entry.destination, "(removed stale managed file)");
856 Ok(StaleCleanupOutcome::Removed)
857 }
858 UninstallOutcome::RestoredBackup { backup } => {
859 print_stale_removed(
860 config,
861 &entry.destination,
862 format!(
863 "(removed stale file, restored {})",
864 path_display::format_home(&backup, &config.home_dir)
865 ),
866 );
867 Ok(StaleCleanupOutcome::Removed)
868 }
869 UninstallOutcome::ForceRemoved | UninstallOutcome::ForceRestoredBackup { .. } => {
870 Ok(StaleCleanupOutcome::Removed)
871 }
872 UninstallOutcome::NotFound => {
873 print_stale_not_found(config, &entry.destination);
874 Ok(StaleCleanupOutcome::NotFound)
875 }
876 UninstallOutcome::UserModified => {
877 eprintln!(
878 " {} {}: stale source but user-modified, kept",
879 colors::symbol("!"),
880 entry.source
881 );
882 Ok(StaleCleanupOutcome::UserModified)
883 }
884 UninstallOutcome::DryRun => Ok(StaleCleanupOutcome::Skipped),
885 }
886}
887
888async fn upgrade_file_content(
889 config: &Config,
890 cat: &metadata::AppCategory,
891 file: &metadata::AppFile,
892 env_map: &BTreeMap<String, String>,
893) -> Result<Vec<u8>> {
894 super::materialize_file_content(config, cat, file, env_map).await
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use crate::apps::{AppFile, AppListMode};
901 use crate::config::Config;
902 use crate::install_core::manifest::{AppInstallStrategy, hash_content};
903 use tokio::fs;
904
905 async fn relocation_fixture(
906 user_modified: bool,
907 destination_conflict: bool,
908 ) -> (
909 Config,
910 AppManifest,
911 AppEntry,
912 metadata::AppCategory,
913 PathBuf,
914 PathBuf,
915 ) {
916 let dir = crate::test_support::make_temp_dir("shine-upgrade-relocation").await;
917 let mut config = Config::new_for_test(&dir);
918 config.is_external_presets = true;
919 let source_dir = config.presets_dir().join("app/sample");
920 fs::create_dir_all(&source_dir).await.unwrap();
921 fs::write(source_dir.join("config.toml"), b"managed\n")
922 .await
923 .unwrap();
924 let old_destination = dir.join("old/config.toml");
925 let new_root = dir.join("new");
926 fs::create_dir_all(old_destination.parent().unwrap())
927 .await
928 .unwrap();
929 let old_content: &[u8] = if user_modified {
930 b"modified\n"
931 } else {
932 b"managed\n"
933 };
934 fs::write(&old_destination, old_content).await.unwrap();
935 if destination_conflict {
936 fs::create_dir_all(&new_root).await.unwrap();
937 fs::write(new_root.join("config.toml"), b"mine\n")
938 .await
939 .unwrap();
940 }
941 let entry = AppEntry {
942 source: "app/sample/config.toml".to_string(),
943 destination: old_destination.clone(),
944 backup: None,
945 content_hash: hash_content(b"managed\n"),
946 install_strategy: AppInstallStrategy::Copy,
947 uses_env: false,
948 requires_admin: false,
949 };
950 let manifest = AppManifest {
951 entries: vec![entry.clone()],
952 };
953 let category = metadata::AppCategory {
954 name: "sample".to_string(),
955 description: None,
956 destination_root: Some(new_root.display().to_string()),
957 files: vec![AppFile {
958 source_rel: PathBuf::from("config.toml"),
959 target_rel: PathBuf::from("config.toml"),
960 destination_root: None,
961 description: None,
962 display_name: None,
963 legacy_dest_annotation: None,
964 transforms: Vec::new(),
965 install_strategy: AppInstallStrategy::Copy,
966 requires_admin: false,
967 restart_hint: None,
968 generator: None,
969 }],
970 list_mode: AppListMode::Files,
971 post_upgrade: Vec::new(),
972 post_install: Vec::new(),
973 uses_metadata: true,
974 has_explicit_files: true,
975 artifact: None,
976 };
977 (
978 config,
979 manifest,
980 entry,
981 category,
982 old_destination,
983 new_root.join("config.toml"),
984 )
985 }
986
987 #[test]
988 fn no_op_rows_only_start_the_app_section_in_verbose_mode() {
989 let mut quiet_separator = crate::output::SectionSeparator::new();
990 let mut quiet = UpgradeSection::new(&mut quiet_separator, false, 1);
991 quiet.print_up_to_date("app/sample/config.toml");
992 quiet.print_manual_refresh("app/sample/generated.txt", "sample", "generated.txt");
993 assert!(!quiet.started);
994
995 let mut verbose_separator = crate::output::SectionSeparator::new();
996 let mut verbose = UpgradeSection::new(&mut verbose_separator, true, 1);
997 verbose.print_up_to_date("app/sample/config.toml");
998 assert!(verbose.started);
999 }
1000
1001 #[tokio::test]
1002 async fn relocation_moves_an_unmodified_managed_file() {
1003 let (config, manifest, entry, category, old_destination, new_destination) =
1004 relocation_fixture(false, false).await;
1005 let mut separator = crate::output::SectionSeparator::new();
1006 let mut section = UpgradeSection::new(&mut separator, false, 1);
1007 let result = relocate_upgrade_entry(
1008 &config,
1009 &manifest,
1010 &entry,
1011 &category,
1012 &category.files[0],
1013 &BTreeMap::new(),
1014 new_destination.clone(),
1015 &mut section,
1016 )
1017 .await;
1018
1019 let EntryUpgradeResult::Updated(updated) = result else {
1020 panic!("expected relocation to update")
1021 };
1022 assert_eq!(updated.destination, new_destination);
1023 assert_eq!(fs::read(&updated.destination).await.unwrap(), b"managed\n");
1024 assert!(!old_destination.exists());
1025 fs::remove_dir_all(config.home_dir).await.unwrap();
1026 }
1027
1028 #[tokio::test]
1029 async fn relocation_keeps_a_user_modified_old_file() {
1030 let (config, manifest, entry, category, old_destination, new_destination) =
1031 relocation_fixture(true, false).await;
1032 let mut separator = crate::output::SectionSeparator::new();
1033 let mut section = UpgradeSection::new(&mut separator, false, 1);
1034 let result = relocate_upgrade_entry(
1035 &config,
1036 &manifest,
1037 &entry,
1038 &category,
1039 &category.files[0],
1040 &BTreeMap::new(),
1041 new_destination.clone(),
1042 &mut section,
1043 )
1044 .await;
1045
1046 assert!(matches!(result, EntryUpgradeResult::UserModified));
1047 assert_eq!(fs::read(old_destination).await.unwrap(), b"modified\n");
1048 assert!(!new_destination.exists());
1049 fs::remove_dir_all(config.home_dir).await.unwrap();
1050 }
1051
1052 #[tokio::test]
1053 async fn relocation_does_not_overwrite_an_unmanaged_new_destination() {
1054 let (config, manifest, entry, category, old_destination, new_destination) =
1055 relocation_fixture(false, true).await;
1056 let mut separator = crate::output::SectionSeparator::new();
1057 let mut section = UpgradeSection::new(&mut separator, false, 1);
1058 let result = relocate_upgrade_entry(
1059 &config,
1060 &manifest,
1061 &entry,
1062 &category,
1063 &category.files[0],
1064 &BTreeMap::new(),
1065 new_destination.clone(),
1066 &mut section,
1067 )
1068 .await;
1069
1070 assert!(matches!(result, EntryUpgradeResult::UserModified));
1071 assert_eq!(fs::read(old_destination).await.unwrap(), b"managed\n");
1072 assert_eq!(fs::read(new_destination).await.unwrap(), b"mine\n");
1073 fs::remove_dir_all(config.home_dir).await.unwrap();
1074 }
1075}