Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ itertools = "0.12"
indexmap = { version = "2.5", features = ["serde"] }
lru = "0.12"
glob = "0.3.3"
walkdir = "2.5.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
serde_json = { version = "1", features = ["preserve_order"] }
Expand Down
120 changes: 120 additions & 0 deletions docs/netsuke-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,126 @@ validation, and list-all semantics. Behavioural MiniJinja fixtures exercise the
filter in Stage 3/4 renders to prove determinism across repeated invocations
with identical environments.

Sequence of the resolver when falling back to the workspace:

```mermaid
sequenceDiagram
participant "Caller" as "Caller"
participant "WhichResolver" as "WhichResolver"
participant "EnvSnapshot" as "EnvSnapshot"
participant "Lookup" as "lookup() in lookup.rs"
participant "HandleMiss" as "handle_miss()"
participant "SearchWorkspace" as "search_workspace()"

"Caller"->>"WhichResolver": "resolve(command, options)"
"WhichResolver"->>"EnvSnapshot": "capture(cwd_override)"
"EnvSnapshot"-->>"WhichResolver": "EnvSnapshot { cwd, raw_path }"
"WhichResolver"->>"Lookup": "lookup(env, command, options)"
"Lookup"->>"Lookup": "search PATH directories for matches"
alt "matches found"
"Lookup"-->>"WhichResolver": "Vec<Utf8PathBuf> (maybe canonicalised)"
"WhichResolver"-->>"Caller": "Ok(matches)"
else "no matches in PATH"
"Lookup"->>"HandleMiss": "handle_miss(env, command, options, dirs)"
"HandleMiss"->>"HandleMiss": "check if 'raw_path' is empty"
alt "PATH empty and 'cwd_mode' != 'Never'"
"HandleMiss"->>"SearchWorkspace": "search_workspace(env.cwd, command, options.all)"
"SearchWorkspace"->>"SearchWorkspace": "walk workspace with 'WalkDir' and filter executables"
"SearchWorkspace"-->>"HandleMiss": "discovered paths (possibly empty)"
alt "discovered not empty"
alt "options.canonical is true"
"HandleMiss"->>"HandleMiss": "canonicalise(discovered)"
"HandleMiss"-->>"Lookup": "canonical paths"
else "options.canonical is false"
"HandleMiss"-->>"Lookup": "discovered paths"
end
"Lookup"-->>"WhichResolver": "Vec<Utf8PathBuf> from workspace"
"WhichResolver"-->>"Caller": "Ok(matches)"
else "discovered empty"
"HandleMiss"-->>"Lookup": "Error(not_found_error)"
"Lookup"-->>"WhichResolver": "Error"
"WhichResolver"-->>"Caller": "Err(not_found)"
end
else "PATH not empty or 'cwd_mode' is 'Never'"
"HandleMiss"-->>"Lookup": "Error(not_found_error)"
"Lookup"-->>"WhichResolver": "Error"
"WhichResolver"-->>"Caller": "Err(not_found)"
end
end
```

Structural view of the which module and configuration wiring:

```mermaid
classDiagram
class StdlibConfig {
+workspace_root_path() -> Option<&Utf8Path>
}

class Environment {
+register_with_config(config: StdlibConfig)
}

class WhichModule {
+register(env: &mut Environment, cwd_override: Option<Arc<Utf8PathBuf>>)
}

class WhichResolver {
-cache: Arc<Mutex<LruCache<CacheKey, CacheEntry>>>
-cwd_override: Option<Arc<Utf8PathBuf>>
+new(cwd_override: Option<Arc<Utf8PathBuf>>) -> WhichResolver
+resolve(command: &str, options: &WhichOptions) -> Result<Vec<Utf8PathBuf>, Error>
}

class EnvSnapshot {
+cwd: Utf8PathBuf
+raw_path: Option<OsString>
+capture(cwd_override: Option<&Utf8Path>) -> Result<EnvSnapshot, Error>
}

class WhichOptions {
+cwd_mode: CwdMode
+canonical: bool
+all: bool
+fresh: bool
}

class CwdMode {
<<enumeration>>
+Never
+OtherModes
}

Environment --> StdlibConfig : uses
Environment --> WhichModule : calls register
StdlibConfig --> WhichModule : provides workspace_root_path as cwd_override
WhichModule --> WhichResolver : constructs via new(cwd_override)
WhichResolver --> EnvSnapshot : calls capture(cwd_override)
WhichResolver --> WhichOptions : reads lookup options
WhichOptions --> CwdMode : uses cwd_mode
```

### Cucumber execution flow

```mermaid
sequenceDiagram
actor "Developer" as "Developer"
participant "TestRunner" as "Rust test binary"
participant "CliWorld" as "CliWorld"
participant "Cucumber" as "Cucumber runner"
participant "FS" as "Feature files under 'tests/features'"

"Developer"->>"TestRunner": "run 'cargo test' (including cucumber tests)"
"TestRunner"->>"CliWorld": "create world instance"
"CliWorld"->>"CliWorld": "configure via 'cucumber()'"
"CliWorld"->>"Cucumber": "builder with 'max_concurrent_scenarios(1)'"
"Cucumber"->>"FS": "discover '.feature' files in 'tests/features'"
"Cucumber"->>"CliWorld": "execute scenarios sequentially (max 1)"
"CliWorld"-->>"Cucumber": "scenario results (stdout, stderr, exit codes)"
"Cucumber"-->>"TestRunner": "aggregate results and 'run_and_exit'"
"TestRunner"-->>"Developer": "process exit code and output with improved diagnostics"
```

Implementation mirrors the design with a small (64-entry) LRU cache keyed by
the command name, current directory, `PATH`, optional `PATHEXT`, and every
filter option aside from `fresh`. Cache hits validate metadata before returning
Expand Down
5 changes: 4 additions & 1 deletion src/stdlib/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,10 @@ pub fn register_with_config(env: &mut Environment<'_>, config: StdlibConfig) ->
register_file_tests(env);
path::register_filters(env);
collections::register_filters(env);
which::register(env);
let which_cwd = config
.workspace_root_path()
.map(|path| Arc::new(path.to_path_buf()));
which::register(env, which_cwd);
let impure = state.impure_flag();
let (network_config, command_config) = config.into_components();
network::register_functions(env, Arc::clone(&impure), network_config);
Expand Down
6 changes: 4 additions & 2 deletions src/stdlib/which/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,19 @@ pub(super) const CACHE_CAPACITY: usize = 64;
#[derive(Clone, Debug)]
pub(crate) struct WhichResolver {
cache: Arc<Mutex<LruCache<CacheKey, CacheEntry>>>,
cwd_override: Option<Arc<Utf8PathBuf>>,
}

impl WhichResolver {
pub(crate) fn new() -> Self {
pub(crate) fn new(cwd_override: Option<Arc<Utf8PathBuf>>) -> Self {
#[expect(
clippy::unwrap_used,
reason = "cache capacity constant is greater than zero"
)]
let capacity = NonZeroUsize::new(CACHE_CAPACITY).unwrap();
Self {
cache: Arc::new(Mutex::new(LruCache::new(capacity))),
cwd_override,
}
}

Expand All @@ -38,7 +40,7 @@ impl WhichResolver {
command: &str,
options: &WhichOptions,
) -> Result<Vec<Utf8PathBuf>, Error> {
let env = EnvSnapshot::capture()?;
let env = EnvSnapshot::capture(self.cwd_override.as_deref().map(Utf8PathBuf::as_path))?;
let key = CacheKey::new(command, &env, options);
if !options.fresh
&& let Some(cached) = self.try_cache(&key)
Expand Down
8 changes: 6 additions & 2 deletions src/stdlib/which/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ pub(super) struct EnvSnapshot {
}

impl EnvSnapshot {
pub(super) fn capture() -> Result<Self, Error> {
let cwd = current_dir_utf8()?;
pub(super) fn capture(cwd_override: Option<&Utf8Path>) -> Result<Self, Error> {
let cwd = if let Some(override_cwd) = cwd_override {
override_cwd.to_path_buf()
} else {
current_dir_utf8()?
};
let raw_path = std::env::var_os("PATH");
let entries = parse_path_entries(raw_path.clone(), &cwd)?;
#[cfg(windows)]
Expand Down
Loading
Loading