whitaker-installer 0.2.7

Installer CLI for Whitaker Dylint lint libraries
Documentation
//! Toolchain detection and validation for the installer.
//!
//! This module provides utilities to detect the pinned Rust toolchain from
//! `rust-toolchain.toml` and verify that it is installed via rustup.

use crate::error::{InstallerError, Result};
use camino::{Utf8Path, Utf8PathBuf};
use std::process::{Command, Output};

/// Components required for building dylint lints.
///
/// Only these components are installed by the installer, regardless of what
/// rust-toolchain.toml specifies. Development tools like rustfmt and clippy
/// are not needed for building lints.
const REQUIRED_COMPONENTS: &[&str] = &["rust-src", "rustc-dev", "llvm-tools-preview"];

/// Represents a detected Rust toolchain configuration.
#[derive(Debug, Clone)]
pub struct Toolchain {
    channel: String,
    workspace_root: Utf8PathBuf,
}

/// Status describing whether a toolchain install occurred.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolchainInstallStatus {
    installed_toolchain: bool,
}

impl ToolchainInstallStatus {
    /// Returns true if the toolchain was installed during this run.
    #[must_use]
    pub fn installed_toolchain(&self) -> bool {
        self.installed_toolchain
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ToolchainConfig {
    channel: String,
}

/// Abstraction for running external commands.
#[cfg_attr(test, mockall::automock)]
trait CommandRunner {
    /// Runs a program with the given arguments and returns the output.
    ///
    /// Note: mockall requires explicit lifetimes for nested references in trait
    /// methods; eliding causes E0106 when the automock attribute generates mock
    /// code.
    #[expect(
        clippy::needless_lifetimes,
        reason = "mockall requires explicit lifetime for &[&str]"
    )]
    fn run<'a>(&self, program: &str, args: &[&'a str]) -> std::io::Result<Output>;
}

struct SystemCommandRunner;

impl CommandRunner for SystemCommandRunner {
    #[expect(clippy::needless_lifetimes, reason = "signature must match trait")]
    fn run<'a>(&self, program: &str, args: &[&'a str]) -> std::io::Result<Output> {
        Command::new(program).args(args).output()
    }
}

impl Toolchain {
    /// Detect the pinned toolchain from `rust-toolchain.toml`.
    ///
    /// # Errors
    ///
    /// Returns an error if the toolchain file is not found or cannot be parsed.
    pub fn detect(workspace_root: &Utf8Path) -> Result<Self> {
        let toolchain_path = workspace_root.join("rust-toolchain.toml");

        if !toolchain_path.exists() {
            return Err(InstallerError::ToolchainFileNotFound {
                path: toolchain_path,
            });
        }

        let contents = std::fs::read_to_string(&toolchain_path)?;
        let config = parse_toolchain_config(&contents)?;

        Ok(Self {
            channel: config.channel,
            workspace_root: workspace_root.to_owned(),
        })
    }

    /// Create a toolchain with an explicit override channel.
    ///
    /// This constructor does not perform validation; callers are responsible
    /// for ensuring the `workspace_root` and `channel` are valid.
    #[must_use]
    pub fn with_override(workspace_root: &Utf8Path, channel: &str) -> Self {
        Self {
            channel: channel.to_owned(),
            workspace_root: workspace_root.to_owned(),
        }
    }

    /// Verify that the toolchain is installed via rustup.
    ///
    /// # Errors
    ///
    /// Returns an error if rustup is not found or the toolchain is not installed.
    pub fn verify_installed(&self) -> Result<()> {
        let runner = SystemCommandRunner;
        if self.is_installed_with(&runner)? {
            Ok(())
        } else {
            Err(InstallerError::ToolchainNotInstalled {
                toolchain: self.channel.clone(),
            })
        }
    }

    /// Install the toolchain via rustup if it is missing.
    ///
    /// # Errors
    ///
    /// Returns an error if rustup fails to install the toolchain or required
    /// components.
    pub fn ensure_installed(
        &self,
        additional_components: &[&str],
    ) -> Result<ToolchainInstallStatus> {
        let runner = SystemCommandRunner;
        self.ensure_installed_with(&runner, additional_components)
    }

    fn ensure_installed_with(
        &self,
        runner: &dyn CommandRunner,
        additional_components: &[&str],
    ) -> Result<ToolchainInstallStatus> {
        if self.is_installed_with(runner)? {
            // Toolchain already present - just ensure components are installed
            self.install_components_with(runner, additional_components)?;
            return Ok(ToolchainInstallStatus {
                installed_toolchain: false,
            });
        }

        // Toolchain not installed - attempt installation then verify
        self.install_toolchain_with(runner)?;
        self.install_components_with(runner, additional_components)?;

        // Verify the newly installed toolchain is actually usable
        if !self.is_installed_with(runner)? {
            return Err(InstallerError::ToolchainNotInstalled {
                toolchain: self.channel.clone(),
            });
        }

        Ok(ToolchainInstallStatus {
            installed_toolchain: true,
        })
    }

    /// Return the channel string for `cargo +<toolchain>` invocations.
    #[must_use]
    pub fn channel(&self) -> &str {
        &self.channel
    }

    /// Return the workspace root path.
    #[must_use]
    pub fn workspace_root(&self) -> &Utf8Path {
        &self.workspace_root
    }

    fn is_installed_with(&self, runner: &dyn CommandRunner) -> Result<bool> {
        let output = run_rustup(runner, &["run", &self.channel, "rustc", "--version"])?;
        Ok(output.status.success())
    }

    fn install_toolchain_with(&self, runner: &dyn CommandRunner) -> Result<()> {
        let output = run_rustup(runner, &["toolchain", "install", &self.channel])?;

        if output.status.success() {
            return Ok(());
        }

        Err(InstallerError::ToolchainInstallFailed {
            toolchain: self.channel.clone(),
            message: stderr_message(&output),
        })
    }

    fn install_components_with(
        &self,
        runner: &dyn CommandRunner,
        additional_components: &[&str],
    ) -> Result<()> {
        let component_list: Vec<&str> = REQUIRED_COMPONENTS
            .iter()
            .copied()
            .chain(additional_components.iter().copied())
            .collect();
        let mut args: Vec<&str> = vec!["component", "add", "--toolchain", &self.channel];
        args.extend(component_list.iter().copied());

        let output = run_rustup(runner, &args)?;

        if output.status.success() {
            return Ok(());
        }

        Err(InstallerError::ToolchainComponentInstallFailed {
            toolchain: self.channel.clone(),
            components: component_list.join(", "),
            message: stderr_message(&output),
        })
    }
}

/// Parse the channel from `rust-toolchain.toml` contents.
///
/// This function supports two formats:
/// 1. Standard format with `[toolchain].channel`
/// 2. Simple format with a top-level `channel` key
///
/// # Example
///
/// ```
/// use whitaker_installer::toolchain::parse_toolchain_channel;
///
/// let contents = r#"
/// [toolchain]
/// channel = "nightly-2026-05-28"
/// "#;
/// let channel = parse_toolchain_channel(contents).unwrap();
/// assert_eq!(channel, "nightly-2026-05-28");
/// ```
///
/// # Errors
///
/// Returns an error if the TOML is invalid or no channel field is found.
pub fn parse_toolchain_channel(contents: &str) -> Result<String> {
    parse_toolchain_config(contents).map(|config| config.channel)
}

fn parse_toolchain_config(contents: &str) -> Result<ToolchainConfig> {
    let table: toml::Table =
        contents
            .parse()
            .map_err(|e| InstallerError::InvalidToolchainFile {
                reason: format!("TOML parse error: {e}"),
            })?;

    let channel = parse_channel_from_table(&table)?;

    Ok(ToolchainConfig { channel })
}

fn parse_channel_from_table(table: &toml::Table) -> Result<String> {
    let channel_from_toolchain = table
        .get("toolchain")
        .and_then(|t| t.get("channel"))
        .and_then(|c| c.as_str());

    if let Some(s) = channel_from_toolchain {
        return Ok(s.to_owned());
    }

    let channel_from_top = table.get("channel").and_then(|c| c.as_str());

    if let Some(s) = channel_from_top {
        return Ok(s.to_owned());
    }

    Err(InstallerError::InvalidToolchainFile {
        reason: "no channel field found in rust-toolchain.toml".to_owned(),
    })
}

fn run_rustup(runner: &dyn CommandRunner, args: &[&str]) -> Result<Output> {
    runner
        .run("rustup", args)
        .map_err(|e| InstallerError::ToolchainDetection {
            reason: format!("failed to run rustup: {e}"),
        })
}

fn stderr_message(output: &Output) -> String {
    let stderr = String::from_utf8_lossy(&output.stderr);
    let trimmed = stderr.trim();
    if trimmed.is_empty() {
        "unknown error".to_owned()
    } else {
        trimmed.to_owned()
    }
}

#[cfg(test)]
mod tests;