crates/nornir-config/src/lib.rs
//! Loader for the consumer's `nornir.toml`.
//!
//! Discovery rule (used by the CLI when no explicit path is given):
//! walk up from `cwd` looking for `workspace_holger/release/nornir.toml`.
//! The discovered file's *grandparent of the grandparent* is the
//! **workspace root** — the dir containing `workspace_holger/`,
//! `holger/`, `znippy/`, etc. All relative paths inside `nornir.toml`
//! (including `[guard].forbidden`) are interpreted against that root.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
// ── The nornir data root: `<base>/.nornir`, base = the running user's home ──
//
// ONE rule for everyone, NO path env-vars. The nornir data root is
// `<base>/.nornir`, where `<base>` defaults to the RUNNING USER's home:
// - a login user `alice` (HOME=/home/alice) → `/home/alice/.nornir`
// - the server (system user `nornir`, HOME=/home/nornir) → `/home/nornir/.nornir`
// Containers get the right path by setting the user's `$HOME` (the standard
// way) — never a nornir-specific env var. Home is resolved with the `home`
// crate, which reads `$HOME` then falls back to getpwuid, so it's correct for
// the systemd service user AND for `sudo -H -u nornir`.
//
// ── The EMBEDDER seam ([`pin_nornir_base`]) ─────────────────────────────────
//
// An EMBEDDING product (`dwarves-server`/`dwarves-solo` compile nornir in) has a
// data root of its OWN that it declares to its uninstaller. Before this seam the
// embedded nornir still wrote to `$HOME/.nornir` — a SECOND root beside the
// declared one, which is why dwarves' uninstall carried a hardcoded legacy-root
// special case for `/home/dwarves/.nornir`. A maintained list of known roots is
// exactly what the uninstall law exists to eliminate.
//
// The only lever that existed was `$HOME`, and moving that also moves `~/.cargo`,
// `~/.rustup` and the server's PATH injection — which is why nobody pulled it.
//
// This is NOT a path env-var and the policy above stands unchanged: it is an
// in-process, set-once override an embedder installs before it starts the server.
// The SHAPE is still `<base>/.nornir` derived by the one writer
// [`nornir_home_from`] — only the base moves, so no caller learns a second layout.
/// Pure, testable: the nornir data root for a given base directory.
/// `nornir_home_from(Path::new("/home/alice")) == "/home/alice/.nornir"`.
pub fn nornir_home_from(home: &Path) -> PathBuf {
home.join(".nornir")
}
/// The process-wide base pinned by [`pin_nornir_base`]. Unset ⇒ the running
/// user's home.
static PINNED_BASE: OnceLock<PathBuf> = OnceLock::new();
/// Has [`nornir_home`] already answered? Set on every resolution, so a pin that
/// arrives too late is REFUSED rather than silently splitting the process across
/// two roots — the exact failure this seam exists to remove.
static BASE_RESOLVED: AtomicBool = AtomicBool::new(false);
/// The minimum base depth. `/` and one-component bases (`/home`, `/var`) would
/// put the data root at `/.nornir` or `/home/.nornir`; a sweep of either is a
/// catastrophe, so the mistake fails where it is written. Mirrors
/// `skidbladnir::uninstall::MIN_ROOT_COMPONENTS`.
const MIN_BASE_COMPONENTS: usize = 2;
/// WHY a [`pin_nornir_base`] was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PinRefused {
/// Not absolute — it would resolve against the process CWD, and a systemd
/// unit's `WorkingDirectory=/` makes that the filesystem root.
NotAbsolute { attempted: PathBuf },
/// Fewer than [`MIN_BASE_COMPONENTS`] components.
TooShallow { attempted: PathBuf, components: usize },
/// [`nornir_home`] already answered with the default; pinning now would move
/// the root out from under whatever already opened it.
AlreadyResolved { attempted: PathBuf },
/// A different base is already pinned. Two bases IS the two-roots bug.
Conflict { pinned: PathBuf, attempted: PathBuf },
}
impl std::fmt::Display for PinRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PinRefused::NotAbsolute { attempted } => write!(
f,
"nornir data-root base {} is not an absolute path",
attempted.display()
),
PinRefused::TooShallow { attempted, components } => write!(
f,
"nornir data-root base {} is only {components} component(s) deep \
(minimum {MIN_BASE_COMPONENTS})",
attempted.display()
),
PinRefused::AlreadyResolved { attempted } => write!(
f,
"cannot pin the nornir data-root base to {} — the root was already \
resolved in this process; pin BEFORE anything reads it",
attempted.display()
),
PinRefused::Conflict { pinned, attempted } => write!(
f,
"the nornir data-root base is already pinned to {} — refusing to \
re-pin it to {}",
pinned.display(),
attempted.display()
),
}
}
}
impl std::error::Error for PinRefused {}
/// Pure decision, dependency-injected: may `want` become the base, given the
/// currently-pinned base and whether the root has already been resolved?
///
/// Split out from [`pin_nornir_base`] because the global is set-once per PROCESS
/// — a test can exercise the real setter exactly once, so every rule lives here
/// where each can be proven independently.
fn decide_pin(
pinned: Option<&Path>,
already_resolved: bool,
want: &Path,
) -> Result<PathBuf, PinRefused> {
if let Some(p) = pinned {
return if p == want {
Ok(want.to_path_buf()) // idempotent — the same pin twice is fine
} else {
Err(PinRefused::Conflict { pinned: p.to_path_buf(), attempted: want.to_path_buf() })
};
}
if !want.is_absolute() {
return Err(PinRefused::NotAbsolute { attempted: want.to_path_buf() });
}
let components = want
.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.count();
if components < MIN_BASE_COMPONENTS {
return Err(PinRefused::TooShallow { attempted: want.to_path_buf(), components });
}
if already_resolved {
return Err(PinRefused::AlreadyResolved { attempted: want.to_path_buf() });
}
Ok(want.to_path_buf())
}
/// **The embedder seam.** Pin the base the nornir data root hangs under for the
/// rest of THIS PROCESS, so an embedding product's nornir state lands inside the
/// root that product declares to its uninstaller — one declared root, one eraser.
///
/// Returns the resulting data root (`<base>/.nornir`). Set-once: pinning the same
/// base again is a no-op; a DIFFERENT base, or a pin after the root has already
/// been read, is [`PinRefused`] — never a quiet second root.
///
/// Call it before starting the server (dwarves does, in its fat serve leg). It is
/// deliberately not an env var: `NORNIR_ROOT`/`NORNIR_WAREHOUSE_ROOT` were removed
/// on purpose and the no-path-env-vars policy above is unchanged.
pub fn pin_nornir_base(base: &Path) -> Result<PathBuf, PinRefused> {
let decided = decide_pin(
PINNED_BASE.get().map(PathBuf::as_path),
BASE_RESOLVED.load(Ordering::Acquire),
base,
)?;
// A racing setter can win the OnceLock; the loser must still agree, or it is
// the two-roots bug wearing a thread.
match PINNED_BASE.set(decided.clone()) {
Ok(()) => Ok(nornir_home_from(&decided)),
Err(_) => {
let winner = PINNED_BASE.get().expect("set() failed ⇒ a value is present");
if winner == &decided {
Ok(nornir_home_from(&decided))
} else {
Err(PinRefused::Conflict { pinned: winner.clone(), attempted: decided })
}
}
}
}
/// The currently-pinned base, or `None` when the root still derives from `$HOME`.
pub fn pinned_nornir_base() -> Option<PathBuf> {
PINNED_BASE.get().cloned()
}
/// The nornir data root: `<base>/.nornir`, where `<base>` is the
/// [`pin_nornir_base`] pin when an embedder set one, else the **running user's**
/// home. Panics only if the OS can resolve no home at all (no `$HOME`, no passwd
/// entry) and nothing was pinned — a misconfigured environment we can't sensibly
/// proceed in.
pub fn nornir_home() -> PathBuf {
// Record the read BEFORE answering: a pin arriving after this point must be
// refused, even if it races us.
BASE_RESOLVED.store(true, Ordering::Release);
if let Some(base) = PINNED_BASE.get() {
return nornir_home_from(base);
}
nornir_home_from(&home::home_dir().expect("resolve running user's HOME"))
}
/// The workspace **registry root** — `<home>/.nornir/workspaces`. Holds
/// `registry.redb` and the per-workspace `<name>/{git,builds}/` trees.
pub fn registry_root() -> PathBuf {
nornir_home().join("workspaces")
}
/// The default embedded/fat **warehouse** — `<home>/.nornir/warehouse`.
pub fn warehouse_default_root() -> PathBuf {
nornir_home().join("warehouse")
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Nornir {
#[serde(default)]
pub guard: Guard,
#[serde(default)]
pub storage: Storage,
#[serde(default)]
pub repo: BTreeMap<String, Repo>,
/// Declarative build variants (task #19) — the set of valid custom builds a
/// repo offers (holger's server/agent/mannequin/gui, …). Empty by default;
/// drives `nornir build --variant <name>`. The same `[build]` /
/// `[[build.variant]]` schema can instead live in a standalone
/// `.nornir/build-variants.toml` (see `nornir_build_thing::variant`).
#[serde(default)]
pub build: nornir_build_thing::variant::BuildVariantSet,
/// Registry-state check backend (release-improvements item #10). The release
/// preflight asks this registry "what version is actually PUBLISHED?" per
/// crate to catch a local-vs-published DESYNC. Default = crates.io (no config
/// needed); set `backend = "custom"` + `url = "<sparse-index-base>"` to point
/// at a private registry / holger mirror. A `--registry-url` CLI flag overrides.
#[serde(default)]
pub registry: RegistryConfig,
}
/// The `[registry]` section — which backend the release preflight's
/// registry-state check queries. Defaults to crates.io.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RegistryConfig {
/// `"crates-io"` (default) or `"custom"`.
#[serde(default = "default_registry_backend")]
pub backend: String,
/// Sparse-index base URL when `backend = "custom"` (e.g. a private registry /
/// holger mirror). Ignored for `crates-io`.
#[serde(default)]
pub url: String,
}
fn default_registry_backend() -> String {
"crates-io".to_string()
}
impl Default for RegistryConfig {
fn default() -> Self {
Self { backend: default_registry_backend(), url: String::new() }
}
}
// NB (config seam): `nornir.toml` owns release *recipes* (gates/bench/
// publish_order) keyed by member name. It does **not** own where source comes
// from or whether a workspace is monitored:
// - member *source* (path | git + branch/ref) lives in the workspace
// descriptor `nornir-workspace.toml` (`crate::workspace::descriptor`);
// - *monitored-ness* + poll interval + per-member sync state live in the
// server's registry record (`crate::registry`);
// - the server *root* (`<root>/<ws>/{git,builds}`) is server-global —
// the home-derived `registry_root()` (`<home>/.nornir/workspaces`).
// `[repo].remote` below is kept only as doc-link metadata (README/CHANGELOG
// "repository" links); for monitored source the descriptor's `git` is
// authoritative.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Guard {
#[serde(default)]
pub forbidden: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Storage {
/// `"local"` (default) or `"remote"`. Empty string also = local.
#[serde(default)]
pub kind: String,
/// Workspace-root-relative dir holding `warehouse/` and `cache/`.
/// Defaults to `<home>/.nornir/workspaces/<name>/builds` (see `warehouse_root`)
/// when empty.
#[serde(default)]
pub local_path: String,
/// The warehouse ROOT, named VERBATIM — no `warehouse/` appended.
///
/// `local_path` can only ever name `<x>/warehouse`, because the resolver
/// appends that segment. The per-workspace layout the server serves is
/// `<home>/.nornir/workspaces/<name>/builds`, which HOLDS `catalog.redb`
/// beside a nested `warehouse/` data dir — so that root was unreachable
/// through `local_path` and could only be obtained by leaving `[storage]`
/// out entirely and letting the resolver guess it from the workspace name.
/// That guess is ?41 defect 1: it reads the CONFIG PATH, which is a property
/// of how a verb found its config rather than of the directory the operator
/// is standing in, so two verbs could each answer honestly and land on two
/// warehouses.
///
/// `root_path` closes the guess by making its answer sayable. Absolute is
/// honored verbatim; relative is joined under `workspace_root`. It wins over
/// `local_path` when both are set.
#[serde(default)]
pub root_path: String,
/// Flight endpoint URL when `kind = "remote"`.
#[serde(default)]
pub remote_url: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Repo {
/// On-disk directory of this repo's checkout, **relative to the workspace
/// root** (or absolute). Overrides the default `<workspace_root>/<name>`
/// convention — needed when the `[repo.<name>]` key and the directory
/// differ, e.g. a temp config that renders nornir's own book from a
/// `nornir-orch` worktree (`[repo.nornir] path = "nornir-orch"`). Empty =
/// fall back to the by-name convention (case-insensitive). Honored by
/// [`Nornir::repo_dir_for`].
#[serde(default)] pub path: String,
/// Doc-link metadata only (README/CHANGELOG "repository" links). NOT the
/// source-of-truth for monitored fetch — that's the descriptor's `git`.
#[serde(default)] pub remote: String,
#[serde(default)] pub history: String,
#[serde(default)] pub readme: String,
#[serde(default)] pub publish_order: Vec<Vec<String>>,
#[serde(default)] pub gates: Gates,
#[serde(default)] pub bench: BenchSpec,
/// `true` ⇒ this repo is PRIVATE and must NEVER be published to crates.io. The
/// crate-metadata gate fails it ("private — do not publish") and the promote path
/// hard-refuses it. Set for SSH-cloned nordisk repos (nornir, holger, korp,
/// ordning); the public HTTPS repos (facett, skade, znippy, knut) leave it false.
#[serde(default)] pub private: bool,
/// Extra cargo features to build this repo's **test matrix** with, comma-
/// separated (e.g. `gui,testmatrix`). Honored by `nornir test run <repo>`:
/// it seeds `NORNIR_TEST_FEATURES`, which the matrix's unit aspect turns into
/// `cargo test --features <list>`. Without it the unit aspect runs the repo's
/// DEFAULT feature set, so any feature-gated test (e.g. holger's
/// `--features gui` robot-UI cell) is silently skipped instead of landing as a
/// matrix row. An explicit `NORNIR_TEST_FEATURES` in the environment always
/// wins (a per-session override). Empty = default feature set.
#[serde(default)] pub test_features: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Gates {
#[serde(default)] pub no_path_patches: bool,
#[serde(default)] pub nexus_floor: bool,
#[serde(default)] pub no_regression: bool,
#[serde(default)] pub max_regression_pct: f64,
/// The CORE-SATURATION LAW gate: fail the release if any PARALLEL bench in
/// the newest run under-used the machine's cores (peak occupancy below
/// `min_core_busy_frac × n_cores`). Reads the warehouse `bench_telemetry`
/// table's `proc_cores_max` — **this process's** task-clock ÷ wall from
/// `/proc/self/stat`, not `/proc/stat`'s host-wide `cores_busy_max`, which
/// counts the neighbours and so let this gate pass a strictly serial arm on
/// any loaded box. Single-thread `_st` variants and single-core hosts are
/// exempt; rows written before `proc_cores_max` existed are reported as
/// SKIPPED rather than counted green.
#[serde(default)] pub core_saturation: bool,
/// Minimum peak core-occupancy fraction (0..1) a parallel bench must reach
/// when `core_saturation` is on. `0.0` → the release gate's default
/// `CORE_SATURATION_MIN_BUSY_FRAC` (0.9).
#[serde(default)] pub min_core_busy_frac: f64,
#[serde(default)] pub integration_roundtrip: Vec<String>,
#[serde(default)] pub docs_fresh: bool,
/// Fail the release if any `[guard].forbidden` path drifted from the
/// recorded manifest (perm re-grant or out-of-band content change).
#[serde(default)] pub guard_intact: bool,
/// Plan #5: run the repo's TEST MATRIX at release time and fail the gate on
/// red (any failing/stalled case in the newest run) or a regression vs the
/// last green run. HEAVY (it executes the suite) — opt-in per repo, and the
/// runner is invoked release/on-request only, never on workspace load.
#[serde(default)] pub tests: bool,
/// AUT5: the autonom completeness gate — fail the release if any discovered
/// surface (viz tab × {thin,fat}, CLI subcommand, MCP tool, unreached fn) has
/// NO covering test row and is NOT excused by a live `.nornir/autonom-allow.toml`
/// entry (`Surface − Covered ≠ ∅`), OR an allowlist entry is stale. HARD zero,
/// not a ratchet. Reads the warehouse `surface_coverage` table; LIGHT (no
/// suite run), so it can default-on per repo unlike the heavy `tests` gate.
#[serde(default)] pub coverage: bool,
}
/// Per-repo bench contract.
///
/// **Corpus shape (decided 2026-05-31):**
///
/// 1. A *corpus* is identified by a flat string name (e.g. `text_500mb`,
/// `rust_crate_mt32`) listed in [`Self::required_results`]. The list
/// is **the schema** — every release-time `BenchRun` must produce at
/// least one `nornir_bench::BenchResult` with each of these names.
/// Missing names → release gate fails *before* regression checks run.
/// 2. Result name format: `<corpus>[_<variant>]`, snake_case, ASCII.
/// Variants are free-form and meaningful only to the repo (e.g.
/// `_st` / `_mt32` for single- vs 32-thread variants in holger).
/// 3. Per-result `metrics` is a free-form JSON map — but cross-repo
/// rollups (Time-Travel viz, regression gate) only auto-discover
/// numeric metrics. Recommended (not enforced) suffix convention:
/// - throughput → `*_mbs` (MiB/s)
/// - latency → `*_ms` or `*_us`
/// - ratios → `*_pct`
/// - counts → plain noun (`files`, `chunks`)
/// 4. Stability requirement: once a name lands in `required_results`,
/// it MUST keep producing comparable numbers across releases. Renames
/// are schema breaks and require a workspace.toml update.
///
/// **Environmental requirements:**
///
/// - [`Self::network_required`] — set `true` if the corpus pulls real
/// artifacts at test-time (e.g. ljar's maven_artifacts suite hits
/// Maven Central). When true the upcoming funnel `network_probe`
/// node must pass before `cargo_test` / `cargo_bench` run for this
/// repo; in offline mode the pipeline records `status=skipped_offline`
/// for these stages rather than failing them.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct BenchSpec {
#[serde(default)]
pub required_results: Vec<String>,
#[serde(default)]
pub network_required: bool,
}
impl BenchSpec {
/// Verify a `BenchRun` produces every required result name. Returns
/// the sorted list of missing names (empty == ok).
pub fn missing_in<'a>(&'a self, run: &nornir_bench::BenchRun) -> Vec<&'a str> {
let produced: std::collections::HashSet<&str> =
run.results.iter().map(|r| r.name.as_str()).collect();
let mut missing: Vec<&str> = self
.required_results
.iter()
.map(|s| s.as_str())
.filter(|n| !produced.contains(n))
.collect();
missing.sort();
missing
}
/// Fail with a clear error if any required result is missing.
pub fn validate(&self, run: &nornir_bench::BenchRun) -> anyhow::Result<()> {
let missing = self.missing_in(run);
if !missing.is_empty() {
anyhow::bail!(
"bench corpus is missing {} required result(s): [{}]",
missing.len(),
missing.join(", ")
);
}
Ok(())
}
}
/// A loaded config plus the paths it was discovered through.
///
/// `Default` yields an EMPTY config (no repos, empty paths) — used by the CLI in
/// thin-client mode, where every command routes to a remote server and no local
/// `nornir.toml` is required.
#[derive(Debug, Clone, Default)]
pub struct Loaded {
pub nornir: Nornir,
pub config_path: PathBuf,
pub workspace_root: PathBuf,
}
/// Where a warehouse root came from — the provenance of the ONE derivation in
/// [`Loaded::warehouse_root_resolved`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WarehouseOrigin {
/// `[storage].root_path` named the root outright — no `warehouse/` appended.
PinnedRoot,
/// `[storage].local_path` is an absolute path, honored verbatim.
PinnedAbsolute,
/// `[storage].local_path` is relative, joined under `workspace_root`.
PinnedRelative,
/// No `[storage].local_path`. The root can only be derived from the
/// workspace NAME, which is read out of the config PATH — an input that
/// differs between verbs. This branch is what silently moved the warehouse.
Unpinned { workspace_name: String },
}
/// One warehouse root and the reason it is that path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WarehouseRoot {
pub path: PathBuf,
pub origin: WarehouseOrigin,
}
/// A config that does not pin `[storage].local_path`, refused BY NAME.
///
/// Carries the config it read, the name it would have had to guess from, and
/// the path it would have invented — so the operator is told the whole story in
/// one line instead of discovering it as an empty `history-show`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnpinnedWarehouse {
pub config_path: PathBuf,
pub workspace_name: String,
pub would_be: PathBuf,
}
impl std::fmt::Display for UnpinnedWarehouse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} declares no [storage].local_path, so the warehouse root could only be guessed \
from the workspace name `{}` (which is read out of the config PATH, and two verbs \
need not discover the same config): it would have been {}. \
Refusing rather than writing where another verb will not read. \
Add a [storage] section naming local_path (absolute, or relative to the workspace \
root), or pass --workspace <name> to say which warehouse you mean.",
self.config_path.display(),
self.workspace_name,
self.would_be.display(),
)
}
}
impl std::error::Error for UnpinnedWarehouse {}
impl Loaded {
/// The canonical workspace name — the `<name>` from the `workspace_<name>`
/// component of the config path (the same derivation the viz picker uses),
/// e.g. `…/workspace_matrix/release/nornir.toml` → `matrix`. Falls back to
/// `default` when no `workspace_<name>` is present (a bare/ad-hoc config).
pub fn workspace_name(&self) -> String {
self.config_path
.components()
.filter_map(|c| c.as_os_str().to_str())
.find_map(|s| s.strip_prefix("workspace_").map(str::to_string))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "default".to_string())
}
/// **THE** warehouse derivation — the only place in the house where a
/// `nornir.toml` becomes a warehouse directory. Everything else
/// ([`Loaded::warehouse_root`], [`Loaded::warehouse_root_checked`]) is a
/// view of this one answer, so two verbs on one config cannot land on two
/// roots.
///
/// Three origins, and the third is the defect:
///
/// * [`WarehouseOrigin::PinnedAbsolute`] — an absolute `[storage].local_path`,
/// honored verbatim (a config may pin an explicit fast path).
/// * [`WarehouseOrigin::PinnedRelative`] — a relative `[storage].local_path`,
/// joined under `workspace_root`.
/// * [`WarehouseOrigin::Unpinned`] — no `[storage].local_path` at all. The
/// root is then derived from the workspace NAME, and the name is read out
/// of the CONFIG PATH — which different verbs discover differently. That
/// is how `bench run` wrote one warehouse while `history-show`, in the
/// same directory on the same config, read another and still printed
/// `N result(s) persisted`. An unpinned config is therefore reported as
/// such rather than silently answered; see [`Loaded::warehouse_root_checked`].
///
/// In every case the Iceberg data dir is `warehouse/` under the answer.
pub fn warehouse_root_resolved(&self) -> WarehouseRoot {
let storage = &self.nornir.storage;
if !storage.root_path.is_empty() {
// The root, said outright. `join` with an absolute path discards
// `workspace_root`, so an absolute value wins as intended.
return WarehouseRoot {
path: self.workspace_root.join(&storage.root_path),
origin: WarehouseOrigin::PinnedRoot,
};
}
if storage.local_path.is_empty() {
let workspace_name = self.workspace_name();
WarehouseRoot {
path: registry_root().join(&workspace_name).join("builds"),
origin: WarehouseOrigin::Unpinned { workspace_name },
}
} else {
// `join` with an absolute `local_path` discards `workspace_root`, so
// an absolute fast path (e.g. `/home/alice/.nornir`) wins as intended.
let origin = if Path::new(&storage.local_path).is_absolute() {
WarehouseOrigin::PinnedAbsolute
} else {
WarehouseOrigin::PinnedRelative
};
WarehouseRoot {
path: self.workspace_root.join(&storage.local_path).join("warehouse"),
origin,
}
}
}
/// Per-workspace Iceberg warehouse root — the path half of
/// [`Loaded::warehouse_root_resolved`], with no second derivation of its own.
///
/// PER-WORKSPACE layout (decided 2026-06-12): with no `[storage].local_path`
/// the answer is `<home>/.nornir/workspaces/<name>/builds` —
/// **byte-for-byte the path the server opens** (`nornir-server` serves, and
/// `monitor::republish` builds, `<root>/<name>/builds`; `catalog.redb` lives
/// right there, with Iceberg's own `warehouse/` data dir nested inside). So
/// fat and server read the *same* warehouse. An explicit `[storage].local_path`
/// still overrides (absolute path honored verbatim; relative joined under
/// `workspace_root`).
///
/// ⚠ This accessor answers even for an unpinned config. A verb that
/// **writes**, or that a reader must later meet, calls
/// [`Loaded::warehouse_root_checked`] instead.
pub fn warehouse_root(&self) -> PathBuf {
self.warehouse_root_resolved().path
}
/// The same one answer, but an **unpinned** config is refused BY NAME
/// instead of answered.
///
/// This is the ruling behind ?41 defect 1: a `nornir.toml` with no
/// `[storage]` section does not get to decide the warehouse, because the
/// only thing left to derive it from is the config PATH, and two verbs in
/// one directory do not have to discover the same config path. Rather than
/// make the derivation "identical for all verbs" — which cannot be promised
/// while the input itself varies per verb — the unpinned branch is closed:
/// the operator names the storage, or nothing is opened. A refusal is
/// recoverable; a success line over a write nobody can read is not.
pub fn warehouse_root_checked(&self) -> Result<PathBuf, UnpinnedWarehouse> {
let resolved = self.warehouse_root_resolved();
match resolved.origin {
WarehouseOrigin::PinnedRoot
| WarehouseOrigin::PinnedAbsolute
| WarehouseOrigin::PinnedRelative => Ok(resolved.path),
WarehouseOrigin::Unpinned { workspace_name } => Err(UnpinnedWarehouse {
config_path: self.config_path.clone(),
workspace_name,
would_be: resolved.path,
}),
}
}
/// Per-workspace funnel store — `<home>/.nornir/workspaces/<name>/funnel`
/// (a workspace's planning DAG belongs to the workspace, not a global dir).
pub fn funnel_root(&self) -> PathBuf {
registry_root().join(self.workspace_name()).join("funnel")
}
}
impl Nornir {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
toml::from_str(&text).with_context(|| format!("parse {}", path.display()))
}
/// Resolve the repo's filesystem path inside `workspace_root`
/// (convention: `<workspace_root>/<repo_name>/`).
pub fn repo_dir(workspace_root: &Path, name: &str) -> PathBuf {
workspace_root.join(name)
}
/// Resolve `name`'s on-disk checkout, honoring an explicit
/// `[repo.<name>].path` override before falling back to the by-name
/// convention ([`repo_dir_resolved`]).
///
/// The override is the only way a config can point a repo at a directory
/// whose name differs from the `[repo.<name>]` key — e.g. rendering
/// nornir's *own* book from a sibling `nornir-orch` worktree via
/// `[repo.nornir] path = "nornir-orch"`. Without it, `docs book nornir`
/// silently resolved to the by-name `<workspace_root>/nornir` checkout (or,
/// when that was absent, fell through to a bogus path) instead of the
/// configured one. An **absolute** override is honored verbatim; a
/// **relative** one is joined under `workspace_root`. Empty (the default)
/// preserves the prior behavior exactly, so other repos are unaffected.
pub fn repo_dir_for(&self, workspace_root: &Path, name: &str) -> PathBuf {
if let Some(repo) = self.repo.get(name) {
if !repo.path.trim().is_empty() {
let p = Path::new(repo.path.trim());
return if p.is_absolute() {
p.to_path_buf()
} else {
workspace_root.join(p)
};
}
}
repo_dir_resolved(workspace_root, name)
}
}
/// Resolve a repo's on-disk directory inside `workspace_root`, tolerating a
/// case mismatch between the configured `[repo.<name>]` key and the actual
/// directory name (e.g. `[repo.njord]` ↔ a `Njord/` checkout). Tries the exact
/// `<workspace_root>/<name>` first; if that doesn't exist, falls back to a
/// case-insensitive match among the workspace's sub-directories. Returns the
/// exact join when nothing matches, so callers still get a sensible path to
/// report in errors.
pub fn repo_dir_resolved(workspace_root: &Path, name: &str) -> PathBuf {
let exact = Nornir::repo_dir(workspace_root, name);
if exact.exists() {
return exact;
}
// An empty `workspace_root` means "relative to the current directory";
// `read_dir("")` errors, which would silently skip the case-insensitive
// fallback — scan "." instead so e.g. `[repo.njord]` still resolves to a
// `Njord/` checkout in the cwd.
let scan_root: &Path = if workspace_root.as_os_str().is_empty() {
Path::new(".")
} else {
workspace_root
};
if let Ok(entries) = std::fs::read_dir(scan_root) {
for entry in entries.flatten() {
if entry.file_name().to_string_lossy().eq_ignore_ascii_case(name)
&& entry.path().is_dir()
{
// When workspace_root is empty the caller expects a bare
// relative name (not "./Njord"); otherwise the full join.
return if workspace_root.as_os_str().is_empty() {
PathBuf::from(entry.file_name())
} else {
entry.path()
};
}
}
}
exact
}
/// Env knob that pins the constellation **workspace/git root** for
/// [`discover`], independent of the current working directory.
///
/// WHY (task #74 — worktree collision): `discover` otherwise only walks UP from
/// `cwd`, so it resolves config only from a canonical sibling checkout (e.g.
/// `/run/media/$USER/T9/git`). An agent running inside a git **worktree**
/// (`git worktree add …`) has a cwd whose ancestry does NOT contain the
/// constellation's `workspace_*/release/nornir.toml`, so discovery fails and
/// parallel-in-worktree runs break. Exporting
/// `NORNIR_WORKSPACE_ROOT=/run/media/$USER/T9/git` points discovery straight
/// at the real root — no hardcoded `nornir` sibling assumption, works from any
/// cwd. Unset/empty, or set to a dir that holds no config, falls through to the
/// existing walk-up unchanged (L2 additive — nothing regresses).
pub const WORKSPACE_ROOT_ENV: &str = "NORNIR_WORKSPACE_ROOT";
/// The `NORNIR_WORKSPACE_ROOT` override as a path, or `None` when unset/empty.
fn env_workspace_root() -> Option<PathBuf> {
std::env::var_os(WORKSPACE_ROOT_ENV)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
/// Try to resolve a `Loaded` config treating `dir` as a single candidate
/// directory — the body of the [`discover`] walk, factored out so the
/// `NORNIR_WORKSPACE_ROOT` override reuses the EXACT same three-way rule the
/// walk-up uses (no twinned resolution logic). `Ok(None)` = "nothing here, keep
/// looking"; `Err` = a config was found but failed to load/parse.
fn probe_dir(cur: &Path) -> Result<Option<Loaded>> {
// Standard: a `nornir.toml` right here.
let plain = cur.join("nornir.toml");
if plain.is_file() {
return Ok(Some(Loaded {
nornir: Nornir::load(&plain)?,
config_path: plain,
workspace_root: cur.to_path_buf(),
}));
}
// Standing INSIDE a `workspace_*/` dir: prefer its OWN `release/nornir.toml`
// over the alphabetical sibling scan one level up — so `cd workspace_nordisk &&
// nornir release …` uses the nordisk catalog, not whichever workspace sorts
// first. workspace_root is the parent (the git/ root), matching the sibling
// scan + `load_explicit`, so each `[repo.<name>]` resolves to `<git>/<name>`.
let here = cur.join("release/nornir.toml");
if here.is_file() {
if let Some(ws_root) = cur.parent() {
return Ok(Some(Loaded {
nornir: Nornir::load(&here)?,
config_path: here,
workspace_root: ws_root.to_path_buf(),
}));
}
}
// Mono-repo fallback: the first `workspace_*/release/nornir.toml` here.
if let Ok(rd) = std::fs::read_dir(cur) {
let mut hits: Vec<PathBuf> = rd
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("workspace_"))
&& p.join("release/nornir.toml").is_file()
})
.map(|p| p.join("release/nornir.toml"))
.collect();
hits.sort();
if let Some(candidate) = hits.into_iter().next() {
let nornir = Nornir::load(&candidate)?;
return Ok(Some(Loaded {
nornir,
config_path: candidate,
workspace_root: cur.to_path_buf(),
}));
}
}
Ok(None)
}
/// Discover `nornir.toml` by walking up from `start` (cargo-style): the first
/// ancestor containing a `nornir.toml` wins, and the workspace root is that
/// directory. Fallback for the dev mono-repo: any `workspace_*/release/nornir.toml`
/// under an ancestor (any `workspace_*`, NOT a hardcoded personal name) — there
/// the workspace root is the dir holding `workspace_*`.
///
/// Worktree escape hatch (task #74): if [`WORKSPACE_ROOT_ENV`]
/// (`NORNIR_WORKSPACE_ROOT`) names a directory that holds config, it is tried
/// FIRST — so a git-worktree cwd whose ancestry lacks the constellation still
/// resolves. It sits BELOW an explicit `--config`/`NORNIR_CONFIG` in the CLI
/// precedence (those call [`load_explicit`], not `discover`), and a
/// stale/empty override simply falls through to the walk-up.
pub fn discover(start: &Path) -> Result<Loaded> {
// L2 additive worktree knob — pin the root regardless of cwd.
if let Some(root) = env_workspace_root() {
if let Some(loaded) = probe_dir(&root)? {
return Ok(loaded);
}
// The override named no usable config; don't hard-fail — fall through to
// the ordinary walk-up so a leftover env var never breaks a run that
// would otherwise resolve from cwd.
}
let mut cur = start
.canonicalize()
.unwrap_or_else(|_| start.to_path_buf());
loop {
if let Some(loaded) = probe_dir(&cur)? {
return Ok(loaded);
}
if !cur.pop() {
return Err(anyhow!(
"no nornir.toml found walking up from {}. Put a nornir.toml at your project \
root, pass --config / set NORNIR_CONFIG, set NORNIR_WORKSPACE_ROOT to the \
constellation git root (useful from a git worktree), or (MCP/CLI client \
mode) set NORNIR_SERVER + NORNIR_SERVER_TOKEN to relay to a remote \
nornir-server with no local config.",
start.display()
));
}
}
}
/// Load from an explicit config path; workspace root = the two-up
/// ancestor (so `…/workspace_holger/release/nornir.toml` → `…/`).
pub fn load_explicit(config_path: &Path) -> Result<Loaded> {
let nornir = Nornir::load(config_path)?;
let workspace_root = config_path
.parent()
.and_then(Path::parent)
.and_then(Path::parent)
.ok_or_else(|| anyhow!("config path lacks grandparent dirs: {}", config_path.display()))?
.to_path_buf();
Ok(Loaded {
nornir,
config_path: config_path.to_path_buf(),
workspace_root,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// `$HOME` is process-global; serialize the HOME-mutating tests so they
/// can't observe each other's value under the parallel test runner.
static HOME_LOCK: Mutex<()> = Mutex::new(());
/// The pure home→root rule: `<home>/.nornir`, identical for ANY user.
#[test]
fn home_root_is_home_dot_nornir_for_any_user() {
// A login user.
assert_eq!(
nornir_home_from(Path::new("/home/alice")),
PathBuf::from("/home/alice/.nornir"),
);
// The server's system user — same code, same shape.
assert_eq!(
nornir_home_from(Path::new("/home/nornir")),
PathBuf::from("/home/nornir/.nornir"),
);
}
/// The composed roots (registry + default warehouse) hang off the home root
/// exactly as documented — assert the full path, not just "didn't panic".
#[test]
fn registry_and_warehouse_compose_under_home() {
let _g = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// Drive the running-user resolver through a known HOME so the assertion
// is deterministic on any host.
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var("HOME", "/home/bob") };
assert_eq!(nornir_home(), PathBuf::from("/home/bob/.nornir"));
assert_eq!(registry_root(), PathBuf::from("/home/bob/.nornir/workspaces"));
assert_eq!(
warehouse_default_root(),
PathBuf::from("/home/bob/.nornir/warehouse"),
);
}
/// An empty `[storage].local_path` falls back to the home-derived warehouse
/// (NOT the old `workspace_holger/.nornir/warehouse`), while an absolute
/// `local_path` is still honored verbatim.
#[test]
fn warehouse_root_default_is_home_derived() {
let _g = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var("HOME", "/home/carol") };
let empty = Loaded {
nornir: Nornir::default(),
config_path: PathBuf::from("/ws/workspace_holger/release/nornir.toml"),
workspace_root: PathBuf::from("/ws"),
};
// PER-WORKSPACE default = <home>/.nornir/workspaces/<name>/builds (the exact
// path the server opens), where <name> is the `workspace_<name>` component.
assert_eq!(
empty.warehouse_root(),
PathBuf::from("/home/carol/.nornir/workspaces/holger/builds"),
);
assert_eq!(empty.workspace_name(), "holger");
assert_eq!(
empty.funnel_root(),
PathBuf::from("/home/carol/.nornir/workspaces/holger/funnel"),
);
// Absolute pin still wins, joined with `warehouse/`.
let mut pinned = Loaded {
nornir: Nornir::default(),
config_path: PathBuf::from("/ws/workspace_holger/release/nornir.toml"),
workspace_root: PathBuf::from("/ws"),
};
pinned.nornir.storage.local_path = "/fast/disk".into();
assert_eq!(
pinned.warehouse_root(),
PathBuf::from("/fast/disk/warehouse"),
);
}
/// `[repo.<name>].path` redirects a repo to a directory whose name differs
/// from the key — the seam that lets `docs book nornir` render nornir's own
/// book from a configured `nornir-orch` worktree. A relative override joins
/// the workspace root; an absolute override is honored verbatim; an empty
/// override falls back to the by-name convention (so other repos are
/// untouched). Inject distinct paths, assert the exact resolved dir.
#[test]
fn repo_dir_for_honors_path_override_relative_and_absolute() {
let ws = Path::new("/ws");
let mut nornir = Nornir::default();
// Self-repo override: the `nornir` key points at a `nornir-orch` dir.
nornir.repo.insert(
"nornir".into(),
Repo { path: "nornir-orch".into(), ..Default::default() },
);
// Absolute override wins over the join.
nornir.repo.insert(
"skade".into(),
Repo { path: "/abs/skade-checkout".into(), ..Default::default() },
);
// No override → by-name convention.
nornir.repo.insert("znippy".into(), Repo::default());
assert_eq!(
nornir.repo_dir_for(ws, "nornir"),
PathBuf::from("/ws/nornir-orch"),
"relative override must join the workspace root, not the by-name dir",
);
assert_eq!(
nornir.repo_dir_for(ws, "skade"),
PathBuf::from("/abs/skade-checkout"),
"absolute override must be honored verbatim",
);
assert_eq!(
nornir.repo_dir_for(ws, "znippy"),
PathBuf::from("/ws/znippy"),
"empty override must fall back to the by-name convention",
);
// A repo absent from the table still resolves by-name (no panic).
assert_eq!(
nornir.repo_dir_for(ws, "ghost"),
PathBuf::from("/ws/ghost"),
);
}
// ── the embedder seam ([`pin_nornir_base`]) ─────────────────────────────
//
// The GLOBAL setter is set-once per PROCESS, so a unit test can drive it at
// most once and could never cover the refusal rules. Every rule therefore
// lives in the pure `decide_pin`, proven independently here; the two
// integration tests (`tests/pin_base.rs`, `tests/pin_after_read.rs`) each get
// their own process and prove the GLOBAL actually routes through it.
/// The happy path AND the shape: a pin yields `<base>/.nornir` — derived by
/// the same one writer, not a second layout.
#[test]
fn decide_pin_accepts_an_absolute_deep_base() {
let base = Path::new("/home/dwarves/.dwarves");
assert_eq!(decide_pin(None, false, base).unwrap(), PathBuf::from(base));
assert_eq!(
nornir_home_from(&decide_pin(None, false, base).unwrap()),
PathBuf::from("/home/dwarves/.dwarves/.nornir"),
"the pinned root must still be <base>/.nornir",
);
}
/// A relative base would resolve against the CWD — and a systemd unit's
/// `WorkingDirectory=/` makes that the filesystem root.
#[test]
fn decide_pin_refuses_a_relative_base() {
let e = decide_pin(None, false, Path::new(".dwarves")).unwrap_err();
assert_eq!(e, PinRefused::NotAbsolute { attempted: PathBuf::from(".dwarves") });
assert!(e.to_string().contains("not an absolute path"), "{e}");
}
/// `/` and `/home` would put the data root at `/.nornir` or `/home/.nornir`.
/// A sweep of either is the catastrophe the depth floor exists to prevent.
#[test]
fn decide_pin_refuses_a_shallow_base() {
for (p, n) in [("/", 0usize), ("/home", 1), ("/var", 1)] {
let e = decide_pin(None, false, Path::new(p)).unwrap_err();
assert_eq!(
e,
PinRefused::TooShallow { attempted: PathBuf::from(p), components: n },
"{p} must be refused as too shallow",
);
}
// …and the floor is exactly 2: `/home/dwarves` is accepted.
assert!(decide_pin(None, false, Path::new("/home/dwarves")).is_ok());
}
/// A pin that arrives AFTER the root was read is refused. Accepting it would
/// move the root out from under whatever already opened it — a process split
/// across two roots, which is the whole bug.
#[test]
fn decide_pin_refuses_a_pin_that_arrives_after_the_root_was_read() {
let want = Path::new("/home/dwarves/.dwarves");
assert!(decide_pin(None, false, want).is_ok(), "before any read it is fine");
let e = decide_pin(None, true, want).unwrap_err();
assert_eq!(e, PinRefused::AlreadyResolved { attempted: want.to_path_buf() });
assert!(e.to_string().contains("pin BEFORE anything reads it"), "{e}");
}
/// Two DIFFERENT bases is the two-roots bug wearing a function call — refused,
/// loudly. The SAME base twice is a no-op, so an idempotent caller is safe.
#[test]
fn decide_pin_is_idempotent_but_refuses_a_second_different_base() {
let a = Path::new("/home/dwarves/.dwarves");
let b = Path::new("/home/dwarves/.nornir-elsewhere");
assert_eq!(decide_pin(Some(a), true, a).unwrap(), a.to_path_buf(), "same base ⇒ no-op");
let e = decide_pin(Some(a), false, b).unwrap_err();
assert_eq!(
e,
PinRefused::Conflict { pinned: a.to_path_buf(), attempted: b.to_path_buf() },
);
assert!(e.to_string().contains("refusing to re-pin"), "{e}");
}
/// `NORNIR_WORKSPACE_ROOT` is process-global; serialize the tests that
/// mutate it so they can't observe each other under the parallel runner.
static WS_ROOT_LOCK: Mutex<()> = Mutex::new(());
/// Task #74 — the worktree escape hatch. A cwd whose ancestry does NOT
/// contain the constellation (the shape of a git *worktree* checked out
/// somewhere else) fails plain `discover`; setting `NORNIR_WORKSPACE_ROOT`
/// to the real git root makes resolution succeed from that non-canonical
/// cwd — proving the hardcoded sibling layout is no longer required. RED if
/// the override is dropped: the "worktree" branch reverts to the Err path.
#[test]
fn discover_honors_workspace_root_env_from_a_worktree_cwd() {
let _g = WS_ROOT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
// A unique sandbox under the OS temp dir — no ancestor holds a config.
let base = std::env::temp_dir().join(format!(
"nornir-cfg-t74-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
));
// The canonical constellation git root: <base>/git/workspace_probe/release/nornir.toml
let git_root = base.join("git");
let cfg = git_root.join("workspace_probe").join("release").join("nornir.toml");
std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
std::fs::write(&cfg, "# minimal nornir.toml for #74 test\n").unwrap();
// The "worktree" cwd lives OUTSIDE the git root — its ancestry has no config.
let worktree_cwd = base.join("elsewhere").join("edda-wt-xyz");
std::fs::create_dir_all(&worktree_cwd).unwrap();
// 1) Without the knob, discovery from the worktree cwd fails (walk-up
// finds nothing) — this is the collision the task describes.
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::remove_var(WORKSPACE_ROOT_ENV) };
assert!(
discover(&worktree_cwd).is_err(),
"a worktree cwd with no constellation in its ancestry must NOT resolve without the knob",
);
// 2) With NORNIR_WORKSPACE_ROOT pinned at the real git root, the SAME
// non-canonical cwd resolves to the constellation config.
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var(WORKSPACE_ROOT_ENV, &git_root) };
let loaded = discover(&worktree_cwd)
.expect("NORNIR_WORKSPACE_ROOT must resolve config from a non-canonical cwd");
assert_eq!(loaded.config_path, cfg, "config_path must be the pinned-root config");
assert_eq!(
loaded.workspace_root, git_root,
"workspace_root must be the pinned git root, not a hardcoded sibling",
);
assert_eq!(loaded.workspace_name(), "probe");
// 3) A stale/empty override falls through to the walk-up (no hard fail),
// so it can't break a run that would otherwise resolve from cwd.
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var(WORKSPACE_ROOT_ENV, base.join("does-not-exist")) };
assert!(
discover(&worktree_cwd).is_err(),
"a non-config override must fall through to the (here failing) walk-up, not resolve spuriously",
);
// Cleanup: drop the env var and the sandbox.
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::remove_var(WORKSPACE_ROOT_ENV) };
let _ = std::fs::remove_dir_all(&base);
}
}
/// ?41 defect 1 — the warehouse derivation is ONE function, and the branch that
/// could not promise the same answer to two verbs is closed by name.
#[cfg(test)]
mod one_warehouse_derivation_tests {
use std::path::PathBuf;
use super::{registry_root, Loaded, WarehouseOrigin};
fn pinned_absolute(dir: &str) -> Loaded {
let mut l = Loaded::default();
l.workspace_root = PathBuf::from("/home/alice/git/workspace_nordisk");
l.config_path = PathBuf::from("/home/alice/git/workspace_nordisk/release/nornir.toml");
l.nornir.storage.local_path = dir.to_string();
l
}
fn unpinned() -> Loaded {
let mut l = Loaded::default();
l.workspace_root = PathBuf::from("/home/alice/git/workspace_nordisk");
l.config_path = PathBuf::from("/home/alice/git/workspace_nordisk/release/nornir.toml");
l
}
/// `warehouse_root()` may never be a SECOND derivation: it is the path half
/// of the resolved answer, always, for every origin.
#[test]
fn warehouse_root_is_the_resolved_path_and_nothing_else() {
for l in [
pinned_absolute("/home/alice/fast/nordisk"),
pinned_absolute("builds"),
unpinned(),
] {
assert_eq!(l.warehouse_root(), l.warehouse_root_resolved().path);
}
}
/// An absolute `local_path` still wins verbatim, and it is labelled as such.
#[test]
fn an_absolute_local_path_is_honored_verbatim() {
let l = pinned_absolute("/home/alice/fast/nordisk");
let r = l.warehouse_root_resolved();
assert_eq!(r.origin, WarehouseOrigin::PinnedAbsolute);
assert_eq!(r.path, PathBuf::from("/home/alice/fast/nordisk/warehouse"));
assert_eq!(l.warehouse_root_checked().unwrap(), r.path);
}
/// A relative one is joined under the workspace root, and it is a PIN, so it
/// is answered rather than refused.
#[test]
fn a_relative_local_path_is_joined_under_the_workspace_root() {
let l = pinned_absolute(".nornir");
let r = l.warehouse_root_resolved();
assert_eq!(r.origin, WarehouseOrigin::PinnedRelative);
assert_eq!(
r.path,
PathBuf::from("/home/alice/git/workspace_nordisk/.nornir/warehouse")
);
assert!(l.warehouse_root_checked().is_ok());
}
/// THE DEFECT: with no `[storage]` the only input left is the config PATH.
/// The checked resolver refuses, and the refusal NAMES the config, the name
/// it would have guessed, and the path it would have invented.
#[test]
fn a_config_without_storage_is_refused_by_name() {
let l = unpinned();
let r = l.warehouse_root_resolved();
assert_eq!(
r.origin,
WarehouseOrigin::Unpinned { workspace_name: "nordisk".to_string() }
);
let err = l.warehouse_root_checked().expect_err("an unpinned config must be refused");
assert_eq!(err.config_path, l.config_path);
assert_eq!(err.workspace_name, "nordisk");
assert_eq!(err.would_be, registry_root().join("nordisk").join("builds"));
let said = err.to_string();
assert!(said.contains("release/nornir.toml"), "{said}");
assert!(said.contains("[storage].local_path"), "{said}");
}
/// And the guess it refuses to make really does move with the config path —
/// same directory, same storage, two config paths, two warehouses. That is
/// the whole reason the branch is closed.
#[test]
fn the_unpinned_guess_moves_with_the_config_path() {
let a = unpinned();
let mut b = unpinned();
b.config_path = PathBuf::from("/home/alice/git/nornir/nornir.toml");
assert_ne!(a.warehouse_root(), b.warehouse_root());
assert!(a.warehouse_root_checked().is_err());
assert!(b.warehouse_root_checked().is_err());
}
}
/// `[storage].root_path` — the per-workspace root said OUTRIGHT, which is what
/// makes closing the unpinned branch possible at all (?41 defect 1).
#[cfg(test)]
mod root_path_tests {
use std::path::PathBuf;
use super::{Loaded, WarehouseOrigin};
fn at(root_path: &str, local_path: &str) -> Loaded {
let mut l = Loaded::default();
l.workspace_root = PathBuf::from("/home/alice/git");
l.config_path = PathBuf::from("/home/alice/git/workspace_nordisk/release/nornir.toml");
l.nornir.storage.root_path = root_path.to_string();
l.nornir.storage.local_path = local_path.to_string();
l
}
/// The root the SERVER serves — `<home>/.nornir/workspaces/<name>/builds`,
/// which HOLDS `catalog.redb` beside a nested `warehouse/` — is unreachable
/// through `local_path`, because the resolver appends `warehouse/` to that.
/// `root_path` names it verbatim, so the workspace no longer has to leave
/// `[storage]` out to get it.
#[test]
fn root_path_names_the_root_verbatim_with_no_warehouse_segment() {
let l = at("/home/alice/.nornir/workspaces/nordisk/builds", "");
let r = l.warehouse_root_resolved();
assert_eq!(r.origin, WarehouseOrigin::PinnedRoot);
assert_eq!(
r.path,
PathBuf::from("/home/alice/.nornir/workspaces/nordisk/builds"),
"no `warehouse/` may be appended to a root_path",
);
assert_eq!(l.warehouse_root_checked().unwrap(), r.path);
}
/// And it is byte-for-byte what the guess used to invent — so adopting it
/// moves no existing warehouse, it only makes the same answer sayable.
#[test]
fn root_path_can_reproduce_the_guess_it_replaces() {
let mut guessing = Loaded::default();
guessing.workspace_root = PathBuf::from("/home/alice/git");
guessing.config_path =
PathBuf::from("/home/alice/git/workspace_nordisk/release/nornir.toml");
let guessed = guessing.warehouse_root();
assert!(guessing.warehouse_root_checked().is_err(), "the guess is refused");
let pinned = at(&guessed.display().to_string(), "");
assert_eq!(pinned.warehouse_root_checked().unwrap(), guessed);
}
/// A relative `root_path` is joined under the workspace root, still with no
/// `warehouse/` appended.
#[test]
fn a_relative_root_path_is_joined_under_the_workspace_root() {
let l = at("builds", "");
assert_eq!(
l.warehouse_root_checked().unwrap(),
PathBuf::from("/home/alice/git/builds")
);
}
/// Both set is not ambiguous: `root_path` is the more specific statement and
/// wins, deterministically, for every verb.
#[test]
fn root_path_wins_over_local_path() {
let l = at("/home/alice/.nornir/workspaces/nordisk/builds", "/somewhere/else");
assert_eq!(l.warehouse_root_resolved().origin, WarehouseOrigin::PinnedRoot);
assert_eq!(
l.warehouse_root_checked().unwrap(),
PathBuf::from("/home/alice/.nornir/workspaces/nordisk/builds")
);
}
}