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

nordisk/edda

crates/nornir-workspace/src/descriptor.rs

//! `nornir-workspace.toml` parsing.
//!
//! ```toml
//! [workspace]
//! name = "holger-suite"
//!
//! [repos.holger]
//! path = "../holger"
//!
//! [repos.znippy]
//! git = "https://git.gunnar.rs/nordisk/znippy"
//! # optional:
//! # branch = "main"
//!
//! [repos.korp]
//! # A FAT member may declare BOTH: `path` is where the checkout should live
//! # locally, and `git` is the fallback remote nornir materializes from when
//! # that path is absent on disk (auto-materialize, see `RepoSource`).
//! path = "../korp"
//! git = "ssh://git@git.gunnar.rs:2222/nordisk/korp.git"
//! ```

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorkspaceDescriptor {
    pub workspace: WorkspaceMeta,
    #[serde(default)]
    pub repos: BTreeMap<String, RepoSpec>,
    /// Absolute path of the descriptor file's parent directory. Set by
    /// [`WorkspaceDescriptor::load`]; ignored for round-trip.
    #[serde(skip)]
    pub descriptor_dir: PathBuf,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorkspaceMeta {
    pub name: String,
    /// Opt-in deep-scan: on each republish, knowledge-scan the members' **entire
    /// transitive dependency closure** (cargo fetch + metadata → gatling-fanned
    /// syn scans) into the warehouse under repo `deps`. Consumed by nornir's
    /// deepscan pass; this leaf only carries the flag.
    #[serde(default)]
    pub deep_scan: bool,
}

/// One member of the workspace.
///
/// # Unknown keys are REFUSED, and that is the point
///
/// `deny_unknown_fields` is not tidiness here, it closes a measured hole. Until
/// 2026-08-24 this struct knew three keys, `release = false` sat on five
/// `[repos.*]` entries of the live `workspace_nordisk/nornir-workspace.toml`, and
/// serde discarded every one of them **in silence**. It was not inert either,
/// which is worse than useless: dwarves reads the same key with its OWN raw
/// `toml::Value` walk (`descriptor_members`), so one file had two readers and the
/// typed one knew about half the vocabulary.
///
/// The shape that hole belongs to is the constellation's most expensive: a thing
/// that looks like configuration, reads like configuration, and is thrown away
/// without a word. The next agent told to "add IMPORTANT static info to the
/// toml" who reaches for `notes = "..."` would have met the same silence. Now the
/// parse says the key's name and stops.
///
/// Adding a key here is therefore a deliberate act — which is what makes the
/// refusal safe to rely on.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RepoSpec {
    #[serde(default)]
    pub path: Option<String>,
    #[serde(default)]
    pub git: Option<String>,
    #[serde(default)]
    pub branch: Option<String>,
    /// Opt OUT of the crates.io release cascade. `release = false` holds the
    /// member entirely — not verified, not prechecked, not published.
    ///
    /// Absent ⇒ released. Carried as `Option<bool>` rather than `bool` so the
    /// round-trip through [`Serialize`] does not INVENT a `release = true` line
    /// on every member of a file that never had one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub release: Option<bool>,
    /// The older spelling of [`RepoSpec::release`], honoured because the live
    /// descriptors and dwarves' reader both accept it. Either being `false`
    /// holds the member — see [`RepoSpec::releasable`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub publish: Option<bool>,
}

#[derive(Debug, Clone)]
pub enum RepoSource {
    /// A local (fat/embedded) checkout. `resolved` is the absolute on-disk
    /// path where the checkout should live. `fallback` carries an optional
    /// git remote (+ ref) to **auto-materialize** the checkout from when
    /// `resolved` is missing — set only when the member declares both `path`
    /// and `git`. `None` preserves the original path-only behavior (missing
    /// path → hard error, no network).
    Path {
        resolved: PathBuf,
        fallback: Option<GitRef>,
    },
    Git(GitRef),
}

/// A git remote reference: a clone/fetch URL plus an optional branch/ref.
#[derive(Debug, Clone)]
pub struct GitRef {
    pub url: String,
    pub branch: Option<String>,
}

impl RepoSpec {
    /// Whether this member takes part in the crates.io release cascade.
    ///
    /// EITHER spelling being `false` holds it, and absence means yes — the same
    /// rule dwarves' `descriptor_members` applies (`release && publish`, both
    /// defaulting true). It lives here so the two readers of this file cannot
    /// drift on what "held" means; a second interpretation of a release opt-out
    /// is the kind of disagreement that publishes something on purpose held.
    pub fn releasable(&self) -> bool {
        self.release.unwrap_or(true) && self.publish.unwrap_or(true)
    }

    pub fn source(&self, descriptor_dir: &Path) -> Result<RepoSource> {
        match (&self.path, &self.git) {
            // `path` (with or without a `git` fallback) → a local checkout.
            (Some(p), git) => {
                let raw = PathBuf::from(p);
                let resolved = if raw.is_absolute() { raw } else { descriptor_dir.join(raw) };
                let fallback = git.as_ref().map(|url| GitRef {
                    url: url.clone(),
                    branch: self.branch.clone(),
                });
                Ok(RepoSource::Path { resolved, fallback })
            }
            (None, Some(url)) => Ok(RepoSource::Git(GitRef {
                url: url.clone(),
                branch: self.branch.clone(),
            })),
            (None, None) => Err(anyhow!("repo spec has neither `path` nor `git`")),
        }
    }
}

impl WorkspaceDescriptor {
    pub fn load(path: &Path) -> Result<Self> {
        let text = std::fs::read_to_string(path)
            .with_context(|| format!("read workspace descriptor {}", path.display()))?;
        let mut d: WorkspaceDescriptor = toml::from_str(&text)
            .with_context(|| format!("parse workspace descriptor {}", path.display()))?;
        d.descriptor_dir = path
            .parent()
            .ok_or_else(|| anyhow!("descriptor path has no parent"))?
            .canonicalize()
            .with_context(|| format!("canonicalize descriptor parent of {}", path.display()))?;
        Ok(d)
    }

    /// Parse a descriptor from raw TOML **content** (no file). `descriptor_dir` is
    /// left empty — fine for git-URL members (monitored workspaces); path-based
    /// members can't be resolved without a base dir. This is the seam that lets the
    /// registry store the descriptor IN redb and re-read `deep_scan`/members from
    /// the row instead of a sibling file.
    pub fn from_content(content: &str) -> Result<Self> {
        toml::from_str(content).context("parse workspace descriptor content")
    }

    /// Resolve every repo's source. Returns `(name, source)` pairs in
    /// deterministic (BTreeMap) order.
    pub fn sources(&self) -> Result<Vec<(String, RepoSource)>> {
        self.repos
            .iter()
            .map(|(name, spec)| spec.source(&self.descriptor_dir).map(|s| (name.clone(), s)))
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A path-only member still resolves to a local checkout with NO fallback —
    /// backward-compatible behavior (missing path → hard error downstream).
    #[test]
    fn path_only_member_has_no_fallback() {
        let spec = RepoSpec { path: Some("../foo".into()), ..Default::default() };
        match spec.source(Path::new("/ws")).unwrap() {
            RepoSource::Path { resolved, fallback } => {
                assert_eq!(resolved, PathBuf::from("/ws/../foo"));
                assert!(fallback.is_none(), "path-only member must carry no git fallback");
            }
            other => panic!("expected Path, got {other:?}"),
        }
    }

    /// A git-only member resolves to a `Git` source carrying url + branch.
    #[test]
    fn git_only_member_resolves_to_git() {
        let spec = RepoSpec {
            git: Some("ssh://git@git.gunnar.rs:2222/nordisk/foo.git".into()),
            branch: Some("main".into()),
            ..Default::default()
        };
        match spec.source(Path::new("/ws")).unwrap() {
            RepoSource::Git(gr) => {
                assert_eq!(gr.url, "ssh://git@git.gunnar.rs:2222/nordisk/foo.git");
                assert_eq!(gr.branch.as_deref(), Some("main"));
            }
            other => panic!("expected Git, got {other:?}"),
        }
    }

    /// A FAT member declaring BOTH `path` and `git` parses to a `Path` source
    /// whose `fallback` carries the remote (+ branch) to auto-materialize from —
    /// the new combined shape, no longer a "choose one" error.
    #[test]
    fn member_with_both_path_and_git_carries_fallback() {
        let spec = RepoSpec {
            path: Some("/abs/korp".into()),
            git: Some("ssh://git@git.gunnar.rs:2222/nordisk/korp.git".into()),
            branch: Some("dev".into()),
            ..Default::default()
        };
        match spec.source(Path::new("/ws")).unwrap() {
            RepoSource::Path { resolved, fallback } => {
                assert_eq!(resolved, PathBuf::from("/abs/korp"), "absolute path honored verbatim");
                let gr = fallback.expect("combined member must carry a git fallback");
                assert_eq!(gr.url, "ssh://git@git.gunnar.rs:2222/nordisk/korp.git");
                assert_eq!(gr.branch.as_deref(), Some("dev"));
            }
            other => panic!("expected Path+fallback, got {other:?}"),
        }
    }

    /// Round-trip the combined shape through real TOML parsing (the descriptor
    /// loader path), proving `[repos.<n>] path = … git = …` co-exist.
    #[test]
    fn toml_parses_combined_path_and_git() {
        let toml = r#"
[workspace]
name = "demo"

[repos.korp]
path = "../korp"
git = "ssh://git@git.gunnar.rs:2222/nordisk/korp.git"
branch = "main"
"#;
        let d: WorkspaceDescriptor = toml::from_str(toml).expect("parse combined descriptor");
        let spec = d.repos.get("korp").expect("korp present");
        assert_eq!(spec.path.as_deref(), Some("../korp"));
        assert_eq!(spec.git.as_deref(), Some("ssh://git@git.gunnar.rs:2222/nordisk/korp.git"));
        assert_eq!(spec.branch.as_deref(), Some("main"));
    }

    /// A member with neither `path` nor `git` is still rejected.
    #[test]
    fn member_with_neither_errors() {
        let spec = RepoSpec::default();
        assert!(spec.source(Path::new("/ws")).is_err());
    }

    /// `release = false` was on FIVE `[repos.*]` entries of the live descriptor
    /// and this parser threw every one away without a word. It must land in the
    /// struct now, and it must mean HELD.
    #[test]
    fn a_release_opt_out_survives_the_parse_and_holds_the_member() {
        let text = r#"
[workspace]
name = "nordisk"

[repos.korp]
git = "ssh://git@git.gunnar.rs:2222/nordisk/korp.git"
release = false

[repos.nornir]
git = "ssh://git@git.gunnar.rs:2222/nordisk/nornir.git"
"#;
        let d = WorkspaceDescriptor::from_content(text).expect("parses");
        let korp = d.repos.get("korp").expect("korp is a member");
        assert_eq!(korp.release, Some(false), "the key must reach the struct, not the floor");
        assert!(!korp.releasable(), "release = false HOLDS the member");
        let nornir = d.repos.get("nornir").expect("nornir is a member");
        assert_eq!(nornir.release, None);
        assert!(nornir.releasable(), "absent means released — the default must not flip");
    }

    /// The older spelling holds too, and EITHER being false is enough — the same
    /// rule dwarves' own reader applies.
    #[test]
    fn either_spelling_of_the_opt_out_holds_the_member() {
        let text = r#"
[workspace]
name = "w"

[repos.a]
git = "g"
publish = false

[repos.b]
git = "g"
release = true
publish = true
"#;
        let d = WorkspaceDescriptor::from_content(text).expect("parses");
        assert!(!d.repos["a"].releasable(), "publish = false must hold it too");
        assert!(d.repos["b"].releasable(), "both true is released");
    }

    /// THE POINT OF `deny_unknown_fields`: a key that looks like configuration and
    /// is not one must be REFUSED BY NAME, not discarded. Without this the next
    /// agent's `notes = "..."` disappears exactly as `release` did.
    #[test]
    fn a_key_this_schema_does_not_know_is_refused_by_name() {
        let text = r#"
[workspace]
name = "w"

[repos.a]
git = "g"
notes = "IMPORTANT: this repo is special"
"#;
        let err = WorkspaceDescriptor::from_content(text)
            .expect_err("an unknown key must not parse silently");
        let msg = format!("{err:#}");
        assert!(
            msg.contains("notes"),
            "the refusal must NAME the key so the writer can see it: {msg}"
        );
    }

    /// The non-vacuity floor for the test above: the refusal must be about the
    /// unknown key and nothing else, i.e. the very same document without it
    /// parses. Otherwise "it errored" could be satisfied by a parser that refuses
    /// everything.
    #[test]
    fn the_same_document_without_the_unknown_key_parses() {
        let text = r#"
[workspace]
name = "w"

[repos.a]
git = "g"
"#;
        let d = WorkspaceDescriptor::from_content(text).expect("parses");
        assert_eq!(d.repos.len(), 1);
    }

    /// Serializing a member that never declared an opt-out must not INVENT one.
    /// A round-trip that adds `release = true` to twenty entries would turn a
    /// harmless read into a twenty-line diff nobody asked for.
    #[test]
    fn a_round_trip_does_not_invent_a_release_line() {
        let spec = RepoSpec { git: Some("g".into()), ..Default::default() };
        let out = toml::to_string(&spec).expect("serializes");
        assert!(!out.contains("release"), "{out}");
        assert!(!out.contains("publish"), "{out}");
    }
}