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