nex-cli 6.4.0

A keyboard-first launcher for Windows
Documentation
use std::fmt::{Display, Formatter};
use std::path::{Path, PathBuf};

const INSTALLED_UPDATER_RELATIVE_PATH: &str = "scripts/update-nex.ps1";
const INSTALLED_UPDATER_FALLBACK_NAME: &str = "update-nex.ps1";
const DEV_UPDATER_RELATIVE_PATH: &str = "scripts/windows/update-nex.ps1";
const MAX_ANCESTOR_SCAN_DEPTH: usize = 10;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateChannel {
    Stable,
    Beta,
}

impl UpdateChannel {
    pub fn as_arg(self) -> &'static str {
        match self {
            Self::Stable => "stable",
            Self::Beta => "beta",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateLaunchError {
    UnsupportedPlatform,
    EnvironmentUnavailable(String),
    ScriptNotFound { checked_paths: Vec<PathBuf> },
    LaunchFailed(String),
}

impl Display for UpdateLaunchError {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::UnsupportedPlatform => write!(f, "updater is only available on Windows"),
            Self::EnvironmentUnavailable(message) => write!(f, "{message}"),
            Self::ScriptNotFound { checked_paths } => {
                if checked_paths.is_empty() {
                    write!(f, "update script not found")
                } else {
                    let joined = checked_paths
                        .iter()
                        .map(|path| path.display().to_string())
                        .collect::<Vec<_>>()
                        .join(", ");
                    write!(f, "update script not found (checked: {joined})")
                }
            }
            Self::LaunchFailed(message) => write!(f, "{message}"),
        }
    }
}

impl std::error::Error for UpdateLaunchError {}

pub fn launch_updater(channel: UpdateChannel) -> Result<PathBuf, UpdateLaunchError> {
    let exe_path = std::env::current_exe().map_err(|error| {
        UpdateLaunchError::EnvironmentUnavailable(format!(
            "could not resolve current executable path: {error}"
        ))
    })?;
    let cwd = std::env::current_dir().map_err(|error| {
        UpdateLaunchError::EnvironmentUnavailable(format!(
            "could not resolve current working directory: {error}"
        ))
    })?;
    let checked_paths = updater_script_candidates(&exe_path, &cwd);
    let script_path = checked_paths
        .iter()
        .find(|candidate| candidate.exists())
        .cloned()
        .ok_or_else(|| UpdateLaunchError::ScriptNotFound {
            checked_paths: checked_paths.clone(),
        })?;

    launch_updater_script(script_path.as_path(), channel)?;
    Ok(script_path)
}

#[cfg(target_os = "windows")]
fn launch_updater_script(script_path: &Path, channel: UpdateChannel) -> Result<(), UpdateLaunchError> {
    std::process::Command::new("powershell.exe")
        .arg("-NoProfile")
        .arg("-ExecutionPolicy")
        .arg("Bypass")
        .arg("-File")
        .arg(script_path)
        .arg("-Channel")
        .arg(channel.as_arg())
        .spawn()
        .map_err(|error| {
            UpdateLaunchError::LaunchFailed(format!(
                "failed to launch updater script '{}': {error}",
                script_path.display()
            ))
        })?;
    Ok(())
}

#[cfg(not(target_os = "windows"))]
fn launch_updater_script(
    _script_path: &Path,
    _channel: UpdateChannel,
) -> Result<(), UpdateLaunchError> {
    Err(UpdateLaunchError::UnsupportedPlatform)
}

fn updater_script_candidates(exe_path: &Path, cwd: &Path) -> Vec<PathBuf> {
    let mut candidates = Vec::new();

    if let Some(exe_dir) = exe_path.parent() {
        if let Some(install_root) = exe_dir.parent() {
            push_unique(
                &mut candidates,
                install_root.join(INSTALLED_UPDATER_RELATIVE_PATH),
            );
            push_unique(
                &mut candidates,
                install_root.join(INSTALLED_UPDATER_FALLBACK_NAME),
            );
        }
        collect_ancestor_candidates(exe_dir, &mut candidates, DEV_UPDATER_RELATIVE_PATH);
    }

    collect_ancestor_candidates(cwd, &mut candidates, DEV_UPDATER_RELATIVE_PATH);

    candidates
}

fn collect_ancestor_candidates(base: &Path, out: &mut Vec<PathBuf>, relative: &str) {
    for ancestor in base.ancestors().take(MAX_ANCESTOR_SCAN_DEPTH) {
        push_unique(out, ancestor.join(relative));
    }
}

fn push_unique(paths: &mut Vec<PathBuf>, candidate: PathBuf) {
    if !paths.iter().any(|existing| existing == &candidate) {
        paths.push(candidate);
    }
}

#[cfg(test)]
mod tests {
    use super::{updater_script_candidates, DEV_UPDATER_RELATIVE_PATH, INSTALLED_UPDATER_RELATIVE_PATH};

    #[test]
    fn updater_candidates_prefer_installed_layout_before_repo_fallback() {
        let root = std::env::temp_dir().join(format!(
            "nex-updater-installed-{}",
            std::process::id()
        ));
        let exe_path = root.join("bin/nex.exe");
        let cwd = root.clone();

        let candidates = updater_script_candidates(&exe_path, &cwd);

        assert_eq!(candidates[0], root.join(INSTALLED_UPDATER_RELATIVE_PATH));
        assert_eq!(candidates[1], root.join("update-nex.ps1"));
    }

    #[test]
    fn updater_candidates_include_repo_style_script_lookup() {
        let root = std::env::temp_dir().join(format!(
            "nex-updater-repo-{}",
            std::process::id()
        ));
        let repo = root.join("repo");
        let exe_path = repo.join("target/debug/nex.exe");
        let cwd = repo.join("apps/core");

        let candidates = updater_script_candidates(&exe_path, &cwd);

        assert!(candidates
            .iter()
            .any(|candidate| candidate == &repo.join(DEV_UPDATER_RELATIVE_PATH)));
    }

    #[test]
    fn updater_candidates_are_deduplicated() {
        let repo = std::env::temp_dir().join(format!(
            "nex-updater-dedupe-{}",
            std::process::id()
        ));
        let exe_path = repo.join("target/debug/nex.exe");
        let cwd = repo.join("target/debug");

        let candidates = updater_script_candidates(&exe_path, &cwd);
        let unique = candidates.iter().collect::<std::collections::BTreeSet<_>>();

        assert_eq!(candidates.len(), unique.len());
    }
}