netsuke-build 0.1.0-beta2

A YAML-powered Ninja/Jinja hybrid build system.
//! Cross-platform `which` filter and helper function for `MiniJinja`.
//!
//! Resolves executables deterministically across Unix and Windows,
//! honouring user-configurable options for returning every match, emitting
//! canonical paths, bypassing the resolver cache, and opt-in search of the
//! current working directory.

use std::{ffi::OsString, num::NonZeroUsize, sync::Arc};

use camino::{Utf8Path, Utf8PathBuf};
use minijinja::{
    Environment, Error, ErrorKind,
    value::{Kwargs, Value},
};

mod cache;
mod env;
mod lookup;
mod options;
mod resolve_error;
mod workspace_switch;
pub(crate) use lookup::{WORKSPACE_SKIP_DIRS, WorkspaceSkipList};

pub(crate) use cache::WhichResolver;
pub(crate) use options::WhichOptions;

use crate::localization::{self, LocalizedMessage, keys};
use resolve_error::ResolveError;

const NOT_FOUND_CODE: &str = "netsuke::jinja::which::not_found";

#[derive(Clone, Debug)]
pub(crate) struct WhichConfig {
    pub(crate) cwd_override: Option<Arc<Utf8PathBuf>>,
    pub(crate) path_override: Option<OsString>,
    pub(crate) pathext_override: Option<OsString>,
    pub(crate) workspace_skips: WorkspaceSkipList,
    pub(crate) cache_capacity: NonZeroUsize,
}

impl WhichConfig {
    pub(crate) const fn new(
        cwd_override: Option<Arc<Utf8PathBuf>>,
        path_override: Option<OsString>,
        workspace_skips: WorkspaceSkipList,
        cache_capacity: NonZeroUsize,
    ) -> Self {
        Self {
            cwd_override,
            path_override,
            pathext_override: None,
            workspace_skips,
            cache_capacity,
        }
    }

    /// Shadow `PATHEXT` for the resolver this configuration builds.
    ///
    /// Kept off [`Self::new`] because the override is rare: only callers that
    /// deliberately pin the extension list supply one, and every other call
    /// site would otherwise pass `None`.
    #[must_use]
    pub(crate) fn with_pathext_override(mut self, pathext: Option<OsString>) -> Self {
        self.pathext_override = pathext;
        self
    }
}

pub(crate) fn register(env: &mut Environment<'_>, config: WhichConfig) {
    let resolver = Arc::new(WhichResolver::new(config));
    {
        let filter_resolver = Arc::clone(&resolver);
        env.add_filter("which", move |value: Value, kwargs: Kwargs| {
            resolve_with(&filter_resolver, &value, &kwargs).and_then(|output| {
                kwargs.assert_all_used()?;
                Ok(output)
            })
        });
    }
    {
        let function_resolver = Arc::clone(&resolver);
        env.add_function("which", move |value: Value, kwargs: Kwargs| {
            resolve_with(&function_resolver, &value, &kwargs).and_then(|output| {
                kwargs.assert_all_used()?;
                Ok(output)
            })
        });
    }
    {
        let predicate_resolver = Arc::clone(&resolver);
        env.add_function("command_available", move |value: Value, kwargs: Kwargs| {
            command_available_with(&predicate_resolver, &value, &kwargs).and_then(|output| {
                kwargs.assert_all_used()?;
                Ok(output)
            })
        });
    }
}

fn resolve_command_name_and_options<'v>(
    command: &'v Value,
    kwargs: &Kwargs,
) -> Result<(&'v str, WhichOptions), Error> {
    let name = command
        .as_str()
        .map(str::trim)
        .filter(|candidate| !candidate.is_empty())
        .ok_or_else(|| {
            Error::from(ResolveError::args(localization::message(
                keys::STDLIB_WHICH_COMMAND_EMPTY,
            )))
        })?;
    let options = WhichOptions::from_kwargs(kwargs).map_err(Error::from)?;
    Ok((name, options))
}

fn resolve_with(
    resolver: &WhichResolver,
    command: &Value,
    kwargs: &Kwargs,
) -> Result<Value, Error> {
    let (name, options) = resolve_command_name_and_options(command, kwargs)?;
    let matches = resolver.resolve(name, &options).map_err(Error::from)?;
    Ok(render_value(&matches, &options))
}

fn command_available_with(
    resolver: &WhichResolver,
    command: &Value,
    kwargs: &Kwargs,
) -> Result<Value, Error> {
    let (name, options) = resolve_command_name_and_options(command, kwargs)?;
    kwargs.assert_all_used()?;
    is_command_available(resolver.resolve(name, &options))
        .map(Value::from)
        .map_err(Error::from)
}

pub(super) fn is_command_available(
    result: Result<Vec<Utf8PathBuf>, ResolveError>,
) -> Result<bool, ResolveError> {
    match result {
        Ok(matches) => Ok(!matches.is_empty()),
        Err(ResolveError::NotFound { .. } | ResolveError::DirectNotFound { .. }) => Ok(false),
        Err(err) => Err(err),
    }
}

fn render_value(matches: &[Utf8PathBuf], options: &WhichOptions) -> Value {
    if options.all {
        let rendered: Vec<String> = matches
            .iter()
            .map(|path| format_path_for_output(path))
            .collect();
        Value::from_serialize(rendered)
    } else {
        let first = matches
            .first()
            .map_or_else(String::new, |path| format_path_for_output(path));
        Value::from(first)
    }
}

impl From<ResolveError> for Error {
    fn from(value: ResolveError) -> Self {
        let (kind, message) = resolve_error_parts(&value);
        Self::new(kind, message)
    }
}

fn resolve_error_parts(error: &ResolveError) -> (ErrorKind, String) {
    match error {
        ResolveError::NotFound {
            command,
            dirs,
            cwd_mode,
        } => (
            ErrorKind::InvalidOperation,
            with_not_found_code(&not_found_message(command, dirs, *cwd_mode)),
        ),
        ResolveError::DirectNotFound { command, path } => (
            ErrorKind::InvalidOperation,
            with_not_found_code(&direct_not_found_message(command, path)),
        ),
        ResolveError::Args { detail } => (ErrorKind::InvalidOperation, args_message(detail)),
        ResolveError::Canonicalize { path, source } => (
            ErrorKind::InvalidOperation,
            canonicalize_message(path, source),
        ),
        ResolveError::IsExecutable { path, source } => (
            ErrorKind::InvalidOperation,
            is_executable_message(path, source),
        ),
        ResolveError::CanonicalizeNonUtf8 => (
            ErrorKind::InvalidOperation,
            localization::message(keys::STDLIB_WHICH_CANONICALIZE_NON_UTF8).to_string(),
        ),
        ResolveError::WorkspaceNonUtf8 { command, path } => (
            ErrorKind::InvalidOperation,
            workspace_non_utf8_message(command, path),
        ),
        ResolveError::CwdResolve { source } => (
            ErrorKind::InvalidOperation,
            localization::message(keys::STDLIB_WHICH_CWD_RESOLVE_FAILED)
                .with_arg("details", source.to_string())
                .to_string(),
        ),
        ResolveError::CwdNonUtf8 => (
            ErrorKind::InvalidOperation,
            localization::message(keys::STDLIB_WHICH_CWD_NON_UTF8).to_string(),
        ),
        ResolveError::WalkDir { source } => {
            (ErrorKind::InvalidOperation, walkdir_error_message(source))
        }
    }
}

fn with_not_found_code(message: &str) -> String {
    format!("{NOT_FOUND_CODE}: {message}")
}

fn not_found_message(command: &str, dirs: &[Utf8PathBuf], mode: options::CwdMode) -> String {
    let mut message = localization::message(keys::STDLIB_WHICH_NOT_FOUND)
        .with_arg("command", command)
        .with_arg("count", dirs.len())
        .with_arg("preview", path_preview(dirs))
        .to_string();
    if let Some(hint) = hint_for_mode(mode) {
        message.push_str(". ");
        message.push_str(&hint.to_string());
    }
    message
}

fn direct_not_found_message(command: &str, path: &Utf8Path) -> String {
    localization::message(keys::STDLIB_WHICH_DIRECT_NOT_FOUND)
        .with_arg("command", command)
        .with_arg("path", path.as_str())
        .to_string()
}

fn args_message(detail: impl std::fmt::Display) -> String {
    localization::message(keys::STDLIB_WHICH_ARGS_ERROR)
        .with_arg("details", detail.to_string())
        .to_string()
}

fn canonicalize_message(path: &Utf8Path, source: &std::io::Error) -> String {
    localization::message(keys::STDLIB_WHICH_CANONICALIZE_FAILED)
        .with_arg("path", path.as_str())
        .with_arg("details", source.to_string())
        .to_string()
}

fn is_executable_message(path: &Utf8Path, source: &std::io::Error) -> String {
    localization::message(keys::STDLIB_WHICH_IS_EXECUTABLE)
        .with_arg("path", path.as_str())
        .with_arg("details", source.to_string())
        .to_string()
}

fn workspace_non_utf8_message(command: &str, path: &str) -> String {
    localization::message(keys::STDLIB_WHICH_WORKSPACE_NON_UTF8)
        .with_arg("command", command)
        .with_arg("path", path)
        .to_string()
}

fn walkdir_error_message(source: &walkdir::Error) -> String {
    localization::message(keys::STDLIB_WHICH_WALKDIR_ERROR)
        .with_arg("details", source.to_string())
        .to_string()
}

fn path_preview(dirs: &[Utf8PathBuf]) -> String {
    const LIMIT: usize = 4;
    if dirs.is_empty() {
        return localization::message(keys::STDLIB_WHICH_PATH_PREVIEW_EMPTY).to_string();
    }
    let mut parts: Vec<_> = dirs
        .iter()
        .take(LIMIT)
        .map(|dir| format_path_for_output(dir))
        .collect();
    if dirs.len() > LIMIT {
        parts.push("".into());
    }
    parts.join(", ")
}

const fn hint_for_mode(mode: options::CwdMode) -> Option<LocalizedMessage> {
    #[cfg(windows)]
    {
        match mode {
            options::CwdMode::Always => None,
            _ => Some(localization::message(
                keys::STDLIB_WHICH_NOT_FOUND_HINT_CWD_ALWAYS,
            )),
        }
    }
    #[cfg(not(windows))]
    {
        match mode {
            options::CwdMode::Never => Some(localization::message(
                keys::STDLIB_WHICH_NOT_FOUND_HINT_CWD_AUTO,
            )),
            _ => None,
        }
    }
}

pub(super) fn format_path_for_output(path: &Utf8Path) -> String {
    #[cfg(windows)]
    {
        path.as_str().replace('\\', "/")
    }
    #[cfg(not(windows))]
    {
        path.as_str().to_owned()
    }
}

#[cfg(test)]
mod pathext_tests;
#[cfg(test)]
mod tests {
    //! Unit tests for the which module facade, covering the command
    //! availability helper across hit, miss, and error outcomes.
    use super::*;
    use rstest::rstest;

    #[rstest]
    fn command_available_helper_returns_true_for_matches() {
        let result = is_command_available(Ok(vec![Utf8PathBuf::from("/bin/tool")]));
        assert!(result.expect("availability result"));
    }

    #[rstest]
    fn command_available_helper_returns_false_for_search_misses() {
        let result = is_command_available(Err(ResolveError::NotFound {
            command: "tool".to_owned(),
            dirs: Vec::new(),
            cwd_mode: options::CwdMode::Never,
        }));
        assert!(!result.expect("availability result"));
    }

    #[rstest]
    fn command_available_helper_returns_false_for_direct_misses() {
        let result = is_command_available(Err(ResolveError::DirectNotFound {
            command: "./tool".to_owned(),
            path: Utf8PathBuf::from("/workspace/tool"),
        }));
        assert!(!result.expect("availability result"));
    }

    #[rstest]
    fn command_available_helper_propagates_argument_errors() {
        let result = is_command_available(Err(ResolveError::args("bad option")));
        assert!(matches!(result, Err(ResolveError::Args { .. })));
    }
}