Skip to main content

cli/
self_install.rs

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_lifecycle
321        .outcomes
322        .iter()
323        .filter(|outcome| {
324            outcome
325                .diagnostic_codes
326                .iter()
327                .any(|code| code == "app_generator_unavailable")
328        })
329        .count();
330    if fatal_app_failures > 0 {
331        bail!(
332            "{} generated app configuration item(s) failed",
333            fatal_app_failures
334        );
335    }
336
337    if sys_report.failed > 0 {
338        bail!(
339            "{} managed system configuration item(s) failed",
340            sys_report.failed
341        );
342    }
343
344    Ok(())
345}
346
347async fn handle_config_target_upgrade(
348    config: &Config,
349    target: &str,
350    verbose: bool,
351    prune_stale: bool,
352    yes: bool,
353) -> Result<()> {
354    use crate::shim::{PresetKind, resolve_preset_kind};
355
356    let target = target.trim();
357    if target.is_empty() {
358        bail!("upgrade target must not be empty");
359    }
360
361    let mut sep = if verbose {
362        println!("{}", colors::bold(&format!("Upgrading {target}")));
363        config::print_presets_note(config);
364        output::SectionSeparator::new()
365    } else {
366        output::SectionSeparator::with_preamble(colors::bold(&format!("Upgrading {target}")))
367    };
368
369    let (updated, user_modified, link_conflicts, failed, restart_hints) =
370        if let Some(item) = target.strip_prefix("sys/") {
371            if item.is_empty() || item.contains('/') {
372                bail!("invalid system target `{target}`; expected sys/<item>");
373            }
374            if prune_stale {
375                bail!("`--prune-stale` applies only to app targets");
376            }
377            let (report, lifecycle) =
378                Box::pin(sys::handle_upgrade_managed_target_with_result_approved(
379                    config,
380                    Some(item),
381                    verbose,
382                    yes,
383                    &mut sep,
384                ))
385                .await?;
386            (
387                lifecycle.summary().changed,
388                lifecycle.summary().preserved + lifecycle.summary().conflicts,
389                0,
390                report.failed,
391                Default::default(),
392            )
393        } else {
394            let normalized = if let Some(rest) = target.strip_prefix("app/") {
395                let category = rest.split('/').next().unwrap_or_default();
396                format!("app/{category}")
397            } else if let Some(rest) = target.strip_prefix("shell/") {
398                let category = rest.split('/').next().unwrap_or_default();
399                format!("shell/{category}")
400            } else {
401                target.to_string()
402            };
403            let (kind, category) = resolve_preset_kind(config, &normalized).await?;
404            match kind {
405                PresetKind::App => {
406                    let (report, lifecycle) =
407                        Box::pin(apps::handle_upgrade_installed_target_with_result_approved(
408                            config,
409                            Some(&category),
410                            prune_stale,
411                            verbose,
412                            yes,
413                            &mut sep,
414                        ))
415                        .await?;
416                    (
417                        changed_app_categories(&lifecycle),
418                        preserved_app_resources(&lifecycle),
419                        0,
420                        lifecycle
421                            .outcomes
422                            .iter()
423                            .filter(|outcome| {
424                                outcome
425                                    .diagnostic_codes
426                                    .iter()
427                                    .any(|code| code == "app_generator_unavailable")
428                            })
429                            .count(),
430                        report.restart_hints,
431                    )
432                }
433                PresetKind::Shell => {
434                    if prune_stale {
435                        bail!("`--prune-stale` applies only to app targets");
436                    }
437                    let (report, lifecycle) = Box::pin(
438                        shells::handle_upgrade_installed_target_with_result_approved(
439                            config,
440                            Some(&category),
441                            verbose,
442                            yes,
443                            &mut sep,
444                        ),
445                    )
446                    .await?;
447                    (
448                        changed_shell_categories(&lifecycle) + usize::from(report.path_changed),
449                        0,
450                        lifecycle.summary().conflicts,
451                        0,
452                        Default::default(),
453                    )
454                }
455            }
456        };
457
458    let summary = config_upgrade_summary_parts(updated, user_modified, link_conflicts);
459    if verbose || sep.has_printed() {
460        output::footer("Done", &summary);
461    } else {
462        println!("{}", colors::dim("Nothing to upgrade."));
463    }
464    for hint in restart_hints {
465        println!("  {} {}", colors::symbol("!"), colors::yellow(&hint));
466    }
467    if failed > 0 {
468        bail!("{failed} managed configuration item(s) failed");
469    }
470    Ok(())
471}
472
473fn config_upgrade_summary_parts(
474    updated: usize,
475    user_modified: usize,
476    link_conflicts: usize,
477) -> Vec<String> {
478    let mut parts = Vec::new();
479    output::push_count(&mut parts, updated, colors::green, "updated");
480    output::push_count(
481        &mut parts,
482        user_modified,
483        colors::yellow,
484        "user-modified (kept)",
485    );
486    output::push_count(&mut parts, link_conflicts, colors::yellow, "link conflicts");
487    parts
488}
489
490fn changed_shell_categories(result: &shine_core::lifecycle::LifecycleResultV1) -> usize {
491    result
492        .outcomes
493        .iter()
494        .filter(|outcome| {
495            outcome.status == shine_core::lifecycle::LifecycleStatus::Changed
496                && outcome.target.starts_with("shell/")
497                && outcome.effects.iter().any(|effect| {
498                    !matches!(effect, shine_core::lifecycle::LifecycleEffect::CacheWritten)
499                })
500        })
501        .filter_map(|outcome| outcome.target.split('/').nth(1))
502        .collect::<std::collections::BTreeSet<_>>()
503        .len()
504}
505
506fn is_app_auxiliary_resource(resource: Option<&str>) -> bool {
507    matches!(
508        resource,
509        Some(
510            "preset-cache"
511                | "purge"
512                | "hook:post-install"
513                | "hook:post-upgrade"
514                | "artifact:teardown"
515        )
516    )
517}
518
519fn changed_app_categories(result: &shine_core::lifecycle::LifecycleResultV1) -> usize {
520    result
521        .outcomes
522        .iter()
523        .filter(|outcome| {
524            outcome.status == shine_core::lifecycle::LifecycleStatus::Changed
525                && outcome.target.starts_with("app/")
526                && !is_app_auxiliary_resource(outcome.resource.as_deref())
527        })
528        .map(|outcome| outcome.target.as_str())
529        .collect::<std::collections::BTreeSet<_>>()
530        .len()
531}
532
533fn preserved_app_resources(result: &shine_core::lifecycle::LifecycleResultV1) -> usize {
534    result
535        .outcomes
536        .iter()
537        .filter(|outcome| {
538            matches!(
539                outcome.status,
540                shine_core::lifecycle::LifecycleStatus::Preserved
541                    | shine_core::lifecycle::LifecycleStatus::Conflict
542            ) && outcome.target.starts_with("app/")
543                && !is_app_auxiliary_resource(outcome.resource.as_deref())
544        })
545        .count()
546}
547
548/// After a successful self-upgrade, try to sync the new binary to the self-install destination.
549/// If the copy fails due to permissions, print a targeted hint instead of failing.
550async fn sync_self_install_dest(config: &Config, src: &std::path::Path) {
551    let dest = match &config.self_install_dest {
552        Some(d) => d,
553        None => return,
554    };
555    match sync_self_install_dest_from(src, dest).await {
556        Ok(SelfInstallSync::Synced) => println!(
557            "{}",
558            colors::green(&format!("Synced system copy at {}", dest.display()))
559        ),
560        Ok(SelfInstallSync::AlreadyCurrent) => {}
561        // Unix already tried `sudo` automatically inside `install_binary_with_elevation`;
562        // reaching here means it was declined or unavailable non-interactively. Windows has
563        // no such auto-elevation path, so it still needs the manual hint.
564        Err(e) if cfg!(windows) && has_io_error_kind(&e, std::io::ErrorKind::PermissionDenied) => {
565            let hint = format!(
566                "Installed copy at {} needs manual sync; rerun from an elevated terminal if needed.",
567                dest.display()
568            );
569            println!("{}", colors::yellow(&hint));
570        }
571        Err(e) => eprintln!(
572            "Warning: failed to sync system copy at {}: {e}",
573            dest.display()
574        ),
575    }
576}
577
578enum SelfInstallSync {
579    Synced,
580    AlreadyCurrent,
581}
582
583async fn sync_self_install_dest_from(
584    src: &std::path::Path,
585    dest: &std::path::Path,
586) -> Result<SelfInstallSync> {
587    if dest.exists() {
588        let canonical_src = src.canonicalize().unwrap_or_else(|_| src.to_path_buf());
589        let canonical_dest = dest.canonicalize().unwrap_or_else(|_| dest.to_path_buf());
590        if canonical_src == canonical_dest {
591            return Ok(SelfInstallSync::AlreadyCurrent);
592        }
593    }
594
595    install_binary_with_elevation(src, dest)
596        .await
597        .map(|()| SelfInstallSync::Synced)
598}
599
600fn has_io_error_kind(err: &anyhow::Error, kind: std::io::ErrorKind) -> bool {
601    err.chain().any(|cause| {
602        cause
603            .downcast_ref::<std::io::Error>()
604            .is_some_and(|io_err| io_err.kind() == kind)
605    })
606}
607
608pub async fn handle_self_install(
609    mut config: Config,
610    dest: Option<std::path::PathBuf>,
611) -> Result<()> {
612    use anyhow::{Context as _, bail};
613
614    let src = std::env::current_exe().context("failed to resolve current executable path")?;
615    let dest = match dest {
616        Some(dest) => dest,
617        None => platform::default_self_install_dest()?,
618    };
619
620    if dest.exists() {
621        let canonical_src = src.canonicalize().unwrap_or_else(|_| src.clone());
622        let canonical_dest = dest.canonicalize().unwrap_or_else(|_| dest.clone());
623        if canonical_src == canonical_dest {
624            let example = if cfg!(windows) {
625                r"C:\path\to\new\shine.exe self install"
626            } else {
627                "sudo /path/to/new/shine self install"
628            };
629            bail!(
630                "source and destination are the same binary: {}. Run the newer binary by full path, e.g. `{example}`, to overwrite this copy.",
631                dest.display()
632            );
633        }
634    }
635
636    install_binary_with_elevation(&src, &dest)
637        .await
638        .with_context(|| self_install_failure_hint(&dest))?;
639
640    // Remember where we installed so `shine self upgrade` can sync this copy automatically.
641    config.self_install_dest = Some(dest.clone());
642    config
643        .save()
644        .await
645        .context("failed to save self_install_dest to config")?;
646
647    println!(
648        "{}",
649        colors::green(&format!("installed to {}", dest.display()))
650    );
651    print_self_install_activation_hint(&dest);
652
653    Ok(())
654}
655
656fn self_install_failure_hint(dest: &std::path::Path) -> String {
657    format!("failed to copy to {}", dest.display())
658}
659
660fn print_self_install_activation_hint(dest: &std::path::Path) {
661    let Some(dir) = dest.parent() else {
662        return;
663    };
664    if platform::current_path_contains_dir(dir) {
665        println!(
666            "{}",
667            colors::dim("The install directory is already on PATH.")
668        );
669    } else {
670        println!(
671            "{}",
672            colors::yellow(&format!(
673                "Install directory is not on PATH: {}",
674                dir.display()
675            ))
676        );
677        println!("{}", colors::dim(&platform::path_install_hint(dir)));
678    }
679}
680
681fn install_binary_atomically(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
682    use anyhow::Context as _;
683
684    let parent = dest
685        .parent()
686        .with_context(|| format!("destination has no parent: {}", dest.display()))?;
687    std::fs::create_dir_all(parent)
688        .with_context(|| format!("failed to create destination dir: {}", parent.display()))?;
689
690    let temp = parent.join(format!(".shine-self-install-{}", uuid::Uuid::new_v4()));
691    std::fs::copy(src, &temp).with_context(|| {
692        format!(
693            "failed to stage binary from {} to {}",
694            src.display(),
695            temp.display()
696        )
697    })?;
698
699    #[cfg(unix)]
700    {
701        use std::os::unix::fs::PermissionsExt;
702        let mode = std::fs::metadata(src)
703            .map(|m| m.permissions().mode())
704            .unwrap_or(0o755);
705        std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(mode))
706            .with_context(|| format!("failed to set permissions on {}", temp.display()))?;
707    }
708
709    match std::fs::rename(&temp, dest) {
710        Ok(()) => Ok(()),
711        Err(err) => {
712            let _ = std::fs::remove_file(&temp);
713            Err(err)
714                .with_context(|| format!("failed to replace {} with staged binary", dest.display()))
715        }
716    }
717}
718
719/// Installs the binary, auto-elevating via `sudo` on Unix if the plain copy
720/// fails because the destination isn't user-writable (e.g. `/usr/local/bin`
721/// owned by root). The CLI-owned administrator adapter serializes this with
722/// other privileged Shine writes so parallel installs cannot race.
723async fn install_binary_with_elevation(
724    src: &std::path::Path,
725    dest: &std::path::Path,
726) -> Result<()> {
727    match install_binary_atomically(src, dest) {
728        Ok(()) => Ok(()),
729        Err(e)
730            if !cfg!(windows)
731                && has_io_error_kind(&e, std::io::ErrorKind::PermissionDenied)
732                && !std::env::var("USER").is_ok_and(|user| user == "root") =>
733        {
734            let _lock = crate::admin_fs::admin_lock().await?;
735            install_binary_privileged(src, dest).await
736        }
737        Err(e) => Err(e),
738    }
739}
740
741#[cfg(unix)]
742async fn install_binary_privileged(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
743    use anyhow::Context as _;
744    use std::os::unix::fs::PermissionsExt;
745
746    if !privilege::ensure_admin(1).await? {
747        anyhow::bail!("administrator permission was not granted");
748    }
749
750    let parent = dest
751        .parent()
752        .with_context(|| format!("destination has no parent: {}", dest.display()))?;
753    let mode = std::fs::metadata(src)
754        .map(|m| m.permissions().mode())
755        .unwrap_or(0o755);
756
757    let status = crate::admin_fs::sudo_command()
758        .arg("mkdir")
759        .arg("-p")
760        .arg(parent)
761        .status()
762        .await
763        .context("failed to create privileged destination directory")?;
764    if !status.success() {
765        anyhow::bail!("administrator permission was not granted");
766    }
767
768    let status = crate::admin_fs::sudo_command()
769        .args(["install", "-m", &format!("{mode:o}"), "--"])
770        .arg(src)
771        .arg(dest)
772        .status()
773        .await
774        .context("failed to install shine binary with administrator privileges")?;
775    if !status.success() {
776        anyhow::bail!("failed to install shine binary with administrator privileges");
777    }
778
779    Ok(())
780}
781
782#[cfg(not(unix))]
783async fn install_binary_privileged(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
784    // No auto-elevation path on Windows: privileged binary copies go through
785    // an elevated terminal instead. Fall back to the plain unprivileged copy
786    // so the caller's original error surfaces if it still fails.
787    install_binary_atomically(src, dest)
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    async fn make_temp_dir() -> std::path::PathBuf {
795        crate::test_support::make_temp_dir("shine-self-install-test").await
796    }
797
798    fn config_in(dir: &std::path::Path) -> Config {
799        crate::test_support::test_config(dir)
800    }
801
802    #[test]
803    fn install_binary_atomically_overwrites_existing_dest() {
804        let dir = std::env::temp_dir().join(format!("shine-self-install-{}", uuid::Uuid::new_v4()));
805        std::fs::create_dir_all(&dir).unwrap();
806        let src = dir.join("new-shine");
807        let dest = dir.join("shine");
808
809        std::fs::write(&src, b"new").unwrap();
810        std::fs::write(&dest, b"old").unwrap();
811
812        install_binary_atomically(&src, &dest).unwrap();
813
814        assert_eq!(std::fs::read(&dest).unwrap(), b"new");
815        std::fs::remove_dir_all(&dir).unwrap();
816    }
817
818    #[tokio::test]
819    async fn sync_self_install_dest_creates_missing_parent() {
820        let dir = std::env::temp_dir().join(format!("shine-self-sync-{}", uuid::Uuid::new_v4()));
821        let src = dir.join("new-shine");
822        let dest = dir.join("usr/local/bin/shine");
823
824        std::fs::create_dir_all(&dir).unwrap();
825        std::fs::write(&src, b"new").unwrap();
826
827        let outcome = sync_self_install_dest_from(&src, &dest).await.unwrap();
828
829        assert!(matches!(outcome, SelfInstallSync::Synced));
830        assert_eq!(std::fs::read(&dest).unwrap(), b"new");
831        std::fs::remove_dir_all(&dir).unwrap();
832    }
833
834    #[tokio::test]
835    async fn sync_self_install_dest_skips_current_exe_path() {
836        let dir = std::env::temp_dir().join(format!("shine-self-sync-{}", uuid::Uuid::new_v4()));
837        let src = dir.join("shine");
838
839        std::fs::create_dir_all(&dir).unwrap();
840        std::fs::write(&src, b"new").unwrap();
841
842        let outcome = sync_self_install_dest_from(&src, &src).await.unwrap();
843
844        assert!(matches!(outcome, SelfInstallSync::AlreadyCurrent));
845        assert_eq!(std::fs::read(&src).unwrap(), b"new");
846        std::fs::remove_dir_all(&dir).unwrap();
847    }
848
849    #[tokio::test]
850    async fn self_install_errors_when_source_is_destination() {
851        let dir = make_temp_dir().await;
852        let config = config_in(&dir);
853        let current = std::env::current_exe().unwrap();
854
855        let err = handle_self_install(config, Some(current))
856            .await
857            .unwrap_err();
858        assert!(
859            err.to_string()
860                .contains("source and destination are the same binary"),
861            "error should explain self-overwrite: {err:#}"
862        );
863
864        tokio::fs::remove_dir_all(&dir).await.unwrap();
865    }
866
867    #[test]
868    fn update_check_failure_warning_is_non_fatal_wording() {
869        let err = anyhow::anyhow!(
870            "GitHub stable release request failed: HTTP 403 Forbidden: API rate limit exceeded"
871        );
872        let warning = format_update_check_failure_warning(&err);
873
874        assert!(warning.contains("warning: skipped shine version check"));
875        assert!(warning.contains("HTTP 403 Forbidden"));
876        assert!(!warning.contains("Update check failed"));
877    }
878
879    #[test]
880    fn config_upgrade_summary_parts_includes_only_nonzero_counts() {
881        assert_eq!(
882            config_upgrade_summary_parts(2, 0, 0),
883            vec!["2 updated".to_string()]
884        );
885    }
886
887    #[test]
888    fn config_upgrade_summary_parts_empty_when_all_zero() {
889        assert!(config_upgrade_summary_parts(0, 0, 0).is_empty());
890    }
891
892    #[test]
893    fn config_upgrade_summary_parts_reports_actionable_counters() {
894        assert_eq!(
895            config_upgrade_summary_parts(1, 2, 3),
896            vec![
897                "1 updated".to_string(),
898                "2 user-modified (kept)".to_string(),
899                "3 link conflicts".to_string(),
900            ]
901        );
902    }
903
904    #[test]
905    fn format_self_upgrade_message_handles_stable_channel() {
906        assert_eq!(
907            format_self_upgrade_message(ReleaseChannel::Stable, "0.21.3", "0.21.4", "v0.21.4",),
908            "Upgraded shine from 0.21.3 to 0.21.4."
909        );
910    }
911
912    #[test]
913    fn format_self_upgrade_message_handles_stable_to_preview_install() {
914        assert_eq!(
915            format_self_upgrade_message(
916                ReleaseChannel::Preview,
917                "0.21.3",
918                "1.0.0-preview",
919                "preview",
920            ),
921            "Installed shine preview 1.0.0-preview over stable 0.21.3 (preview)."
922        );
923    }
924
925    #[test]
926    fn format_self_upgrade_message_handles_preview_to_preview_update() {
927        assert_eq!(
928            format_self_upgrade_message(
929                ReleaseChannel::Preview,
930                "1.0.0-preview",
931                "1.0.1-preview",
932                "preview",
933            ),
934            "Updated shine preview from 1.0.0-preview to 1.0.1-preview (preview)."
935        );
936    }
937}