src/config_spelling.rs
//! **One letter apart, and the loader used to be silent about it.**
//!
//! nornir has TWO toml files with near-identical shapes and different loaders:
//!
//! | file | top-level tables | loader |
//! |---|---|---|
//! | `nornir.toml` (the CONFIG) | `[repo.<name>]` `[guard]` `[storage]` `[build]` `[registry]` | `nornir_config::Nornir` |
//! | `nornir-workspace.toml` (the DESCRIPTOR) | `[workspace]` `[repos.<name>]` | `nornir_workspace::WorkspaceDescriptor` |
//!
//! `nornir_config::Nornir` is a plain serde struct without `deny_unknown_fields`,
//! so a file that says `[repos.gunnar]` parses FINE — as a config with zero
//! repos. That is how a fully populated Mímir warehouse answered `0 rows` and a
//! `knowledge scan` of an unregistered repo answered `symbols=0` on 2026-09-10
//! (`INVENTORY-semantic-twins-2026-09-10.md` §5 #6). The same hole is open the
//! other way: hand the descriptor loader a `[repo.x]` file and serde's error is
//! `missing field workspace`, which names neither spelling.
//!
//! This module is the REFUSAL, in nornir, at the seam where nornir calls those
//! loaders. The loaders themselves live in edda (`nornir-config`,
//! `nornir-workspace`) and are another lane's to harden; until they carry this
//! check natively, every load site in nornir runs [`check_config_file`] /
//! [`check_descriptor_file`] on the path it just loaded. Both are pure over the
//! toml text (one `toml::Table` parse of the same bytes the loader read), so the
//! refusal cannot drift from what was loaded.
use std::path::Path;
use anyhow::{Context, Result, bail};
/// Top-level tables `nornir.toml` knows — the fields of `nornir_config::Nornir`.
/// Pinned here on purpose: if the struct grows a table, the test
/// `config_tables_match_the_loader_struct` goes red and this list is the one
/// place to extend.
pub const CONFIG_TABLES: &[&str] = &["guard", "storage", "repo", "build", "registry"];
/// Top-level tables `nornir-workspace.toml` knows — the fields of
/// `nornir_workspace::WorkspaceDescriptor`.
pub const DESCRIPTOR_TABLES: &[&str] = &["workspace", "repos"];
/// Which of the two spellings a file was handed to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Spelling {
/// `nornir.toml` — `[repo.<name>]`, read by `nornir_config`.
Config,
/// `nornir-workspace.toml` — `[repos.<name>]`, read by `nornir_workspace`.
Descriptor,
}
impl Spelling {
fn known(self) -> &'static [&'static str] {
match self {
Spelling::Config => CONFIG_TABLES,
Spelling::Descriptor => DESCRIPTOR_TABLES,
}
}
fn file_name(self) -> &'static str {
match self {
Spelling::Config => "nornir.toml",
Spelling::Descriptor => "nornir-workspace.toml",
}
}
fn loader(self) -> &'static str {
match self {
Spelling::Config => "nornir_config::Nornir (the CONFIG loader)",
Spelling::Descriptor => "nornir_workspace::WorkspaceDescriptor (the DESCRIPTOR loader)",
}
}
fn repo_table(self) -> &'static str {
match self {
Spelling::Config => "[repo.<name>]",
Spelling::Descriptor => "[repos.<name>]",
}
}
fn other(self) -> Spelling {
match self {
Spelling::Config => Spelling::Descriptor,
Spelling::Descriptor => Spelling::Config,
}
}
}
/// Refuse `text` (read from `path`) if it is not a `nornir.toml` CONFIG by its
/// top-level keys. `[repos.…]` gets the message that names BOTH spellings;
/// any other unknown top-level key gets the generic refusal with the known list.
pub fn check_config_text(text: &str, path: &Path) -> Result<()> {
check_text(text, path, Spelling::Config)
}
/// [`check_config_text`] over the file at `path`. Call it on
/// `Loaded::config_path` right after `nornir_config::discover` / `load_explicit`.
pub fn check_config_file(path: &Path) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("re-read {} for the spelling check", path.display()))?;
check_config_text(&text, path)
}
/// Refuse `text` (read from `path`) if it is not a `nornir-workspace.toml`
/// DESCRIPTOR by its top-level keys — the mirror of [`check_config_text`]:
/// `[repo.…]` (singular) here is the config spelling.
pub fn check_descriptor_text(text: &str, path: &Path) -> Result<()> {
check_text(text, path, Spelling::Descriptor)
}
/// [`check_descriptor_text`] over the file at `path`.
pub fn check_descriptor_file(path: &Path) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("re-read {} for the spelling check", path.display()))?;
check_descriptor_text(&text, path)
}
fn check_text(text: &str, path: &Path, want: Spelling) -> Result<()> {
let table: toml::Table = text
.parse()
.with_context(|| format!("parse {} as toml for the spelling check", path.display()))?;
let other = want.other();
let mut unknown: Vec<&str> = Vec::new();
for key in table.keys() {
if want.known().contains(&key.as_str()) {
continue;
}
// THE one-letter case: the other file's repo table. Named first and in
// full, because this is the mistake that answered `0 rows`.
let other_repo = other.repo_table().trim_start_matches('[').split('.').next().unwrap();
if key == other_repo {
bail!(
"{path}: `[{key}.<name>]` is the {other_file} spelling ({other_loader}), but this \
file was handed to {want_loader}, which reads `{want_repo}` and would have \
IGNORED every `[{key}.*]` table in silence — an empty repo set that answers \
`0 rows` / `symbols=0` instead of failing. Either point the loader at a real \
{want_file} (the CLI: --config / NORNIR_CONFIG; a `workspace_<name>/` dir \
resolves `release/{want_file}`), or rename the tables `[{key}.X]` -> \
`{want_repo_bare}`. Top-level tables a {want_file} knows: {known}.",
path = path.display(),
other_file = other.file_name(),
other_loader = other.loader(),
want_loader = want.loader(),
want_repo = want.repo_table(),
want_file = want.file_name(),
want_repo_bare = want.repo_table().replace("<name>", "X"),
known = want.known().join(", "),
);
}
unknown.push(key.as_str());
}
if !unknown.is_empty() {
let shown: Vec<String> = unknown.iter().map(|k| format!("`[{k}]`")).collect();
bail!(
"{path}: unknown top-level table(s) {shown} for a {want_file} ({want_loader}). \
Known: {known}. A serde loader without `deny_unknown_fields` would have dropped \
them silently; refusing instead. If this is a {other_file} ({other_tables}), it is \
the wrong file for this loader.",
path = path.display(),
shown = shown.join(", "),
want_file = want.file_name(),
want_loader = want.loader(),
known = want.known().join(", "),
other_file = other.file_name(),
other_tables = other
.known()
.iter()
.map(|t| format!("[{t}]"))
.collect::<Vec<_>>()
.join(" "),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn p(name: &str) -> PathBuf {
PathBuf::from(format!("/ws/workspace_nordisk/{name}"))
}
/// The pin: this list IS the loader struct's field set. If `nornir_config::Nornir`
/// grows a table without this list following, a valid config would be refused —
/// so the round-trip is asserted, not assumed.
#[test]
fn config_tables_match_the_loader_struct() {
let n = crate::config::Nornir::default();
let v = toml::Value::try_from(&n).expect("Nornir serialises");
let mut keys: Vec<&str> = v.as_table().unwrap().keys().map(String::as_str).collect();
keys.sort_unstable();
let mut want: Vec<&str> = CONFIG_TABLES.to_vec();
want.sort_unstable();
assert_eq!(keys, want, "CONFIG_TABLES must equal the fields of nornir_config::Nornir");
}
/// The real `release/nornir.toml` shape from the plan, and the real box-wide
/// shapes (`[guard]` + `[repo]` + `[storage]`): all pass.
#[test]
fn a_real_config_passes() {
let text = r#"
[repo.gunnar]
path = "/home/alice/git/gunnar"
remote = "ssh://git.gunnar.rs:2222/gunnar/gunnar"
[repo.monetize-impl]
path = "/home/alice/git/monetize-impl"
private = true
[repo.edda]
[guard]
forbidden = ["x"]
[storage]
local_path = "builds"
"#;
check_config_text(text, &p("release/nornir.toml")).unwrap();
// Empty is a valid (zero-repo) config; the loader accepts it and so do we.
check_config_text("", &p("nornir.toml")).unwrap();
}
/// DIRECTION 1 — the one-letter mistake that answered `0 rows`: a descriptor
/// handed to the config loader. Refused, naming both spellings AND the file.
#[test]
fn a_descriptor_handed_to_the_config_loader_is_refused_loudly() {
let text = r#"
[workspace]
name = "nordisk"
[repos.gunnar]
path = "/home/alice/git/gunnar"
[repos.monetize]
git = "ssh://git.gunnar.rs:2222/gunnar/monetize"
"#;
let err = check_config_text(text, &p("nornir-workspace.toml"))
.expect_err("must refuse")
.to_string();
assert!(err.contains("/ws/workspace_nordisk/nornir-workspace.toml"), "{err}");
assert!(err.contains("`[repos.<name>]`"), "names the wrong spelling: {err}");
assert!(err.contains("`[repo.<name>]`"), "names the right spelling: {err}");
assert!(err.contains("nornir-workspace.toml spelling"), "{err}");
assert!(err.contains("nornir_config::Nornir"), "{err}");
assert!(err.contains("0 rows"), "says what the silence looked like: {err}");
assert!(err.contains("--config / NORNIR_CONFIG"), "says how to fix it: {err}");
}
/// DIRECTION 2 — the mirror: a config handed to the descriptor loader.
#[test]
fn a_config_handed_to_the_descriptor_loader_is_refused_loudly() {
let text = "[repo.gunnar]\npath = \"/home/alice/git/gunnar\"\n";
let err = check_descriptor_text(text, &p("release/nornir.toml"))
.expect_err("must refuse")
.to_string();
assert!(err.contains("release/nornir.toml"), "{err}");
assert!(err.contains("`[repo.<name>]` is the nornir.toml spelling"), "{err}");
assert!(err.contains("`[repos.<name>]`"), "{err}");
assert!(err.contains("nornir_workspace::WorkspaceDescriptor"), "{err}");
// …and a real descriptor passes the descriptor check.
check_descriptor_text("[workspace]\nname = \"x\"\n[repos.a]\npath = \"a\"\n", &p("nornir-workspace.toml"))
.unwrap();
}
/// Any OTHER unknown top-level table is refused too, with the known list —
/// the same silence, a different typo (`[repository.x]`, `[storaeg]`).
#[test]
fn any_unknown_top_level_table_is_refused_with_the_known_list() {
let err = check_config_text(
"[repo.a]\npath = \"a\"\n[repository.b]\npath = \"b\"\n[storaeg]\nkind = \"local\"\n",
&p("nornir.toml"),
)
.expect_err("must refuse")
.to_string();
assert!(err.contains("`[repository]`") && err.contains("`[storaeg]`"), "{err}");
assert!(err.contains("Known: guard, storage, repo, build, registry"), "{err}");
assert!(err.contains("deny_unknown_fields"), "{err}");
// A bare top-level key is the same hole (Nornir has no scalar fields).
let err = check_config_text("name = \"nordisk\"\n[repo.a]\n", &p("nornir.toml"))
.expect_err("must refuse")
.to_string();
assert!(err.contains("`[name]`"), "{err}");
}
/// The file-path entry points read the same bytes the loader read, and a
/// missing file is its own (named) error, not a pass.
#[test]
fn file_entry_points_read_the_file_and_name_it() {
let dir = tempfile::tempdir().unwrap();
let bad = dir.path().join("nornir.toml");
std::fs::write(&bad, "[repos.x]\npath = \"x\"\n").unwrap();
let err = check_config_file(&bad).expect_err("refused").to_string();
assert!(err.contains(&bad.display().to_string()), "{err}");
let good = dir.path().join("release").join("nornir.toml");
std::fs::create_dir_all(good.parent().unwrap()).unwrap();
std::fs::write(&good, "[repo.x]\npath = \"x\"\n").unwrap();
check_config_file(&good).unwrap();
let err = check_descriptor_file(&good).expect_err("mirror refused").to_string();
assert!(err.contains("`[repo.<name>]` is the nornir.toml spelling"), "{err}");
let missing = dir.path().join("nope.toml");
let err = check_config_file(&missing).expect_err("missing file").to_string();
assert!(err.contains("re-read") && err.contains("nope.toml"), "{err}");
}
}