Skip to main content

cli/apps/
upgrade.rs

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    /// Physical files changed, retained for diagnostics and tests.
28    pub updated: usize,
29    /// User-facing app targets changed. Default summaries count this value.
30    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, &current) {
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, &current) {
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
663/// Mutable counters threaded through the upgrade loop, grouped to keep
664/// `handle_stale_entry`'s argument count within clippy's limit.
665struct 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}