use std::{
collections::hash_map::DefaultHasher,
ffi::OsString,
hash::{Hash, Hasher},
num::NonZeroUsize,
sync::{Arc, Mutex, MutexGuard, Once},
};
use camino::Utf8PathBuf;
use lru::LruCache;
use metrics::{counter, describe_counter};
use tracing::field;
use super::{
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>,
workspace_skips: WorkspaceSkipList,
}
impl WhichResolver {
pub(crate) fn new(
cwd_override: Option<Arc<Utf8PathBuf>>,
path_override: Option<OsString>,
workspace_skips: WorkspaceSkipList,
cache_capacity: NonZeroUsize,
) -> Self {
describe_metrics();
Self {
cache: Arc::new(Mutex::new(LruCache::new(cache_capacity))),
cwd_override,
path_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(
self.cwd_override.as_deref().map(Utf8PathBuf::as_path),
self.path_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)
}
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);
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
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(
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(())
}
#[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");
let empty_path = Some(std::ffi::OsString::new());
let resolver = WhichResolver::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(
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(())
}
}