zenops 0.19.0

Declarative system configuration management for shell config and dotfiles.
Documentation
//! PATH-driven binary lookup.
//!
//! Wraps the [`which`] crate so all callers go through one chokepoint
//! that consults a [`SearchPath`] passed in explicitly, rather than
//! reading the process's `PATH` env var directly. Tests inject a
//! `TestEnv`-controlled `SearchPath` to make pkg detection
//! deterministic across hosts; production builds one from the env at
//! startup.

use std::path::PathBuf;

use zenops_expand::{ExpandError, ExpandLookup, ExpandStr};

/// Failures from PATH-binary lookup. "Binary not found" is *not* an error
/// here — `get_path` maps the underlying `which::Error::CannotFindBinaryPath`
/// / `CannotGetCurrentDirAndPathListEmpty` to `Ok(None)` — so this enum
/// only covers the cases where the user's input itself was bad or the
/// lookup machinery broke.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// The `${...}` placeholder expansion on the binary spec failed before
    /// `which` was even invoked. First field is the unexpanded spec.
    #[error("Failed to expand executable path from {0:?}: {1}")]
    ExpandError(ExpandStr, ExpandError),
    /// `which::which` returned an error other than "not found" — typically
    /// `CannotCanonicalize` after a match was found but couldn't be
    /// resolved. First field is the binary name that was looked up.
    #[error("Failed to find executable from {0:?}: {1}")]
    Which(String, which::Error),
}

/// Directories searched for binaries by [`get_path`] and friends.
///
/// Production builds one from the process's `PATH` environment variable
/// at startup via [`SearchPath::from_env`] and threads it through
/// `Config` / `HostContext` to every binary-lookup call site. Tests build
/// one pointing at a `TestEnv`-controlled bin directory so detection is
/// deterministic across hosts instead of inheriting whatever happens to
/// be installed on the test machine.
#[derive(Debug, Clone, Default)]
pub struct SearchPath {
    dirs: Vec<PathBuf>,
}

impl SearchPath {
    /// Read the process's `PATH` and split it into directories. The only
    /// place production code reads `PATH` directly — every subsequent
    /// binary lookup goes through the returned [`SearchPath`].
    pub fn from_env() -> Self {
        Self {
            dirs: std::env::var_os("PATH")
                .map(|p| std::env::split_paths(&p).collect())
                .unwrap_or_default(),
        }
    }

    /// Explicit construction from a directory list. Used by `TestEnv`.
    pub fn new(dirs: impl Into<Vec<PathBuf>>) -> Self {
        Self { dirs: dirs.into() }
    }

    /// Join the directory list back into the colon-separated form
    /// `which::which_in` consumes.
    fn as_os_string(&self) -> std::ffi::OsString {
        std::env::join_paths(&self.dirs)
            .expect("SearchPath dirs were validated as joinable at construction")
    }
}

/// Resolve `binary` against `path` and return its absolute location, or
/// `Ok(None)` if no entry on `path` contains an executable with that name.
/// Errors only on lookup-machinery failures, not on "binary not found".
pub fn get_path(
    binary: impl AsRef<str> + Into<String>,
    path: &SearchPath,
) -> Result<Option<PathBuf>, Error> {
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    ::which::which_in(binary.as_ref(), Some(path.as_os_string()), &cwd)
        .map(Some)
        .or_else(|e| match e {
            which::Error::CannotFindBinaryPath
            | which::Error::CannotGetCurrentDirAndPathListEmpty => Ok(None),
            which::Error::CannotCanonicalize => Err(Error::Which(binary.into(), e)),
        })
}

/// `true` iff `binary` resolves to a file somewhere on `path`. Thin
/// wrapper over [`get_path`] for callers that only need the present /
/// absent answer.
pub fn exists(binary: impl AsRef<str> + Into<String>, path: &SearchPath) -> Result<bool, Error> {
    get_path(binary, path).map(|v| v.is_some())
}

/// `${var}`-expand `binary` against `lookup` and resolve it on `path`.
/// Returns the same shape as [`get_path`]; bubbles `ExpandError` when
/// the spec mentions a variable the lookup can't satisfy.
pub fn expand_and_get_path(
    binary: &ExpandStr,
    lookup: &impl ExpandLookup,
    path: &SearchPath,
) -> Result<Option<PathBuf>, Error> {
    let b = binary
        .expand_to_string(lookup)
        .map_err(|e| Error::ExpandError(binary.clone(), e))?;
    get_path(b, path)
}

/// `true` iff the expanded binary spec resolves on `path`. The
/// presence-test variant of [`expand_and_get_path`].
pub fn expand_and_exists(
    binary: &ExpandStr,
    lookup: &impl ExpandLookup,
    path: &SearchPath,
) -> Result<bool, Error> {
    expand_and_get_path(binary, lookup, path).map(|v| v.is_some())
}