netsuke-build 0.1.0-beta2

A YAML-powered Ninja/Jinja hybrid build system.
//! LRU-backed cache for the `which` resolver to avoid repeat filesystem scans.

use std::{
    collections::hash_map::DefaultHasher,
    ffi::OsString,
    hash::{Hash, Hasher},
    sync::{Arc, Mutex, MutexGuard, Once},
};

use camino::Utf8PathBuf;
use lru::LruCache;
use metrics::{counter, describe_counter};
use tracing::field;

use super::{
    WhichConfig,
    env::EnvSnapshot,
    lookup::{WorkspaceSkipList, lookup},
    options::WhichOptions,
    resolve_error::ResolveError,
};

const WHICH_CACHE_TOTAL: &str = "netsuke_stdlib_which_cache_total";
const WHICH_RESOLUTION_TOTAL: &str = "netsuke_stdlib_which_resolution_total";

#[derive(Clone, Debug)]
pub(crate) struct WhichResolver {
    cache: Arc<Mutex<LruCache<CacheKey, CacheEntry>>>,
    cwd_override: Option<Arc<Utf8PathBuf>>,
    path_override: Option<OsString>,
    pathext_override: Option<OsString>,
    workspace_skips: WorkspaceSkipList,
}

impl WhichResolver {
    /// Build a resolver from its configuration.
    ///
    /// Takes the whole [`WhichConfig`] rather than its fields individually:
    /// the overrides travel together, and threading each one as a separate
    /// argument grows the signature every time a new environment seam is
    /// added.
    pub(crate) fn new(config: WhichConfig) -> Self {
        describe_metrics();
        let WhichConfig {
            cwd_override,
            path_override,
            pathext_override,
            workspace_skips,
            cache_capacity,
        } = config;
        Self {
            cache: Arc::new(Mutex::new(LruCache::new(cache_capacity))),
            cwd_override,
            path_override,
            pathext_override,
            workspace_skips,
        }
    }

    pub(crate) fn resolve(
        &self,
        command: &str,
        options: &WhichOptions,
    ) -> Result<Vec<Utf8PathBuf>, ResolveError> {
        let span = tracing::trace_span!(
            "stdlib.which.resolve",
            cache_outcome = field::Empty,
            result = field::Empty,
            error_category = field::Empty,
        );
        let _guard = span.enter();
        let env = match EnvSnapshot::capture_with_pathext(
            self.cwd_override.as_deref().map(Utf8PathBuf::as_path),
            self.path_override.as_deref(),
            self.pathext_override.as_deref(),
        ) {
            Ok(env) => env,
            Err(err) => {
                record_resolution_error(&span, &err);
                return Err(err);
            }
        };
        let key = CacheKey::new(command, &env, options, &self.workspace_skips);
        if options.fresh {
            record_cache_outcome(&span, "bypass");
        } else if let Some(cached) = self.try_cache(&key) {
            record_cache_outcome(&span, "hit");
            span.record("result", "found");
            counter!(WHICH_RESOLUTION_TOTAL, "outcome" => "found").increment(1);
            return Ok(cached);
        } else {
            record_cache_outcome(&span, "miss");
        }
        let matches = match lookup(command, &env, options, &self.workspace_skips) {
            Ok(matches) => matches,
            Err(err) => {
                record_resolution_error(&span, &err);
                return Err(err);
            }
        };
        self.store(key, matches.clone());
        span.record("result", "found");
        counter!(WHICH_RESOLUTION_TOTAL, "outcome" => "found").increment(1);
        Ok(matches)
    }

    // POLONIUS-REFUSED(lock-boundary): the hit is cloned out of the LRU
    // because a reference cannot outlive the `MutexGuard`, and the resolver
    // is shared across template evaluation sites. Owned returns at this
    // boundary are an aliasing/synchronization constraint, not NLL residue.
    fn try_cache(&self, key: &CacheKey) -> Option<Vec<Utf8PathBuf>> {
        let mut guard = self.lock_cache();
        guard.get(key).map(|entry| entry.matches.clone())
    }

    fn store(&self, key: CacheKey, matches: Vec<Utf8PathBuf>) {
        let mut guard = self.lock_cache();
        guard.put(key, CacheEntry { matches });
    }

    fn lock_cache(&self) -> MutexGuard<'_, LruCache<CacheKey, CacheEntry>> {
        match self.cache.lock() {
            Ok(guard) => guard,
            Err(poisoned) => poisoned.into_inner(),
        }
    }
}

fn describe_metrics() {
    static DESCRIBE: Once = Once::new();
    DESCRIBE.call_once(|| {
        describe_counter!(
            WHICH_CACHE_TOTAL,
            "Counts which resolver cache outcomes labelled as hit, miss, or bypass."
        );
        describe_counter!(
            WHICH_RESOLUTION_TOTAL,
            "Counts which resolver outcomes labelled as found, not_found, or error."
        );
    });
}

fn record_cache_outcome(span: &tracing::Span, outcome: &'static str) {
    span.record("cache_outcome", outcome);
    counter!(WHICH_CACHE_TOTAL, "outcome" => outcome).increment(1);
}

fn record_resolution_error(span: &tracing::Span, error: &ResolveError) {
    let category = error.category();
    let outcome = if matches!(
        error,
        ResolveError::NotFound { .. } | ResolveError::DirectNotFound { .. }
    ) {
        "not_found"
    } else {
        "error"
    };
    span.record("result", outcome);
    span.record("error_category", category);
    tracing::debug!(
        outcome,
        error_category = category,
        "which resolver finished with non-success result",
    );
    counter!(
        WHICH_RESOLUTION_TOTAL,
        "outcome" => outcome,
        "category" => category,
    )
    .increment(1);
}

#[derive(Clone, Debug)]
struct CacheEntry {
    matches: Vec<Utf8PathBuf>,
}

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct CacheKey {
    command: String,
    env_fingerprint: u64,
    cwd: Utf8PathBuf,
    options: WhichOptions,
    workspace_skips: WorkspaceSkipList,
}

impl CacheKey {
    fn new(
        command: &str,
        env: &EnvSnapshot,
        options: &WhichOptions,
        workspace_skips: &WorkspaceSkipList,
    ) -> Self {
        Self {
            command: command.to_owned(),
            env_fingerprint: env_fingerprint(env),
            cwd: env.cwd.clone(),
            options: options.cache_key_view(),
            workspace_skips: workspace_skips.clone(),
        }
    }
}

fn env_fingerprint(env: &EnvSnapshot) -> u64 {
    let mut hasher = DefaultHasher::new();
    env.raw_path.hash(&mut hasher);
    env.raw_pathext.hash(&mut hasher);
    // The workspace switch is an environment input like PATH: a fallback hit
    // cached while the switch was on must not answer a resolution made with
    // it off, and vice versa.
    env.workspace_switch().hash(&mut hasher);
    hasher.finish()
}

#[cfg(test)]
mod tests {
    //! Unit tests for the which resolver cache: key derivation, capacity
    //! bounds, and skip-list handling during resolution.
    use super::*;
    use crate::stdlib::which::workspace_switch::WorkspaceSwitch;
    use anyhow::{Result, anyhow, ensure};
    use camino::Utf8PathBuf;
    use rstest::rstest;
    use std::{num::NonZeroUsize, sync::Arc};
    use tempfile::TempDir;

    fn cache_key_for(command: &str) -> CacheKey {
        CacheKey {
            command: command.to_owned(),
            env_fingerprint: 1,
            cwd: Utf8PathBuf::from("/"),
            options: WhichOptions::default(),
            workspace_skips: WorkspaceSkipList::default(),
        }
    }

    #[rstest]
    fn cache_capacity_bounds_entries() {
        let resolver = WhichResolver::new(WhichConfig::new(
            None,
            None,
            WorkspaceSkipList::default(),
            NonZeroUsize::new(1).expect("non-zero cache capacity"),
        ));

        let first_key = cache_key_for("first");
        let first_path = Utf8PathBuf::from("/bin/first");
        resolver.store(first_key.clone(), vec![first_path.clone()]);
        assert_eq!(
            resolver.try_cache(&first_key),
            Some(vec![first_path.clone()])
        );

        let second_key = cache_key_for("second");
        let second_path = Utf8PathBuf::from("/bin/second");
        resolver.store(second_key.clone(), vec![second_path.clone()]);

        assert!(resolver.try_cache(&first_key).is_none());
        assert_eq!(resolver.try_cache(&second_key), Some(vec![second_path]));
    }

    #[test]
    fn cache_key_differs_when_skip_lists_differ() -> Result<()> {
        let temp = TempDir::new()?;
        let cwd = Utf8PathBuf::from_path_buf(temp.path().to_path_buf())
            .map_err(|path| anyhow!("temp path should be utf8: {path:?}"))?;
        let env = EnvSnapshot::capture(Some(cwd.as_path()), Some(std::ffi::OsStr::new("")))?;
        let options = WhichOptions::default();

        let key_a = CacheKey::new(
            "tool",
            &env,
            &options,
            &WorkspaceSkipList::from_names(["target"]),
        );
        let key_b = CacheKey::new(
            "tool",
            &env,
            &options,
            &WorkspaceSkipList::from_names(["build"]),
        );

        ensure!(key_a != key_b, "skip lists must influence cache key");
        Ok(())
    }

    /// Differing workspace-switch readings must not share a cache entry.
    ///
    /// A fallback hit cached while `NETSUKE_WHICH_WORKSPACE` left the search
    /// enabled would otherwise answer a resolution made with it disabled —
    /// cross-toggle cache poisoning. The snapshots here differ only in the
    /// captured switch state, so key inequality proves the fingerprint
    /// covers it.
    #[test]
    fn cache_key_differs_when_workspace_switch_differs() -> Result<()> {
        let temp = TempDir::new()?;
        let cwd = Utf8PathBuf::from_path_buf(temp.path().to_path_buf())
            .map_err(|path| anyhow!("temp path should be utf8: {path:?}"))?;
        let base = EnvSnapshot::capture(Some(cwd.as_path()), Some(std::ffi::OsStr::new("")))?;
        let options = WhichOptions::default();
        let skips = WorkspaceSkipList::default();

        let enabled = base.clone().with_workspace_switch(WorkspaceSwitch::Absent);
        let disabled = base.with_workspace_switch(WorkspaceSwitch::Value(String::from("0")));
        ensure!(
            enabled.workspace_fallback_enabled() && !disabled.workspace_fallback_enabled(),
            "the fixtures should sit on opposite sides of the switch"
        );

        let key_enabled = CacheKey::new("tool", &enabled, &options, &skips);
        let key_disabled = CacheKey::new("tool", &disabled, &options, &skips);
        ensure!(
            key_enabled != key_disabled,
            "the workspace switch must influence the cache key"
        );
        Ok(())
    }

    #[test]
    fn resolver_applies_skip_list_during_resolution() -> Result<()> {
        let temp = TempDir::new()?;
        let cwd = Utf8PathBuf::from_path_buf(temp.path().to_path_buf())
            .map_err(|path| anyhow!("temp path should be utf8: {path:?}"))?;

        let target = cwd.join("target");
        test_support::fs::create_dir_all(target.as_std_path())?;
        test_support::write_exec(target.as_std_path(), "tool")?;

        let capacity = NonZeroUsize::new(64).expect("non-zero cache capacity");
        // Use path_override to set empty PATH instead of mutating global env
        let empty_path = Some(std::ffi::OsString::new());
        let resolver = WhichResolver::new(WhichConfig::new(
            Some(Arc::new(cwd.clone())),
            empty_path.clone(),
            WorkspaceSkipList::default(),
            capacity,
        ));
        let options = WhichOptions::default();
        let err = resolver
            .resolve("tool", &options)
            .expect_err("default skip should ignore target");

        ensure!(matches!(err, ResolveError::NotFound { .. }));

        let resolver_custom = WhichResolver::new(WhichConfig::new(
            Some(Arc::new(cwd.clone())),
            empty_path,
            WorkspaceSkipList::from_names([".git"]),
            capacity,
        ));
        let matches = resolver_custom.resolve("tool", &options)?;
        ensure!(
            matches == vec![target.join("tool")],
            "expected executable discovery when target not skipped"
        );

        Ok(())
    }
}