topgrade 17.4.0

Upgrade all the things
use crate::command::CommandExt;
use crate::execution_context::ExecutionContext;
use crate::step::Step;
use crate::terminal::{print_separator, prompt_yesno};
use crate::utils::require;
use color_eyre::eyre::Result;
use rust_i18n::t;
use std::collections::HashSet;
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use tracing::debug;

pub fn run_macports(ctx: &ExecutionContext) -> Result<()> {
    let port = require("port")?;

    print_separator("MacPorts");

    let sudo = ctx.require_sudo()?;

    sudo.execute(ctx, &port)?.arg("selfupdate").status_checked()?;
    sudo.execute(ctx, &port)?
        .args(["-u", "upgrade", "outdated"])
        .status_checked()?;
    if ctx.config().cleanup() {
        sudo.execute(ctx, &port)?.args(["-N", "reclaim"]).status_checked()?;
    }

    Ok(())
}

pub fn run_mas(ctx: &ExecutionContext) -> Result<()> {
    let mas = require("mas")?;
    print_separator(t!("macOS App Store"));

    ctx.execute(mas).arg("upgrade").status_checked()
}

pub fn run_microsoft_office(ctx: &ExecutionContext) -> Result<()> {
    let msupdate = find_msupdate()?;
    print_separator("Microsoft Office");

    // Check for available updates first
    let output = ctx.execute(&msupdate).always().arg("--list").output_checked_utf8()?;

    debug!("msupdate --list output: {:?}", output);

    if output.stdout.contains("No updates available") {
        io::stdout().write_all(output.stdout.as_bytes())?;
        io::stderr().write_all(output.stderr.as_bytes())?;
        return Ok(());
    }

    // Install updates, waiting up to 600 seconds for completion
    ctx.execute(&msupdate)
        .args(["--install", "--wait", "600"])
        .status_checked()
}

fn find_msupdate() -> Result<PathBuf> {
    // Known paths where Microsoft AutoUpdate places msupdate
    let candidates = [
        "/Library/Application Support/Microsoft/MAU2.0/Microsoft AutoUpdate.app/Contents/MacOS/msupdate",
        "/Library/Application Support/Microsoft/MAU 2.0/Microsoft AutoUpdate.app/Contents/MacOS/msupdate",
    ];

    for path in &candidates {
        let p = PathBuf::from(path);
        if p.exists() {
            debug!("Found msupdate at {:?}", p);
            return Ok(p);
        }
    }

    Err(crate::error::SkipStep("Microsoft AutoUpdate (msupdate) not found".to_string()).into())
}

pub fn upgrade_macos(ctx: &ExecutionContext) -> Result<()> {
    print_separator(t!("macOS system update"));

    let should_ask = !(ctx.config().yes(Step::System) || ctx.run_type().dry());
    if should_ask {
        println!("{}", t!("Finding available software"));
        if system_update_available(ctx)? {
            let answer = prompt_yesno(t!("A system update is available. Do you wish to install it?").as_ref())?;
            if !answer {
                return Ok(());
            }
            println!();
        } else {
            println!("{}", t!("No new software available."));
            return Ok(());
        }
    }

    let mut command = ctx.execute("softwareupdate");
    command.args(["--install", "--all"]);

    if should_ask {
        command.arg("--no-scan");
    }

    command.status_checked()
}

fn system_update_available(ctx: &ExecutionContext) -> Result<bool> {
    let output = ctx
        .execute("softwareupdate")
        .always()
        .arg("--list")
        .output_checked_utf8()?;

    debug!("{:?}", output);

    Ok(!output.stderr.contains("No new software available"))
}

pub fn run_sparkle(ctx: &ExecutionContext) -> Result<()> {
    let sparkle = require("sparkle")?;

    print_separator("Sparkle");

    for application in (fs::read_dir("/Applications")?).flatten() {
        let probe = ctx
            .execute(&sparkle)
            .always()
            .arg(application.path())
            .args(["--probe", "--user-agent-name", "topgrade"])
            .output_checked_utf8();
        if probe.is_ok() {
            let mut command = ctx.execute(&sparkle);
            command.arg(application.path());
            command.args(["--check-immediately", "--user-agent-name", "topgrade"]);
            command.status_checked()?;
        }
    }
    Ok(())
}

pub fn update_xcodes(ctx: &ExecutionContext) -> Result<()> {
    let xcodes = require("xcodes")?;
    print_separator("Xcodes");

    let should_ask = !(ctx.config().yes(Step::Xcodes) || ctx.run_type().dry());

    let releases = ctx.execute(&xcodes).args(["update"]).output_checked_utf8()?.stdout;

    let releases_installed: Vec<String> = releases
        .lines()
        .filter(|r| r.contains("(Installed)"))
        .map(String::from)
        .collect();

    if releases_installed.is_empty() {
        println!("{}", t!("No Xcode releases installed."));
        return Ok(());
    }

    let (installed_gm, installed_beta, installed_regular) =
        releases_installed
            .iter()
            .fold((false, false, false), |(gm, beta, regular), release| {
                (
                    gm || release.contains("GM") || release.contains("Release Candidate"),
                    beta || release.contains("Beta"),
                    regular
                        || !(release.contains("GM")
                            || release.contains("Release Candidate")
                            || release.contains("Beta")),
                )
            });

    let releases_gm = releases
        .lines()
        .filter(|&r| r.matches("GM").count() > 0 || r.matches("Release Candidate").count() > 0)
        .map(String::from)
        .collect();
    let releases_beta = releases
        .lines()
        .filter(|&r| r.matches("Beta").count() > 0)
        .map(String::from)
        .collect();
    let releases_regular = releases
        .lines()
        .filter(|&r| {
            r.matches("GM").count() == 0
                && r.matches("Release Candidate").count() == 0
                && r.matches("Beta").count() == 0
        })
        .map(String::from)
        .collect();

    if installed_gm {
        process_xcodes_releases(releases_gm, should_ask, ctx)?;
    }
    if installed_beta {
        process_xcodes_releases(releases_beta, should_ask, ctx)?;
    }
    if installed_regular {
        process_xcodes_releases(releases_regular, should_ask, ctx)?;
    }

    let releases_new = ctx
        .execute(&xcodes)
        .always()
        .args(["list"])
        .output_checked_utf8()?
        .stdout;

    let releases_gm_new_installed: HashSet<_> = releases_new
        .lines()
        .filter(|release| {
            release.contains("(Installed)") && (release.contains("GM") || release.contains("Release Candidate"))
        })
        .collect();
    let releases_beta_new_installed: HashSet<_> = releases_new
        .lines()
        .filter(|release| release.contains("(Installed)") && release.contains("Beta"))
        .collect();
    let releases_regular_new_installed: HashSet<_> = releases_new
        .lines()
        .filter(|release| {
            release.contains("(Installed)")
                && !(release.contains("GM") || release.contains("Release Candidate") || release.contains("Beta"))
        })
        .collect();

    for releases_new_installed in [
        releases_gm_new_installed,
        releases_beta_new_installed,
        releases_regular_new_installed,
    ] {
        if should_ask && releases_new_installed.len() == 2 {
            let answer_uninstall =
                prompt_yesno(t!("Would you like to move the former Xcode release to the trash?").as_ref())?;
            if answer_uninstall {
                let _ = ctx
                    .execute(&xcodes)
                    .args([
                        "uninstall",
                        releases_new_installed.iter().next().copied().unwrap_or_default(),
                    ])
                    .status_checked();
            }
        }
    }

    Ok(())
}

pub fn process_xcodes_releases(releases_filtered: Vec<String>, should_ask: bool, ctx: &ExecutionContext) -> Result<()> {
    let xcodes = require("xcodes")?;

    if releases_filtered.last().is_none_or(|s| !s.contains("(Installed)")) && !releases_filtered.is_empty() {
        println!(
            "{} {}",
            t!("New Xcode release detected:"),
            releases_filtered.last().cloned().unwrap_or_default()
        );
        if should_ask {
            let answer_install = prompt_yesno(t!("Would you like to install it?").as_ref())?;
            if answer_install {
                let _ = ctx
                    .execute(xcodes)
                    .args(["install", &releases_filtered.last().cloned().unwrap_or_default()])
                    .status_checked();
            }
            println!();
        }
    }

    Ok(())
}