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};
23
24#[derive(Debug, Default)]
25pub struct AppUpgradeReport {
26    pub updated: usize,
27    pub skipped: usize,
28    pub failed: usize,
29    pub user_modified: usize,
30    pub restart_hints: BTreeSet<String>,
31}
32
33struct UpgradeSection<'a> {
34    sep: &'a mut crate::output::SectionSeparator,
35    verbose: bool,
36    installed_count: usize,
37    started: bool,
38}
39
40impl<'a> UpgradeSection<'a> {
41    fn new(
42        sep: &'a mut crate::output::SectionSeparator,
43        verbose: bool,
44        installed_count: usize,
45    ) -> Self {
46        Self {
47            sep,
48            verbose,
49            installed_count,
50            started: false,
51        }
52    }
53
54    fn begin(&mut self) {
55        if self.started {
56            return;
57        }
58        self.sep.begin();
59        if self.verbose {
60            output::summary_line(
61                "App Configs",
62                &[colors::dim(&format!(
63                    "{} installed file(s)",
64                    self.installed_count
65                ))],
66            );
67        } else {
68            println!("{}", colors::bold("App Configs"));
69        }
70        self.started = true;
71    }
72
73    fn print_up_to_date(&mut self, source: &str) {
74        if self.verbose {
75            self.begin();
76            println!("  {} {source}: up to date", colors::symbol("✓"));
77        }
78    }
79
80    fn print_manual_refresh(&mut self, source: &str, category: &str, file: &str) {
81        if self.verbose {
82            self.begin();
83            println!(
84                "  {} {source}: manual refresh only (shine app refresh {category} {file})",
85                colors::symbol("•")
86            );
87        }
88    }
89}
90
91pub async fn handle_upgrade_installed(
92    config: &Config,
93    prune_stale: bool,
94    sep: &mut crate::output::SectionSeparator,
95) -> Result<AppUpgradeReport> {
96    handle_upgrade_installed_with_output(config, prune_stale, false, sep).await
97}
98
99pub(crate) async fn handle_upgrade_installed_with_output(
100    config: &Config,
101    prune_stale: bool,
102    verbose: bool,
103    sep: &mut crate::output::SectionSeparator,
104) -> Result<AppUpgradeReport> {
105    handle_upgrade_installed_target(config, None, prune_stale, verbose, sep).await
106}
107
108pub(crate) async fn handle_upgrade_installed_target(
109    config: &Config,
110    category_filter: Option<&str>,
111    prune_stale: bool,
112    verbose: bool,
113    sep: &mut crate::output::SectionSeparator,
114) -> Result<AppUpgradeReport> {
115    let mut manifest = AppManifest::load(config.shine_dir()).await?;
116    if manifest.entries.is_empty() {
117        return Ok(AppUpgradeReport::default());
118    }
119
120    let selected_entries = manifest
121        .entries
122        .iter()
123        .filter(|entry| {
124            category_filter.is_none_or(|filter| {
125                app_category_from_source(&entry.source).as_deref() == Some(filter)
126            })
127        })
128        .collect::<Vec<_>>();
129    if let Some(category) = category_filter
130        && selected_entries.is_empty()
131    {
132        anyhow::bail!("app preset is not installed: {category}");
133    }
134
135    let env = EnvConfig::load_or_init(config).await?;
136    let env_map = env.as_map();
137    let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
138    let installed_categories: BTreeSet<String> = selected_entries
139        .iter()
140        .filter_map(|entry| app_category_from_source(&entry.source))
141        .collect();
142
143    if !config.is_external_presets {
144        for category in &installed_categories {
145            let prefix = format!("app/{category}");
146            let _ = crate::presets::extract_prefix(&prefix, config.presets_dir(), true).await?;
147        }
148    }
149
150    let mut categories_by_name: BTreeMap<String, metadata::AppCategory> = BTreeMap::new();
151    for cat_name in &installed_categories {
152        if config.is_external_presets
153            && !config.preset_path(Path::new("app").join(cat_name)).exists()
154        {
155            continue;
156        }
157        let categories = metadata::load_active_categories(config, Some(cat_name)).await?;
158        if let Some(cat) = categories.into_iter().find(|cat| cat.name == *cat_name) {
159            categories_by_name.insert(cat_name.clone(), cat);
160        }
161    }
162
163    let mut section = UpgradeSection::new(sep, verbose, selected_entries.len());
164    if verbose {
165        section.begin();
166    }
167
168    let mut updated = 0usize;
169    let mut skipped = 0usize;
170    let mut failed = 0usize;
171    let mut user_modified = 0usize;
172    let mut pending_upserts: Vec<AppEntry> = Vec::new();
173    let mut restart_hints = BTreeSet::new();
174    let mut pending_removals: Vec<PathBuf> = Vec::new();
175    let mut updated_categories = BTreeSet::new();
176
177    for entry in selected_entries {
178        let Some((cat_name, file_rel)) = app_source_parts(&entry.source) else {
179            section.begin();
180            eprintln!(
181                "  {} {}: invalid source, skipped",
182                colors::symbol("!"),
183                entry.source
184            );
185            skipped += 1;
186            continue;
187        };
188
189        let Some(cat) = categories_by_name.get(cat_name) else {
190            section.begin();
191            handle_stale_entry(
192                config,
193                entry,
194                prune_stale,
195                interactive,
196                &mut StaleEntryCounters {
197                    pending_removals: &mut pending_removals,
198                    updated: &mut updated,
199                    user_modified: &mut user_modified,
200                    skipped: &mut skipped,
201                },
202            )
203            .await?;
204            continue;
205        };
206        let Some(file) = cat
207            .files
208            .iter()
209            .find(|file| file.source_rel.to_string_lossy().as_ref() == file_rel)
210        else {
211            section.begin();
212            handle_stale_entry(
213                config,
214                entry,
215                prune_stale,
216                interactive,
217                &mut StaleEntryCounters {
218                    pending_removals: &mut pending_removals,
219                    updated: &mut updated,
220                    user_modified: &mut user_modified,
221                    skipped: &mut skipped,
222                },
223            )
224            .await?;
225            continue;
226        };
227
228        if file
229            .generator
230            .as_ref()
231            .is_some_and(|generator| !generator.auto)
232        {
233            section.print_manual_refresh(&entry.source, cat_name, file_rel);
234            skipped += 1;
235            continue;
236        }
237
238        match try_upgrade_entry(config, entry, cat, file, env_map, &mut section).await {
239            EntryUpgradeResult::Updated(new_entry) => {
240                updated_categories.insert(cat.name.clone());
241                pending_upserts.push(new_entry);
242                updated += 1;
243                if let Some(hint) = &file.restart_hint {
244                    restart_hints.insert(hint.clone());
245                }
246            }
247            EntryUpgradeResult::UserModified => {
248                user_modified += 1;
249                skipped += 1;
250            }
251            EntryUpgradeResult::Skipped => {
252                section.print_up_to_date(&entry.source);
253                skipped += 1;
254            }
255            EntryUpgradeResult::Failed => {
256                skipped += 1;
257            }
258            EntryUpgradeResult::FatalGenerator => {
259                failed += 1;
260            }
261        }
262    }
263
264    for destination in pending_removals {
265        manifest.remove_by_dest(&destination);
266    }
267
268    let (new_updated, new_skipped, new_failed, new_upserts, new_restart_hints) =
269        install_new_category_files(
270            config,
271            &categories_by_name,
272            &manifest,
273            env_map,
274            &mut section,
275        )
276        .await?;
277    updated += new_updated;
278    skipped += new_skipped;
279    for entry in &new_upserts {
280        if let Some(category) = app_category_from_source(&entry.source) {
281            updated_categories.insert(category.to_string());
282        }
283    }
284    pending_upserts.extend(new_upserts);
285    restart_hints.extend(new_restart_hints);
286
287    for upsert in pending_upserts {
288        manifest.upsert(upsert);
289    }
290    manifest.save(config.shine_dir()).await?;
291
292    super::hooks::run_app_hooks(
293        config,
294        |name| categories_by_name.get(name),
295        &updated_categories,
296        super::hooks::HookPhase::PostUpgrade,
297    )
298    .await;
299
300    Ok(AppUpgradeReport {
301        updated,
302        skipped,
303        failed: failed + new_failed,
304        user_modified,
305        restart_hints,
306    })
307}
308
309enum EntryUpgradeResult {
310    Updated(AppEntry),
311    UserModified,
312    Skipped,
313    Failed,
314    FatalGenerator,
315}
316
317async fn try_upgrade_entry(
318    config: &Config,
319    entry: &AppEntry,
320    cat: &metadata::AppCategory,
321    file: &metadata::AppFile,
322    env_map: &BTreeMap<String, String>,
323    section: &mut UpgradeSection<'_>,
324) -> EntryUpgradeResult {
325    let content = match upgrade_file_content(config, cat, file, env_map).await {
326        Ok(c) => c,
327        Err(e) => {
328            section.begin();
329            print_install_error(&entry.source, &e);
330            return if file
331                .generator
332                .as_ref()
333                .is_some_and(|generator| env_map.contains_key(&generator.when_env))
334                && !entry.destination.exists()
335            {
336                EntryUpgradeResult::FatalGenerator
337            } else {
338                EntryUpgradeResult::Failed
339            };
340        }
341    };
342
343    let new_hash = match desired_content_hash(file, &content) {
344        Ok(h) => h,
345        Err(e) => {
346            section.begin();
347            print_install_error(&entry.source, &e);
348            return EntryUpgradeResult::Failed;
349        }
350    };
351
352    match tokio::fs::read(&entry.destination).await {
353        Ok(current) => {
354            let current_hash = match installed_content_hash(file, &current) {
355                Ok(Some(h)) => h,
356                Ok(None) => {
357                    section.begin();
358                    eprintln!(
359                        "  {} {}: managed keys missing, skipped",
360                        colors::symbol("!"),
361                        entry.source
362                    );
363                    return EntryUpgradeResult::UserModified;
364                }
365                Err(e) => {
366                    section.begin();
367                    print_install_error(&entry.source, &e);
368                    return EntryUpgradeResult::Failed;
369                }
370            };
371            if current_hash != entry.content_hash {
372                section.begin();
373                eprintln!(
374                    "  {} {}: user-modified, skipped",
375                    colors::symbol("!"),
376                    entry.source
377                );
378                return EntryUpgradeResult::UserModified;
379            }
380            if new_hash == entry.content_hash {
381                return EntryUpgradeResult::Skipped;
382            }
383        }
384        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
385        Err(e) => {
386            section.begin();
387            print_install_error(&entry.source, &anyhow::Error::from(e));
388            return EntryUpgradeResult::Failed;
389        }
390    }
391
392    match install_prepared_content(file, &content, &entry.destination, true, false, true).await {
393        Ok(InstallOutcome::Installed { hash })
394        | Ok(InstallOutcome::BackedUpAndInstalled { hash, .. }) => {
395            let display_name = file
396                .display_name
397                .as_deref()
398                .map(|s| s.to_string())
399                .unwrap_or_else(|| format!("{}/{}", cat.name, file.source_rel.display()));
400            section.begin();
401            print_install_success(&display_name, "", &entry.destination, config);
402            EntryUpgradeResult::Updated(AppEntry {
403                source: entry.source.clone(),
404                destination: entry.destination.clone(),
405                backup: entry.backup.clone(),
406                content_hash: hash,
407                install_strategy: file.install_strategy.clone(),
408                uses_env: file.transforms.iter().any(|t| t == "template"),
409                requires_admin: file.requires_admin,
410            })
411        }
412        Ok(InstallOutcome::AlreadyManaged) | Ok(InstallOutcome::DryRun) => {
413            EntryUpgradeResult::Skipped
414        }
415        Err(e) => {
416            section.begin();
417            print_install_error(&entry.source, &e);
418            EntryUpgradeResult::Failed
419        }
420    }
421}
422
423enum StaleCleanupOutcome {
424    Removed,
425    NotFound,
426    UserModified,
427    Skipped,
428}
429
430fn apply_stale_outcome(
431    outcome: StaleCleanupOutcome,
432    destination: PathBuf,
433    pending_removals: &mut Vec<PathBuf>,
434    updated: &mut usize,
435    user_modified: &mut usize,
436    skipped: &mut usize,
437) {
438    match outcome {
439        StaleCleanupOutcome::Removed | StaleCleanupOutcome::NotFound => {
440            pending_removals.push(destination);
441            *updated += 1;
442        }
443        StaleCleanupOutcome::UserModified => {
444            *user_modified += 1;
445            *skipped += 1;
446        }
447        StaleCleanupOutcome::Skipped => {
448            *skipped += 1;
449        }
450    }
451}
452
453/// Mutable counters threaded through the upgrade loop, grouped to keep
454/// `handle_stale_entry`'s argument count within clippy's limit.
455struct StaleEntryCounters<'a> {
456    pending_removals: &'a mut Vec<PathBuf>,
457    updated: &'a mut usize,
458    user_modified: &'a mut usize,
459    skipped: &'a mut usize,
460}
461
462async fn handle_stale_entry(
463    config: &Config,
464    entry: &AppEntry,
465    prune_stale: bool,
466    interactive: bool,
467    counters: &mut StaleEntryCounters<'_>,
468) -> Result<()> {
469    let outcome = cleanup_stale_entry(config, entry, prune_stale, interactive).await?;
470    apply_stale_outcome(
471        outcome,
472        entry.destination.clone(),
473        counters.pending_removals,
474        counters.updated,
475        counters.user_modified,
476        counters.skipped,
477    );
478    Ok(())
479}
480
481async fn install_new_category_files(
482    config: &Config,
483    categories_by_name: &BTreeMap<String, metadata::AppCategory>,
484    manifest: &AppManifest,
485    env_map: &BTreeMap<String, String>,
486    section: &mut UpgradeSection<'_>,
487) -> Result<(usize, usize, usize, Vec<AppEntry>, BTreeSet<String>)> {
488    let mut updated = 0usize;
489    let mut skipped = 0usize;
490    let mut failed = 0usize;
491    let mut new_upserts: Vec<AppEntry> = Vec::new();
492    let mut restart_hints = BTreeSet::new();
493
494    for cat in categories_by_name.values() {
495        for file in &cat.files {
496            if file
497                .generator
498                .as_ref()
499                .is_some_and(|generator| !generator.auto)
500            {
501                continue;
502            }
503            let destination = match resolve_install_destination(cat, file, config) {
504                Ok(d) => d,
505                Err(e) => {
506                    section.begin();
507                    eprintln!(
508                        "  {} {}/{}: bad destination: {e:#}",
509                        colors::symbol("✗"),
510                        cat.name,
511                        file.source_rel.display()
512                    );
513                    skipped += 1;
514                    continue;
515                }
516            };
517            if manifest.find_by_dest(&destination).is_some() {
518                continue;
519            }
520
521            let source = format!("app/{}/{}", cat.name, file.source_rel.display());
522
523            if destination.exists() && file.install_strategy.is_copy() {
524                section.begin();
525                eprintln!(
526                    "  {} {}: destination exists and is not managed, skipped",
527                    colors::symbol("!"),
528                    source
529                );
530                skipped += 1;
531                continue;
532            }
533
534            let content = match upgrade_file_content(config, cat, file, env_map).await {
535                Ok(content) => content,
536                Err(e) => {
537                    section.begin();
538                    eprintln!("  {} {}: {e:#}", colors::symbol_stderr("✗"), source);
539                    if file
540                        .generator
541                        .as_ref()
542                        .is_some_and(|generator| env_map.contains_key(&generator.when_env))
543                    {
544                        failed += 1;
545                    } else {
546                        skipped += 1;
547                    }
548                    continue;
549                }
550            };
551
552            let outcome =
553                install_prepared_content(file, &content, &destination, false, false, true).await;
554
555            match outcome {
556                Ok(InstallOutcome::Installed { hash })
557                | Ok(InstallOutcome::BackedUpAndInstalled { hash, .. }) => {
558                    let display_name = file
559                        .display_name
560                        .as_deref()
561                        .map(|s| s.to_string())
562                        .unwrap_or_else(|| format!("{}/{}", cat.name, file.source_rel.display()));
563                    section.begin();
564                    print_install_success(&display_name, "", &destination, config);
565                    new_upserts.push(AppEntry {
566                        source,
567                        destination,
568                        backup: None,
569                        content_hash: hash,
570                        install_strategy: file.install_strategy.clone(),
571                        uses_env: file.transforms.iter().any(|t| t == "template")
572                            || file.generator.is_some(),
573                        requires_admin: file.requires_admin,
574                    });
575                    updated += 1;
576                    if let Some(hint) = &file.restart_hint {
577                        restart_hints.insert(hint.clone());
578                    }
579                }
580                Ok(InstallOutcome::AlreadyManaged) => {
581                    section.begin();
582                    eprintln!(
583                        "  {} {}: destination exists and is not managed, skipped",
584                        colors::symbol("!"),
585                        source
586                    );
587                    skipped += 1;
588                }
589                Ok(InstallOutcome::DryRun) => {
590                    skipped += 1;
591                }
592                Err(e) => {
593                    section.begin();
594                    eprintln!("  {} {}: {e:#}", colors::symbol_stderr("✗"), source);
595                    skipped += 1;
596                }
597            }
598        }
599    }
600
601    Ok((updated, skipped, failed, new_upserts, restart_hints))
602}
603
604async fn cleanup_stale_entry(
605    config: &Config,
606    entry: &AppEntry,
607    prune_stale: bool,
608    interactive: bool,
609) -> Result<StaleCleanupOutcome> {
610    let should_remove = if prune_stale {
611        true
612    } else if interactive {
613        let prompt = format!(
614            "Preset source '{}' no longer exists. Remove managed file {}?",
615            entry.source,
616            path_display::format_home(&entry.destination, &config.home_dir)
617        );
618        Confirm::new()
619            .with_prompt(prompt)
620            .default(false)
621            .interact()?
622    } else {
623        eprintln!(
624            "  {} {}: stale source, skipped (use --prune-stale to clean)",
625            colors::symbol("!"),
626            entry.source
627        );
628        return Ok(StaleCleanupOutcome::Skipped);
629    };
630
631    if !should_remove {
632        eprintln!(
633            "  {} {}: stale source, skipped",
634            colors::symbol("!"),
635            entry.source
636        );
637        return Ok(StaleCleanupOutcome::Skipped);
638    }
639
640    match uninstall_app_entry(entry, false, false).await? {
641        UninstallOutcome::Removed => {
642            print_stale_removed(config, &entry.destination, "(removed stale managed file)");
643            Ok(StaleCleanupOutcome::Removed)
644        }
645        UninstallOutcome::RestoredBackup { backup } => {
646            print_stale_removed(
647                config,
648                &entry.destination,
649                format!(
650                    "(removed stale file, restored {})",
651                    path_display::format_home(&backup, &config.home_dir)
652                ),
653            );
654            Ok(StaleCleanupOutcome::Removed)
655        }
656        UninstallOutcome::ForceRemoved | UninstallOutcome::ForceRestoredBackup { .. } => {
657            Ok(StaleCleanupOutcome::Removed)
658        }
659        UninstallOutcome::NotFound => {
660            print_stale_not_found(config, &entry.destination);
661            Ok(StaleCleanupOutcome::NotFound)
662        }
663        UninstallOutcome::UserModified => {
664            eprintln!(
665                "  {} {}: stale source but user-modified, kept",
666                colors::symbol("!"),
667                entry.source
668            );
669            Ok(StaleCleanupOutcome::UserModified)
670        }
671        UninstallOutcome::DryRun => Ok(StaleCleanupOutcome::Skipped),
672    }
673}
674
675async fn upgrade_file_content(
676    config: &Config,
677    cat: &metadata::AppCategory,
678    file: &metadata::AppFile,
679    env_map: &BTreeMap<String, String>,
680) -> Result<Vec<u8>> {
681    super::materialize_file_content(config, cat, file, env_map).await
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[test]
689    fn no_op_rows_only_start_the_app_section_in_verbose_mode() {
690        let mut quiet_separator = crate::output::SectionSeparator::new();
691        let mut quiet = UpgradeSection::new(&mut quiet_separator, false, 1);
692        quiet.print_up_to_date("app/sample/config.toml");
693        quiet.print_manual_refresh("app/sample/generated.txt", "sample", "generated.txt");
694        assert!(!quiet.started);
695
696        let mut verbose_separator = crate::output::SectionSeparator::new();
697        let mut verbose = UpgradeSection::new(&mut verbose_separator, true, 1);
698        verbose.print_up_to_date("app/sample/config.toml");
699        assert!(verbose.started);
700    }
701}