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
91 changes: 91 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -2406,6 +2406,97 @@ The full normalization contract, which the property tests in
`parse_pathext(None)` and `parse_pathext(Some("; ;"))` both yield
`DEFAULT_PATHEXT`.

### Home-directory resolution ladders

`stdlib::path::path_utils` resolves the user's home through two precedence
ladders — POSIX (`HOME`, then `USERPROFILE`) and Windows (those two, then the
`HOMEDRIVE`/`HOMEPATH` pair, then `HOMESHARE`). Both take an injected
`read_env` closure. `home_from_env` remains the sole platform-*selection*
point, and each ladder is gated to its own platform plus `test`
(`posix_home_from` is `#[cfg(any(not(windows), test))]`, `windows_home_from`
is `#[cfg(any(windows, test))]`), so a release build compiles only the ladder
it uses while the `test` arm keeps both reachable from any host.

#### Ladder ownership and call sites

- The ladders are `pub(super)` and owned by `stdlib::path`. They are not a
general home-directory utility: callers elsewhere use `expanduser`.
- `expanduser` resolves the home through the injected `HomeDirectory` value
and an injected `read_env` reader: `Explicit` and `Missing` never touch the
environment, and `Ambient` drives `home_from_env` with whatever reader the
caller supplied. The composition root lives at the registration boundary —
filter registration in `stdlib::path::filters` captures the process-backed
reader once, carrying the sanctioned site-level expectation — so
`path_utils` holds no process access at all. Tests inject their own reader,
covering the `Ambient` path without touching the process environment.

#### Ladder composition rules

- Keep each ladder free of platform *selection logic*, leaving that to
`home_from_env`. Gating decides only whether a ladder compiles, never which
one applies — that separation is what lets the `test` arm expose both
ladders to the CI host.
- Gate each ladder `#[cfg(any(windows, test))]` or its inverse, and have
`home_from_env` name only the ladder it selects. Compiling both
unconditionally would leave the inapplicable one dead in a release build,
which `-D warnings` rejects; the previous workaround — binding both as a
`(posix, windows)` function-pointer pair so the unused one counted as
referenced — was an artificial dead-code anchor and has been removed.
Bounded constants used by only one ladder (`HOME_SOURCE_DRIVE_PATH` and
`HOME_SOURCE_HOMESHARE`, both Windows-only) carry the same gate as the
ladder that reads them.
- The ladders report what the environment says. An empty value is passed
through rather than treated as unset for the single-variable readings
(`HOME`, `USERPROFILE`, `HOMESHARE`), and interpreting that is
`expanduser`'s concern, not theirs. The `HOMEDRIVE`/`HOMEPATH` pair is the
exception: it counts only when both halves are non-empty, since a bare
drive or a bare relative path is not a home directory; an incomplete pair
falls through to `HOMESHARE`.

#### Home-resolution telemetry

The ladders stay pure: each *returns* the resolved home paired with a bounded
`&'static str` label naming the rung that supplied it, and emits nothing.
`resolve_home` is the sole telemetry boundary, emitting a
`tracing::debug!` event for every resolution, plus an additional
`tracing::debug!` failure event when no home was available, with these
fields:

Table: Home-resolution telemetry fields.

| Field | Meaning |
| --- | --- |
| `event` | Always `stdlib.expanduser.home`, so the events are filterable. |
| `source` | The bounded label naming what supplied the home. |
| `found` | Whether a home was resolved at all. |
| `outcome` | Present only on the failure event: `home_unavailable`. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

`source` is drawn from a closed set and is never derived from a value:

- `home` — `HOME`.
- `userprofile` — `USERPROFILE`.
- `drive_path` — the `HOMEDRIVE`/`HOMEPATH` pair, both halves non-empty.
- `homeshare` — `HOMESHARE`.
- `explicit` — a configured `HomeDirectory::Explicit` value.
- `missing` — no source supplied a home.

`resolve_home` also increments a counter,
`netsuke_stdlib_expanduser_home_total`, described once per process via a
`Once`-guarded `describe_counter!`, matching the pattern in
`stdlib::which::cache`. It carries two labels, both drawn from closed sets so
the series count is fixed by the code, never by the environment: `outcome` is
`found` or `home_unavailable`; `source` is the same bounded label set listed
above. It increments exactly once per resolution whatever the outcome, so the
counter totals resolutions rather than events — the failure path emits a
second *debug event* but no second sample. Both the success and failure cases
are pinned by tests in `src/stdlib/path/home_tests.rs`, which capture samples
through a local `metrics_util` `DebuggingRecorder` rather than the global one.

The events carry no paths and no environment values: neither the resolved
home, nor a variable's contents, nor the expanded result. Adding a rung means
adding a label to the closed set above and pinning it in the ladder tests, not
recording the value that distinguished it.

### Configuration discovery module layout

`src/cli/discovery.rs` attaches several small `#[path = "..."]` modules that
Expand Down
7 changes: 7 additions & 0 deletions docs/netsuke-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,13 @@ Implementation notes:
- `expanduser` mirrors shell semantics by inspecting `HOME`, `USERPROFILE`,
and on Windows the `HOMEDRIVE`/`HOMEPATH` or `HOMESHARE` fallbacks.
Platform-specific forms such as `~user` remain unsupported.
- The sole process-environment read for home resolution lives at the
registration boundary: `register_expanduser` in `stdlib::path::filters`
captures a process-backed reader closure once and injects it into the pure
resolution ladders in `path_utils`. Those ladders never touch the ambient
environment directly; they only consult whatever reader they are given.
Tests supply their own readers, exercising the ladders without reaching the
process environment.
- `with_suffix` removes dotted suffix segments (default `n = 1`) before
appending the provided suffix.

Expand Down
14 changes: 13 additions & 1 deletion docs/stdlib-yaml-and-jinja-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,19 @@ split the same input identically.
`{{ 'input-link' | realpath }}`.
- `path | expanduser` is host-observing because it reads the home-directory
environment. It expands `~` and `~/...`; named-user forms such as `~alice`
are unsupported. For example, `{{ '~/cache' | expanduser }}`.
are unsupported. The filter tries a platform-specific ladder of variables
and uses the first one that resolves, treating an empty value as set (not
skipped) except in the `HOMEDRIVE`/`HOMEPATH` case noted below:
- POSIX hosts: `HOME`, then `USERPROFILE`.
- Windows hosts: `HOME`, then `USERPROFILE`, then `HOMEDRIVE` joined with
`HOMEPATH`, then `HOMESHARE`. The joined pair is used only when both
halves are non-empty because an empty `HOMEPATH` would yield a bare
drive letter (`C:`) and an empty `HOMEDRIVE` would yield a
current-drive path (`\me`); an incomplete pair falls through to
`HOMESHARE`.
- If none of these resolve, the filter fails with an error.

For example, `{{ '~/cache' | expanduser }}`.

## Read and identify files

Expand Down
9 changes: 9 additions & 0 deletions docs/users-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,15 @@ every helper's signature, defaults, purity, platform caveats, and executable
examples. Host-observing helpers belong only in trusted manifests: Netsuke
bounds command and network output, but does not sandbox template evaluation.

One helper deserves a note here because its result depends on the host's
environment. `path | expanduser` expands a leading `~` against the home
directory, resolved from `HOME` then `USERPROFILE` on POSIX hosts, and from
`HOME`, `USERPROFILE`, the `HOMEDRIVE`/`HOMEPATH` pair, then `HOMESHARE` on
Windows. The Windows pair counts only when both halves are non-empty; an
incomplete pair falls through to `HOMESHARE`. Named-user forms such as
`~alice` are unsupported, and when no home directory resolves at all, the
filter fails rather than passing the `~` through silently.

## Use the command-line interface

The top-level command shape is:
Expand Down
10 changes: 10 additions & 0 deletions proptest-regressions/stdlib/path/home_tests.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
# Recorded while mutation-testing the property (the HOMEDRIVE emptiness
# check was disabled to prove the test detects it), not from a defect in
# the ladder itself.
cc be405399e1c660af7820653ee114f838dcaba6702e5fe1b47efc3a0a4953002c # shrinks to home = None, userprofile = None, homedrive = Some(""), homepath = Some("a"), homeshare = None
21 changes: 18 additions & 3 deletions src/stdlib/path/filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@ use super::{fs_utils, hash_utils, path_utils};
use crate::localization::{self, keys};
use crate::stdlib::config_types::HomeDirectory;

/// Register the `expanduser` filter.
///
/// Composition root: the one sanctioned ambient environment read. The
/// process-backed reader is captured here, at the registration boundary, and
/// injected into `expanduser`, so `path_utils` holds no process access and
/// `HomeDirectory::Ambient` consults whatever reader registration supplies.
fn register_expanduser(env: &mut Environment<'_>, home_directory: HomeDirectory) {
#[expect(
clippy::disallowed_methods,
reason = "composition root: registration captures the process-backed reader once and injects it into the home ladders"
)]
let read_env = |key: &str| std::env::var(key).ok();
env.add_filter("expanduser", move |raw: String| -> Result<String, Error> {
path_utils::expanduser(&raw, &home_directory, read_env)
});
}

pub(crate) fn register_filters(env: &mut Environment<'_>, home_directory: HomeDirectory) {
env.add_filter("basename", |raw: String| -> Result<String, Error> {
Ok(path_utils::basename(Utf8Path::new(&raw)))
Expand Down Expand Up @@ -39,9 +56,7 @@ pub(crate) fn register_filters(env: &mut Environment<'_>, home_directory: HomeDi
env.add_filter("realpath", |raw: String| -> Result<String, Error> {
path_utils::canonicalize_any(Utf8Path::new(&raw)).map(camino::Utf8PathBuf::into_string)
});
env.add_filter("expanduser", move |raw: String| -> Result<String, Error> {
path_utils::expanduser(&raw, &home_directory)
});
register_expanduser(env, home_directory);
env.add_filter("size", |raw: String| -> Result<u64, Error> {
fs_utils::file_size(Utf8Path::new(&raw))
});
Expand Down
79 changes: 79 additions & 0 deletions src/stdlib/path/home_metrics_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//! Tests for the bounded home-resolution counter.
//!
//! Split from `home_tests`, which covers the ladders and the tracing events;
//! these cases pin the metric alone. A local `DebuggingRecorder` captures the
//! samples without touching the global recorder, so they stay as isolated as
//! the ladder cases. Both labels are drawn from closed sets in `path_utils`,
//! so the assertions below pin the series a dashboard would group by.

use super::path_utils::{EXPANDUSER_HOME_TOTAL, expanduser};
use crate::stdlib::config_types::HomeDirectory;
use metrics_util::MetricKind;
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
use rstest::rstest;

/// Resolve one path under a local recorder and return the counter samples
/// as `(outcome, source, value)` triples.
fn samples_for(
home: &HomeDirectory,
read_env: impl Fn(&str) -> Option<String>,
) -> Vec<(String, String, u64)> {
let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
metrics::with_local_recorder(&recorder, || {
drop(expanduser("~/x", home, read_env));
});
snapshotter
.snapshot()
.into_vec()
.into_iter()
.filter_map(|(key, _unit, _description, value)| {
if key.kind() != MetricKind::Counter || key.key().name() != EXPANDUSER_HOME_TOTAL {
return None;
}
let label = |name: &str| {
key.key()
.labels()
.find(|label| label.key() == name)
.map(|label| label.value().to_owned())
};
let DebugValue::Counter(count) = value else {
return None;
};
Some((label("outcome")?, label("source")?, count))
})
.collect()
}

/// A resolution that finds a home records `found` against the rung that
/// supplied it — one sample, whichever branch resolved it.
#[rstest]
#[case::explicit(HomeDirectory::Explicit("/home/a".to_owned()), "explicit")]
#[case::ambient(HomeDirectory::Ambient, "home")]
fn a_resolved_home_is_counted_against_its_source(
#[case] home: HomeDirectory,
#[case] expected_source: &str,
) {
let read_env = |key: &str| (key == "HOME").then(|| "/home/a".to_owned());
let samples = samples_for(&home, read_env);
assert_eq!(
samples,
vec![("found".to_owned(), expected_source.to_owned(), 1)],
"one sample labelled by outcome and source",
);
}

/// A resolution that finds nothing records the failure outcome against the
/// `missing` source, and still counts exactly once despite the second debug
/// event the failure path emits.
#[rstest]
#[case::missing(HomeDirectory::Missing)]
#[case::ambient_with_an_empty_reader(HomeDirectory::Ambient)]
fn an_unresolvable_home_is_counted_once(#[case] home: HomeDirectory) {
let samples = samples_for(&home, |_| None);
assert_eq!(
samples,
vec![("home_unavailable".to_owned(), "missing".to_owned(), 1)],
"the failure path adds an event, not a second sample",
);
}
Loading
Loading