whitaker-installer 0.2.7

Installer CLI for Whitaker Dylint lint libraries
Documentation
//! Dependency installation for Dylint tools.
//!
//! This module checks whether `cargo-dylint` and `dylint-link` are already
//! available, then installs any missing tools by preferring repository-hosted
//! release archives before falling back to `cargo binstall` or `cargo install`.

use crate::dependency_binaries::{
    DependencyBinaryInstaller, RepositoryDependencyBinaryInstaller, find_dependency_binary,
    host_target,
};
use crate::dirs::{BaseDirs, SystemBaseDirs};
use crate::error::{InstallerError, Result};
use std::io;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Output};

mod install;
use install::*;

/// Abstraction for running external commands.
pub trait CommandExecutor {
    /// Runs a command with arguments and returns the captured output.
    ///
    /// # Errors
    ///
    /// Returns any I/O errors encountered while spawning or running the command.
    fn run(&self, cmd: &str, args: &[&str]) -> Result<Output>;
}

/// Executes commands on the host system.
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemCommandExecutor;

impl CommandExecutor for SystemCommandExecutor {
    fn run(&self, cmd: &str, args: &[&str]) -> Result<Output> {
        Command::new(cmd)
            .args(args)
            .output()
            .map_err(InstallerError::from)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct DependencyTool {
    package: &'static str,
    command: &'static str,
    args: &'static [&'static str],
}

const CARGO_DYLINT_TOOL: DependencyTool = DependencyTool {
    package: "cargo-dylint",
    command: "cargo",
    args: &["dylint", "--version"],
};

const DYLINT_LINK_TOOL: DependencyTool = DependencyTool {
    package: "dylint-link",
    command: "dylint-link",
    args: &["--version"],
};

const DEPENDENCY_TOOLS: [DependencyTool; 2] = [CARGO_DYLINT_TOOL, DYLINT_LINK_TOOL];

/// Status of Dylint tool availability.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DylintToolStatus {
    /// Whether `cargo-dylint` is installed.
    pub cargo_dylint: bool,
    /// Whether `dylint-link` is installed.
    pub dylint_link: bool,
}

impl DylintToolStatus {
    /// Returns `true` when both tools are installed.
    #[must_use]
    pub fn all_installed(&self) -> bool {
        self.cargo_dylint && self.dylint_link
    }
}

/// Additional install options used by test-support hooks.
#[cfg(any(test, feature = "test-support"))]
pub struct DependencyInstallOptions<'a> {
    /// Base directories used by the repository installer.
    pub dirs: &'a dyn BaseDirs,
    /// The repository-first installer implementation.
    pub repository_installer: &'a dyn DependencyBinaryInstaller,
    /// Host target override used for repository asset naming.
    pub target: Option<crate::installer_packaging::TargetTriple>,
    /// Whether stderr output should be suppressed.
    pub quiet: bool,
}

/// Checks whether the Dylint tools are installed.
#[must_use]
pub fn check_dylint_tools(executor: &dyn CommandExecutor) -> DylintToolStatus {
    DylintToolStatus {
        cargo_dylint: is_tool_installed(executor, &CARGO_DYLINT_TOOL),
        dylint_link: is_tool_installed(executor, &DYLINT_LINK_TOOL),
    }
}

/// Install missing tools without emitting progress output.
pub fn install_dylint_tools(
    executor: &dyn CommandExecutor,
    status: &DylintToolStatus,
) -> Result<()> {
    let mut sink = io::sink();
    install_dylint_tools_with_output(executor, status, true, &mut sink)
}

/// Install missing tools while writing progress output to `stderr`.
pub fn install_dylint_tools_with_output(
    executor: &dyn CommandExecutor,
    status: &DylintToolStatus,
    quiet: bool,
    stderr: &mut dyn Write,
) -> Result<()> {
    let repository_installer = RepositoryDependencyBinaryInstaller;
    let system_dirs = SystemBaseDirs::new();
    let target = host_target();
    let dirs = system_dirs.as_ref().map(|dirs| dirs as &dyn BaseDirs);
    let cargo_fallback_mode = cargo_fallback_mode(executor);
    install_missing_tools(
        executor,
        status,
        stderr,
        &InstallContext {
            repo: repository_install_context(
                dirs,
                Some(&repository_installer as &dyn DependencyBinaryInstaller),
                target.as_ref(),
            ),
            cargo_fallback_mode,
            quiet,
        },
    )
}

/// Install missing tools with injected repository-install hooks.
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub fn install_dylint_tools_with_options(
    executor: &dyn CommandExecutor,
    status: &DylintToolStatus,
    stderr: &mut dyn Write,
    options: DependencyInstallOptions<'_>,
) -> Result<()> {
    let cargo_fallback_mode = cargo_fallback_mode(executor);
    install_missing_tools(
        executor,
        status,
        stderr,
        &InstallContext {
            repo: repository_install_context(
                Some(options.dirs),
                Some(options.repository_installer),
                options.target.as_ref(),
            ),
            cargo_fallback_mode,
            quiet: options.quiet,
        },
    )
}

/// Arguments used to query Cargo's registry of installed binaries.
const CARGO_INSTALL_LIST_ARGS: [&str; 2] = ["install", "--list"];

fn is_tool_installed(executor: &dyn CommandExecutor, tool: &DependencyTool) -> bool {
    // The manifest is embedded in the binary, so a lookup failure is
    // effectively unreachable. Should it ever occur, degrade to the previous
    // success-only probes rather than reporting every tool as missing.
    let expected_version = find_dependency_binary(tool.package)
        .ok()
        .flatten()
        .map(|dependency| dependency.version());

    if tool == &DYLINT_LINK_TOOL {
        return is_dylint_link_installed(executor, expected_version);
    }
    is_versioned_tool_installed(executor, tool, expected_version)
}

fn is_dylint_link_installed(
    executor: &dyn CommandExecutor,
    expected_version: Option<&str>,
) -> bool {
    // Presence is established by resolving an executable file on PATH.
    // `dylint-link` is a linker wrapper with no reliable self-reporting
    // subcommand, so it is never executed to prove it works.
    if find_binary_on_path(DYLINT_LINK_TOOL.command).is_none() {
        return false;
    }
    let Some(expected_version) = expected_version else {
        return true;
    };
    // `dylint-link` is a pure linker wrapper: it forwards its entire argument
    // list to the underlying linker and can never report its own version, so
    // `dylint-link --version` is not a usable version source. Query Cargo's
    // registry of installed binaries instead, which records the version each
    // binary was installed at.
    cargo_installed_version(executor, DYLINT_LINK_TOOL.package)
        .is_some_and(|version| version == expected_version)
}

fn is_versioned_tool_installed(
    executor: &dyn CommandExecutor,
    tool: &DependencyTool,
    expected_version: Option<&str>,
) -> bool {
    let Some(expected_version) = expected_version else {
        return command_succeeds(executor, tool.command, tool.args);
    };
    executor.run(tool.command, tool.args).is_ok_and(|output| {
        output.status.success()
            && first_semver_token(&String::from_utf8_lossy(&output.stdout))
                .is_some_and(|version| version == expected_version)
    })
}

/// Extracts the installed version of `package` from `cargo install --list`.
///
/// Matches lines of the form `dylint-link v6.0.1:` (or
/// `dylint-link v6.0.1 (/path):` for path installs), returning the version
/// with the leading `v` and trailing `:` stripped.
fn cargo_installed_version(executor: &dyn CommandExecutor, package: &str) -> Option<String> {
    let output = executor.run("cargo", &CARGO_INSTALL_LIST_ARGS).ok()?;
    if !output.status.success() {
        return None;
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    stdout.lines().find_map(|line| {
        let mut tokens = line.split_whitespace();
        if tokens.next() != Some(package) {
            return None;
        }
        tokens
            .next()
            .and_then(|token| token.strip_prefix('v'))
            .map(|version| version.trim_end_matches(':').to_owned())
    })
}

/// Returns the first whitespace-separated token shaped like `MAJOR.MINOR.PATCH`.
fn first_semver_token(text: &str) -> Option<&str> {
    text.split_whitespace().find(|token| {
        let components: Vec<&str> = token.split('.').collect();
        components.len() == 3
            && components.iter().all(|component| {
                !component.is_empty()
                    && component
                        .chars()
                        .all(|character| character.is_ascii_digit())
            })
    })
}

fn is_binstall_available(executor: &dyn CommandExecutor) -> bool {
    command_succeeds(executor, "cargo", &["binstall", "--version"])
}

#[cfg(test)]
fn is_binary_on_path(binary_name: &str) -> bool {
    find_binary_on_path(binary_name).is_some()
}

fn find_binary_on_path(binary_name: &str) -> Option<std::path::PathBuf> {
    let path_var = std::env::var_os("PATH")?;

    std::env::split_paths(&path_var)
        .find_map(|directory| find_binary_in_directory(&directory, binary_name))
}

fn find_binary_in_directory(directory: &Path, binary_name: &str) -> Option<std::path::PathBuf> {
    binary_candidates(directory, binary_name)
        .into_iter()
        .find(|candidate| is_executable_file(candidate))
}

fn binary_candidates(directory: &Path, binary_name: &str) -> Vec<std::path::PathBuf> {
    #[cfg(windows)]
    let mut candidates = Vec::new();
    #[cfg(not(windows))]
    let candidates = vec![directory.join(binary_name)];
    #[cfg(windows)]
    {
        if Path::new(binary_name).extension().is_some() {
            candidates.push(directory.join(binary_name));
        } else {
            let lowercase_name = binary_name.to_ascii_lowercase();
            candidates.extend(
                windows_path_extensions()
                    .into_iter()
                    .filter(|extension| !lowercase_name.ends_with(&extension.to_ascii_lowercase()))
                    .map(|extension| directory.join(format!("{binary_name}{extension}"))),
            );
        }
    }
    candidates
}

#[cfg(windows)]
fn windows_path_extensions() -> Vec<String> {
    let path_ext = std::env::var_os("PATHEXT")
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| std::ffi::OsString::from(".COM;.EXE;.BAT;.CMD"));

    path_ext
        .to_string_lossy()
        .split(';')
        .filter_map(|extension| {
            let trimmed = extension.trim();
            if trimmed.is_empty() {
                None
            } else if trimmed.starts_with('.') {
                Some(trimmed.to_owned())
            } else {
                Some(format!(".{trimmed}"))
            }
        })
        .collect()
}

#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
    use std::os::unix::fs::PermissionsExt;

    std::fs::metadata(path)
        .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
        .unwrap_or(false)
}

#[cfg(not(unix))]
fn is_executable_file(path: &Path) -> bool {
    path.is_file()
}

#[cfg(test)]
mod path_tests;
#[cfg(test)]
mod tests;