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, ¤t) {
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
453struct 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}