1use anyhow::{Result, bail};
2
3use crate::config::{self, Config};
4#[cfg(unix)]
5use crate::privilege;
6use crate::update_check::{self, ReleaseChannel, UpdateStatus};
7use crate::{apps, colors, env, info, list, output, platform, shells, sys, version};
8use shine_core::lifecycle::LifecycleOperation;
9use shine_core::runtime::{
10 AppPlanRequest, PlanningInputVersions, ShellPlanRequest, SysManagedPlanRequest,
11};
12
13pub async fn handle_update(
14 config: &Config,
15 target: Option<&str>,
16 diff: bool,
17 verbose: bool,
18 refresh_release: bool,
19 run_generators: bool,
20) -> Result<()> {
21 let compatibility = crate::preset_migration::active_compatibility_plan(target).await?;
22 let migration_required = crate::preset_migration::compatibility_required(&compatibility);
23 crate::preset_migration::print_compatibility(&compatibility);
24
25 if let Some(target) = target {
26 info::handle_update_target(config, target, run_generators).await?;
27 if migration_required {
28 bail!(
29 "{}",
30 crate::preset_migration::compatibility_failure_message(&compatibility)
31 );
32 }
33 return Ok(());
34 }
35
36 let config_updates = if verbose {
37 match Box::pin(list::handle_status_list(config, diff, run_generators)).await {
38 Ok(()) => {
39 println!();
40 Ok(true)
41 }
42 Err(error) => Err(error),
43 }
44 } else {
45 Box::pin(list::handle_update_list(config, diff, run_generators)).await
46 };
47 let (mut printed_update, config_update_error) = match config_updates {
48 Ok(printed) => (printed, None),
49 Err(error) => {
50 eprintln!(
51 "{}",
52 colors::yellow_stderr(&format!(
53 "warning: configuration update check failed: {error:#}"
54 ))
55 );
56 (false, Some(error))
57 }
58 };
59
60 let current = version::semver();
61 if verbose {
62 println!("Checking for updates (current: {current})...");
63 }
64
65 let update_status = if refresh_release {
66 update_check::check_for_update_forced(config).await
67 } else {
68 update_check::check_for_update(config).await
69 };
70
71 match update_status {
72 Ok(UpdateStatus::UpToDate) => {
73 if verbose {
74 println!(
75 "{}",
76 colors::green(&format!("shine {current} is up to date."))
77 );
78 }
79 }
80 Ok(UpdateStatus::UpdateAvailable { latest }) => {
81 if printed_update && !verbose {
82 println!();
83 }
84 println!(
85 "{}",
86 colors::yellow(&format!(
87 "A newer version of shine is available: {current} -> {latest}."
88 ))
89 );
90 println!("Run `shine self upgrade` to install it.");
91 printed_update = true;
92 }
93 Ok(UpdateStatus::UpdateRequired { latest }) => {
94 if printed_update && !verbose {
95 println!();
96 }
97 println!(
98 "{}",
99 colors::yellow(&format!(
100 "A newer patch release of shine is available: {current} -> {latest}."
101 ))
102 );
103 println!("Run `shine self upgrade` to install it.");
104 printed_update = true;
105 }
106 Err(e) => {
107 eprintln!("{}", format_update_check_failure_warning(&e));
108 }
109 }
110
111 if !printed_update && config_update_error.is_none() && !migration_required {
112 println!("{}", colors::dim("Nothing to update."));
113 }
114
115 if let Some(error) = config_update_error {
116 return Err(error);
117 }
118 if migration_required {
119 bail!(
120 "{}",
121 crate::preset_migration::compatibility_failure_message(&compatibility)
122 );
123 }
124
125 Ok(())
126}
127
128fn format_update_check_failure_warning(err: &anyhow::Error) -> String {
129 colors::yellow_stderr(&format!("warning: skipped shine version check: {err}"))
130}
131
132pub async fn handle_self_upgrade(config: &Config, channel: Option<ReleaseChannel>) -> Result<()> {
133 let current = version::semver();
134 let selected_channel = channel.unwrap_or(ReleaseChannel::Stable);
135 let force_install = channel.is_some();
136 println!(
137 "Checking for {} upgrades (current: {current})...",
138 selected_channel.as_str()
139 );
140
141 match update_check::upgrade_to_release(config, selected_channel, force_install).await {
142 Ok(update_check::UpgradeResult::AlreadyUpToDate { channel, latest }) => {
143 println!(
144 "{}",
145 colors::green(&format!(
146 "shine {current} is up to date on the {} channel ({latest}).",
147 channel.as_str()
148 ))
149 );
150 }
151 Ok(update_check::UpgradeResult::Upgraded {
152 channel,
153 previous: _,
154 previous_display,
155 release_tag,
156 installed_version,
157 installed_path,
158 }) => {
159 println!(
160 "{}",
161 colors::green(&format_self_upgrade_message(
162 channel,
163 &previous_display,
164 &installed_version,
165 &release_tag,
166 ))
167 );
168 sync_self_install_dest(config, &installed_path).await;
169 }
170 Err(e) => {
171 update_check::invalidate_update_cache(config).await;
172 bail!("Upgrade failed: {e}");
173 }
174 }
175
176 Ok(())
177}
178
179fn format_self_upgrade_message(
180 channel: ReleaseChannel,
181 previous_display: &str,
182 installed_version: &str,
183 release_tag: &str,
184) -> String {
185 match channel {
186 ReleaseChannel::Stable => {
187 format!("Upgraded shine from {previous_display} to {installed_version}.")
188 }
189 ReleaseChannel::Preview => {
190 if previous_display.contains("-preview") {
191 format!(
192 "Updated shine preview from {previous_display} to {installed_version} ({release_tag})."
193 )
194 } else {
195 format!(
196 "Installed shine preview {installed_version} over stable {previous_display} ({release_tag})."
197 )
198 }
199 }
200 }
201}
202
203pub async fn handle_config_upgrade(
204 config: &Config,
205 target: Option<&str>,
206 verbose: bool,
207 prune_stale: bool,
208 yes: bool,
209) -> Result<()> {
210 let compatibility = crate::preset_migration::active_compatibility_plan(target).await?;
211 crate::preset_migration::print_compatibility(&compatibility);
212 if crate::preset_migration::compatibility_required(&compatibility) {
213 bail!(
214 "{}",
215 crate::preset_migration::compatibility_failure_message(&compatibility)
216 );
217 }
218 if let Some(target) = target {
219 return handle_config_target_upgrade(config, target, verbose, prune_stale, yes).await;
220 }
221 if verbose {
222 println!("{}", colors::bold("Upgrading installed configs"));
223 config::print_presets_note(config);
224 }
225
226 let mut sep = if verbose {
227 output::SectionSeparator::new()
228 } else {
229 output::SectionSeparator::with_preamble(colors::bold("Upgrading installed configs"))
230 };
231
232 let env_report = Box::pin(env::upgrade::handle_upgrade(config, false, verbose)).await?;
233 let os_id = sys::detect_os_id().await?;
234 let reviewed = crate::lifecycle_plan::review_upgrade_plans(
235 config,
236 [
237 crate::lifecycle_plan::LifecyclePlanRequest::shell(
238 ShellPlanRequest {
239 operation: LifecycleOperation::Upgrade,
240 target: None,
241 force: false,
242 purge: false,
243 input_versions: PlanningInputVersions::default(),
244 },
245 config,
246 ),
247 crate::lifecycle_plan::LifecyclePlanRequest::app(
248 AppPlanRequest {
249 operation: LifecycleOperation::Upgrade,
250 target: None,
251 force: false,
252 purge: false,
253 prune_stale,
254 input_versions: PlanningInputVersions::default(),
255 },
256 config,
257 ),
258 crate::lifecycle_plan::LifecyclePlanRequest::sys(
259 SysManagedPlanRequest {
260 operation: LifecycleOperation::Upgrade,
261 os_id,
262 target: None,
263 input_versions: PlanningInputVersions::default(),
264 },
265 config,
266 ),
267 ],
268 yes,
269 verbose,
270 )
271 .await?;
272 let mut prepared = crate::lifecycle_plan::prepare_plans(config, reviewed).await?;
273 let shell_prepared = prepared.remove(0);
274 let app_prepared = prepared.remove(0);
275 let sys_prepared = prepared.remove(0);
276
277 let (shell_report, shell_lifecycle) =
278 Box::pin(shells::handle_upgrade_installed_with_result_prepared(
279 config,
280 verbose,
281 shell_prepared,
282 &mut sep,
283 ))
284 .await?;
285 let (app_report, app_lifecycle) = Box::pin(
286 apps::handle_upgrade_installed_with_output_with_result_prepared(
287 config,
288 prune_stale,
289 verbose,
290 app_prepared,
291 &mut sep,
292 ),
293 )
294 .await?;
295 let (sys_report, _sys_lifecycle) = Box::pin(sys::handle_upgrade_managed_with_result_prepared(
296 config,
297 verbose,
298 sys_prepared,
299 &mut sep,
300 ))
301 .await?;
302
303 let updated = env_report.updated
304 + changed_shell_categories(&shell_lifecycle)
305 + usize::from(shell_report.path_changed)
306 + changed_app_categories(&app_lifecycle)
307 + sys_report.updated;
308 let user_modified = env_report.user_modified + preserved_app_resources(&app_lifecycle);
309
310 let summary = config_upgrade_summary_parts(updated, user_modified, shell_report.link_conflicts);
311 if verbose || sep.has_printed() {
312 output::footer("Done", &summary);
313 } else {
314 println!("{}", colors::dim("Nothing to upgrade."));
315 }
316 for hint in &app_report.restart_hints {
317 println!(" {} {}", colors::symbol("!"), colors::yellow(hint));
318 }
319
320 let fatal_app_failures = app_upgrade_failure_count(&app_report);
321 if fatal_app_failures > 0 {
322 bail!("{} app configuration item(s) failed", fatal_app_failures);
323 }
324
325 if sys_report.failed > 0 {
326 bail!(
327 "{} managed system configuration item(s) failed",
328 sys_report.failed
329 );
330 }
331
332 Ok(())
333}
334
335async fn handle_config_target_upgrade(
336 config: &Config,
337 target: &str,
338 verbose: bool,
339 prune_stale: bool,
340 yes: bool,
341) -> Result<()> {
342 use crate::shim::{PresetKind, resolve_preset_kind};
343
344 let target = target.trim();
345 if target.is_empty() {
346 bail!("upgrade target must not be empty");
347 }
348
349 let mut sep = if verbose {
350 println!("{}", colors::bold(&format!("Upgrading {target}")));
351 config::print_presets_note(config);
352 output::SectionSeparator::new()
353 } else {
354 output::SectionSeparator::with_preamble(colors::bold(&format!("Upgrading {target}")))
355 };
356
357 let (updated, user_modified, link_conflicts, failed, restart_hints) =
358 if let Some(item) = target.strip_prefix("sys/") {
359 if item.is_empty() || item.contains('/') {
360 bail!("invalid system target `{target}`; expected sys/<item>");
361 }
362 if prune_stale {
363 bail!("`--prune-stale` applies only to app targets");
364 }
365 let (report, lifecycle) =
366 Box::pin(sys::handle_upgrade_managed_target_with_result_approved(
367 config,
368 Some(item),
369 verbose,
370 yes,
371 &mut sep,
372 ))
373 .await?;
374 (
375 lifecycle.summary().changed,
376 lifecycle.summary().preserved + lifecycle.summary().conflicts,
377 0,
378 report.failed,
379 Default::default(),
380 )
381 } else {
382 let normalized = if let Some(rest) = target.strip_prefix("app/") {
383 let category = rest.split('/').next().unwrap_or_default();
384 format!("app/{category}")
385 } else if let Some(rest) = target.strip_prefix("shell/") {
386 let category = rest.split('/').next().unwrap_or_default();
387 format!("shell/{category}")
388 } else {
389 target.to_string()
390 };
391 let (kind, category) = resolve_preset_kind(config, &normalized).await?;
392 match kind {
393 PresetKind::App => {
394 let (report, lifecycle) =
395 Box::pin(apps::handle_upgrade_installed_target_with_result_approved(
396 config,
397 Some(&category),
398 prune_stale,
399 verbose,
400 yes,
401 &mut sep,
402 ))
403 .await?;
404 (
405 changed_app_categories(&lifecycle),
406 preserved_app_resources(&lifecycle),
407 0,
408 app_upgrade_failure_count(&report),
409 report.restart_hints,
410 )
411 }
412 PresetKind::Shell => {
413 if prune_stale {
414 bail!("`--prune-stale` applies only to app targets");
415 }
416 let (report, lifecycle) = Box::pin(
417 shells::handle_upgrade_installed_target_with_result_approved(
418 config,
419 Some(&category),
420 verbose,
421 yes,
422 &mut sep,
423 ),
424 )
425 .await?;
426 (
427 changed_shell_categories(&lifecycle) + usize::from(report.path_changed),
428 0,
429 lifecycle.summary().conflicts,
430 0,
431 Default::default(),
432 )
433 }
434 }
435 };
436
437 let summary = config_upgrade_summary_parts(updated, user_modified, link_conflicts);
438 if verbose || sep.has_printed() {
439 output::footer("Done", &summary);
440 } else {
441 println!("{}", colors::dim("Nothing to upgrade."));
442 }
443 for hint in restart_hints {
444 println!(" {} {}", colors::symbol("!"), colors::yellow(&hint));
445 }
446 if failed > 0 {
447 bail!("{failed} managed configuration item(s) failed");
448 }
449 Ok(())
450}
451
452fn config_upgrade_summary_parts(
453 updated: usize,
454 user_modified: usize,
455 link_conflicts: usize,
456) -> Vec<String> {
457 let mut parts = Vec::new();
458 output::push_count(&mut parts, updated, colors::green, "updated");
459 output::push_count(
460 &mut parts,
461 user_modified,
462 colors::yellow,
463 "user-modified (kept)",
464 );
465 output::push_count(&mut parts, link_conflicts, colors::yellow, "link conflicts");
466 parts
467}
468
469fn app_upgrade_failure_count(report: &apps::AppUpgradeReport) -> usize {
470 report.failed
471}
472
473fn changed_shell_categories(result: &shine_core::lifecycle::LifecycleResultV1) -> usize {
474 result
475 .outcomes
476 .iter()
477 .filter(|outcome| {
478 outcome.status == shine_core::lifecycle::LifecycleStatus::Changed
479 && outcome.target.starts_with("shell/")
480 && outcome.effects.iter().any(|effect| {
481 !matches!(effect, shine_core::lifecycle::LifecycleEffect::CacheWritten)
482 })
483 })
484 .filter_map(|outcome| outcome.target.split('/').nth(1))
485 .collect::<std::collections::BTreeSet<_>>()
486 .len()
487}
488
489fn is_app_auxiliary_resource(resource: Option<&str>) -> bool {
490 matches!(
491 resource,
492 Some(
493 "preset-cache"
494 | "purge"
495 | "hook:post-install"
496 | "hook:post-upgrade"
497 | "artifact:teardown"
498 )
499 )
500}
501
502fn changed_app_categories(result: &shine_core::lifecycle::LifecycleResultV1) -> usize {
503 result
504 .outcomes
505 .iter()
506 .filter(|outcome| {
507 outcome.status == shine_core::lifecycle::LifecycleStatus::Changed
508 && outcome.target.starts_with("app/")
509 && !is_app_auxiliary_resource(outcome.resource.as_deref())
510 })
511 .map(|outcome| outcome.target.as_str())
512 .collect::<std::collections::BTreeSet<_>>()
513 .len()
514}
515
516fn preserved_app_resources(result: &shine_core::lifecycle::LifecycleResultV1) -> usize {
517 result
518 .outcomes
519 .iter()
520 .filter(|outcome| {
521 matches!(
522 outcome.status,
523 shine_core::lifecycle::LifecycleStatus::Preserved
524 | shine_core::lifecycle::LifecycleStatus::Conflict
525 ) && outcome.target.starts_with("app/")
526 && !is_app_auxiliary_resource(outcome.resource.as_deref())
527 })
528 .count()
529}
530
531async fn sync_self_install_dest(config: &Config, src: &std::path::Path) {
534 let dest = match &config.self_install_dest {
535 Some(d) => d,
536 None => return,
537 };
538 match sync_self_install_dest_from(src, dest).await {
539 Ok(SelfInstallSync::Synced) => println!(
540 "{}",
541 colors::green(&format!("Synced system copy at {}", dest.display()))
542 ),
543 Ok(SelfInstallSync::AlreadyCurrent) => {}
544 Err(e) if cfg!(windows) && has_io_error_kind(&e, std::io::ErrorKind::PermissionDenied) => {
548 let hint = format!(
549 "Installed copy at {} needs manual sync; rerun from an elevated terminal if needed.",
550 dest.display()
551 );
552 println!("{}", colors::yellow(&hint));
553 }
554 Err(e) => eprintln!(
555 "Warning: failed to sync system copy at {}: {e}",
556 dest.display()
557 ),
558 }
559}
560
561enum SelfInstallSync {
562 Synced,
563 AlreadyCurrent,
564}
565
566async fn sync_self_install_dest_from(
567 src: &std::path::Path,
568 dest: &std::path::Path,
569) -> Result<SelfInstallSync> {
570 if dest.exists() {
571 let canonical_src = src.canonicalize().unwrap_or_else(|_| src.to_path_buf());
572 let canonical_dest = dest.canonicalize().unwrap_or_else(|_| dest.to_path_buf());
573 if canonical_src == canonical_dest {
574 return Ok(SelfInstallSync::AlreadyCurrent);
575 }
576 }
577
578 install_binary_with_elevation(src, dest)
579 .await
580 .map(|()| SelfInstallSync::Synced)
581}
582
583fn has_io_error_kind(err: &anyhow::Error, kind: std::io::ErrorKind) -> bool {
584 err.chain().any(|cause| {
585 cause
586 .downcast_ref::<std::io::Error>()
587 .is_some_and(|io_err| io_err.kind() == kind)
588 })
589}
590
591pub async fn handle_self_install(
592 mut config: Config,
593 dest: Option<std::path::PathBuf>,
594) -> Result<()> {
595 use anyhow::{Context as _, bail};
596
597 let src = std::env::current_exe().context("failed to resolve current executable path")?;
598 let dest = match dest {
599 Some(dest) => dest,
600 None => platform::default_self_install_dest()?,
601 };
602
603 if dest.exists() {
604 let canonical_src = src.canonicalize().unwrap_or_else(|_| src.clone());
605 let canonical_dest = dest.canonicalize().unwrap_or_else(|_| dest.clone());
606 if canonical_src == canonical_dest {
607 let example = if cfg!(windows) {
608 r"C:\path\to\new\shine.exe self install"
609 } else {
610 "sudo /path/to/new/shine self install"
611 };
612 bail!(
613 "source and destination are the same binary: {}. Run the newer binary by full path, e.g. `{example}`, to overwrite this copy.",
614 dest.display()
615 );
616 }
617 }
618
619 install_binary_with_elevation(&src, &dest)
620 .await
621 .with_context(|| self_install_failure_hint(&dest))?;
622
623 config.self_install_dest = Some(dest.clone());
625 config
626 .save()
627 .await
628 .context("failed to save self_install_dest to config")?;
629
630 println!(
631 "{}",
632 colors::green(&format!("installed to {}", dest.display()))
633 );
634 print_self_install_activation_hint(&dest);
635
636 Ok(())
637}
638
639fn self_install_failure_hint(dest: &std::path::Path) -> String {
640 format!("failed to copy to {}", dest.display())
641}
642
643fn print_self_install_activation_hint(dest: &std::path::Path) {
644 let Some(dir) = dest.parent() else {
645 return;
646 };
647 if platform::current_path_contains_dir(dir) {
648 println!(
649 "{}",
650 colors::dim("The install directory is already on PATH.")
651 );
652 } else {
653 println!(
654 "{}",
655 colors::yellow(&format!(
656 "Install directory is not on PATH: {}",
657 dir.display()
658 ))
659 );
660 println!("{}", colors::dim(&platform::path_install_hint(dir)));
661 }
662}
663
664fn install_binary_atomically(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
665 use anyhow::Context as _;
666
667 let parent = dest
668 .parent()
669 .with_context(|| format!("destination has no parent: {}", dest.display()))?;
670 std::fs::create_dir_all(parent)
671 .with_context(|| format!("failed to create destination dir: {}", parent.display()))?;
672
673 let temp = parent.join(format!(".shine-self-install-{}", uuid::Uuid::new_v4()));
674 std::fs::copy(src, &temp).with_context(|| {
675 format!(
676 "failed to stage binary from {} to {}",
677 src.display(),
678 temp.display()
679 )
680 })?;
681
682 #[cfg(unix)]
683 {
684 use std::os::unix::fs::PermissionsExt;
685 let mode = std::fs::metadata(src)
686 .map(|m| m.permissions().mode())
687 .unwrap_or(0o755);
688 std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(mode))
689 .with_context(|| format!("failed to set permissions on {}", temp.display()))?;
690 }
691
692 match std::fs::rename(&temp, dest) {
693 Ok(()) => Ok(()),
694 Err(err) => {
695 let _ = std::fs::remove_file(&temp);
696 Err(err)
697 .with_context(|| format!("failed to replace {} with staged binary", dest.display()))
698 }
699 }
700}
701
702async fn install_binary_with_elevation(
707 src: &std::path::Path,
708 dest: &std::path::Path,
709) -> Result<()> {
710 match install_binary_atomically(src, dest) {
711 Ok(()) => Ok(()),
712 Err(e)
713 if !cfg!(windows)
714 && has_io_error_kind(&e, std::io::ErrorKind::PermissionDenied)
715 && !std::env::var("USER").is_ok_and(|user| user == "root") =>
716 {
717 let _lock = crate::admin_fs::admin_lock().await?;
718 install_binary_privileged(src, dest).await
719 }
720 Err(e) => Err(e),
721 }
722}
723
724#[cfg(unix)]
725async fn install_binary_privileged(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
726 use anyhow::Context as _;
727 use std::os::unix::fs::PermissionsExt;
728
729 if !privilege::ensure_admin(1).await? {
730 anyhow::bail!("administrator permission was not granted");
731 }
732
733 let parent = dest
734 .parent()
735 .with_context(|| format!("destination has no parent: {}", dest.display()))?;
736 let mode = std::fs::metadata(src)
737 .map(|m| m.permissions().mode())
738 .unwrap_or(0o755);
739
740 let status = crate::admin_fs::sudo_command()
741 .arg("mkdir")
742 .arg("-p")
743 .arg(parent)
744 .status()
745 .await
746 .context("failed to create privileged destination directory")?;
747 if !status.success() {
748 anyhow::bail!("administrator permission was not granted");
749 }
750
751 let status = crate::admin_fs::sudo_command()
752 .args(["install", "-m", &format!("{mode:o}"), "--"])
753 .arg(src)
754 .arg(dest)
755 .status()
756 .await
757 .context("failed to install shine binary with administrator privileges")?;
758 if !status.success() {
759 anyhow::bail!("failed to install shine binary with administrator privileges");
760 }
761
762 Ok(())
763}
764
765#[cfg(not(unix))]
766async fn install_binary_privileged(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
767 install_binary_atomically(src, dest)
771}
772
773#[cfg(test)]
774mod tests {
775 use super::*;
776
777 async fn make_temp_dir() -> std::path::PathBuf {
778 crate::test_support::make_temp_dir("shine-self-install-test").await
779 }
780
781 fn config_in(dir: &std::path::Path) -> Config {
782 crate::test_support::test_config(dir)
783 }
784
785 #[test]
786 fn install_binary_atomically_overwrites_existing_dest() {
787 let dir = std::env::temp_dir().join(format!("shine-self-install-{}", uuid::Uuid::new_v4()));
788 std::fs::create_dir_all(&dir).unwrap();
789 let src = dir.join("new-shine");
790 let dest = dir.join("shine");
791
792 std::fs::write(&src, b"new").unwrap();
793 std::fs::write(&dest, b"old").unwrap();
794
795 install_binary_atomically(&src, &dest).unwrap();
796
797 assert_eq!(std::fs::read(&dest).unwrap(), b"new");
798 std::fs::remove_dir_all(&dir).unwrap();
799 }
800
801 #[tokio::test]
802 async fn sync_self_install_dest_creates_missing_parent() {
803 let dir = std::env::temp_dir().join(format!("shine-self-sync-{}", uuid::Uuid::new_v4()));
804 let src = dir.join("new-shine");
805 let dest = dir.join("usr/local/bin/shine");
806
807 std::fs::create_dir_all(&dir).unwrap();
808 std::fs::write(&src, b"new").unwrap();
809
810 let outcome = sync_self_install_dest_from(&src, &dest).await.unwrap();
811
812 assert!(matches!(outcome, SelfInstallSync::Synced));
813 assert_eq!(std::fs::read(&dest).unwrap(), b"new");
814 std::fs::remove_dir_all(&dir).unwrap();
815 }
816
817 #[tokio::test]
818 async fn sync_self_install_dest_skips_current_exe_path() {
819 let dir = std::env::temp_dir().join(format!("shine-self-sync-{}", uuid::Uuid::new_v4()));
820 let src = dir.join("shine");
821
822 std::fs::create_dir_all(&dir).unwrap();
823 std::fs::write(&src, b"new").unwrap();
824
825 let outcome = sync_self_install_dest_from(&src, &src).await.unwrap();
826
827 assert!(matches!(outcome, SelfInstallSync::AlreadyCurrent));
828 assert_eq!(std::fs::read(&src).unwrap(), b"new");
829 std::fs::remove_dir_all(&dir).unwrap();
830 }
831
832 #[tokio::test]
833 async fn self_install_errors_when_source_is_destination() {
834 let dir = make_temp_dir().await;
835 let config = config_in(&dir);
836 let current = std::env::current_exe().unwrap();
837
838 let err = handle_self_install(config, Some(current))
839 .await
840 .unwrap_err();
841 assert!(
842 err.to_string()
843 .contains("source and destination are the same binary"),
844 "error should explain self-overwrite: {err:#}"
845 );
846
847 tokio::fs::remove_dir_all(&dir).await.unwrap();
848 }
849
850 #[test]
851 fn update_check_failure_warning_is_non_fatal_wording() {
852 let err = anyhow::anyhow!(
853 "GitHub stable release request failed: HTTP 403 Forbidden: API rate limit exceeded"
854 );
855 let warning = format_update_check_failure_warning(&err);
856
857 assert!(warning.contains("warning: skipped shine version check"));
858 assert!(warning.contains("HTTP 403 Forbidden"));
859 assert!(!warning.contains("Update check failed"));
860 }
861
862 #[test]
863 fn config_upgrade_summary_parts_includes_only_nonzero_counts() {
864 assert_eq!(
865 config_upgrade_summary_parts(2, 0, 0),
866 vec!["2 updated".to_string()]
867 );
868 }
869
870 #[test]
871 fn config_upgrade_summary_parts_empty_when_all_zero() {
872 assert!(config_upgrade_summary_parts(0, 0, 0).is_empty());
873 }
874
875 #[test]
876 fn config_upgrade_summary_parts_reports_actionable_counters() {
877 assert_eq!(
878 config_upgrade_summary_parts(1, 2, 3),
879 vec![
880 "1 updated".to_string(),
881 "2 user-modified (kept)".to_string(),
882 "3 link conflicts".to_string(),
883 ]
884 );
885 }
886
887 #[test]
888 fn app_upgrade_failure_count_uses_the_authoritative_report_total() {
889 let report = apps::AppUpgradeReport {
890 failed: 3,
891 ..Default::default()
892 };
893
894 assert_eq!(app_upgrade_failure_count(&report), 3);
895 }
896
897 #[test]
898 fn format_self_upgrade_message_handles_stable_channel() {
899 assert_eq!(
900 format_self_upgrade_message(ReleaseChannel::Stable, "0.21.3", "0.21.4", "v0.21.4",),
901 "Upgraded shine from 0.21.3 to 0.21.4."
902 );
903 }
904
905 #[test]
906 fn format_self_upgrade_message_handles_stable_to_preview_install() {
907 assert_eq!(
908 format_self_upgrade_message(
909 ReleaseChannel::Preview,
910 "0.21.3",
911 "1.0.0-preview",
912 "preview",
913 ),
914 "Installed shine preview 1.0.0-preview over stable 0.21.3 (preview)."
915 );
916 }
917
918 #[test]
919 fn format_self_upgrade_message_handles_preview_to_preview_update() {
920 assert_eq!(
921 format_self_upgrade_message(
922 ReleaseChannel::Preview,
923 "1.0.0-preview",
924 "1.0.1-preview",
925 "preview",
926 ),
927 "Updated shine preview from 1.0.0-preview to 1.0.1-preview (preview)."
928 );
929 }
930}