Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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}");
    }
}