crates/nornir-workspace/src/lib.rs
//! Multi-repo workspace descriptor.
//!
//! A *workspace* in nornir's sense is a collection of independent
//! repositories (each typically a cargo workspace of its own) that the
//! release pipeline treats as one unit. The descriptor is a single
//! `nornir-workspace.toml` listing every repo by name and giving its
//! source — either a local path or a git URL.
//!
//! The dependency-graph computation built from this descriptor lives in
//! nornir's warehouse (`dep_graph`) — graphs are historical artefacts Urðr
//! remembers, so the analysis + persistence are co-located there. This leaf
//! only parses the toml and resolves each member to an on-disk checkout; it
//! deliberately does NOT touch the warehouse, keeping it a cycle-free leaf.
pub mod descriptor;
pub mod resolve;
pub use descriptor::{GitRef, RepoSource, RepoSpec, WorkspaceDescriptor, WorkspaceMeta};
pub use resolve::{git_cache_dir, resolve_sources};
use std::path::{Path, PathBuf};
/// The descriptor file names a directory may hold. There is exactly ONE, and a
/// `nornir-workspace.fat.toml` beside it is **ignored**.
///
/// It used to hold two, fat first, so that a box-local "populate from the working
/// trees" descriptor (path= members, no SSH) won when a directory held both. That
/// ordering was removed 2026-08-13 on the owner's order, because a shadowing name is
/// not a preference — it is a silent replacement. A stale `.fat.toml` does not
/// conflict with the canonical descriptor and does not error; it simply becomes the
/// roster, so the same question returns different answers depending on whether the
/// reader stood in that directory. The suite has already paid for this once: a
/// member present in the fat roster and absent from the monitored one meant Mímir
/// saw a repo locally that the server had never indexed.
///
/// A local path=-member roster is still perfectly legal — `path = "…"` members are
/// resolved by [`resolve`] regardless of the file's name. What is gone is the
/// ability to obtain one by FILENAME alone: point at it with an explicit
/// `--descriptor <file>`, which bypasses this list, and the choice is then visible
/// in the command that made it.
pub const DESCRIPTOR_NAMES: &[&str] = &["nornir-workspace.toml"];
/// The descriptor file inside `dir`, per [`DESCRIPTOR_NAMES`] — which names one
/// file. `None` when `dir` does not hold it.
pub fn descriptor_in_dir(dir: &Path) -> Option<PathBuf> {
DESCRIPTOR_NAMES.iter().map(|n| dir.join(n)).find(|p| p.is_file())
}
/// Resolve a user-supplied descriptor path — which may be the descriptor FILE
/// itself OR the DIRECTORY that holds it — to the descriptor file. A file is
/// returned as-is — including a `.fat.toml` named explicitly, which is the only
/// way one is read now; a directory is resolved via [`descriptor_in_dir`];
/// a non-existent path yields `None`. This is what lets `--descriptor <dir>` and
/// `workspace add <dir>` work instead of silently reading a directory as a file.
pub fn resolve_descriptor_path(path: &Path) -> Option<PathBuf> {
if path.is_dir() {
descriptor_in_dir(path)
} else if path.is_file() {
Some(path.to_path_buf())
} else {
None
}
}
/// Walk up from `start` looking for a workspace descriptor, returning the first
/// one found (per [`DESCRIPTOR_NAMES`]). This is the CLIENT-side
/// convenience that lets a user stand in a workspace dir (or any sub-directory of
/// it) and run `nornir workspace add <name>` without a `--descriptor` flag: we
/// check the current directory for a descriptor, then walk up a directory,
/// repeating to the filesystem root. Returns `None` when no descriptor is found
/// anywhere on the way up.
pub fn discover_descriptor(start: &Path) -> Option<PathBuf> {
let mut cur = start
.canonicalize()
.unwrap_or_else(|_| start.to_path_buf());
loop {
if let Some(found) = descriptor_in_dir(&cur) {
return Some(found);
}
if !cur.pop() {
return None;
}
}
}
#[cfg(test)]
mod discover_tests {
use super::*;
#[test]
fn discovers_descriptor_walking_up_from_subdir() {
let tmp = std::env::temp_dir().join(format!(
"nornir-discover-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let ws = tmp.join("workspace_demo");
let deep = ws.join("sub/a/b");
std::fs::create_dir_all(&deep).unwrap();
let desc = ws.join("nornir-workspace.toml");
std::fs::write(&desc, "[workspace]\nname = \"demo\"\n").unwrap();
// From the workspace dir itself.
let found = discover_descriptor(&ws).expect("found at ws root");
assert_eq!(found.canonicalize().unwrap(), desc.canonicalize().unwrap());
// From a deep sub-directory — walks up to the same descriptor.
let found_deep = discover_descriptor(&deep).expect("found walking up");
assert_eq!(
found_deep.canonicalize().unwrap(),
desc.canonicalize().unwrap()
);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **Red-when-reverted: a `.fat.toml` beside the canonical descriptor is
/// IGNORED.** Put `"nornir-workspace.fat.toml"` back into [`DESCRIPTOR_NAMES`]
/// — at either end of the list — and the first three asserts fail, because a
/// directory holding both would resolve to the fat file again. This is the
/// guard on the 2026-08-13 removal: a shadowing filename is a silent
/// replacement of the roster, not a preference.
#[test]
fn a_fat_descriptor_beside_the_canonical_one_is_ignored() {
let tmp = std::env::temp_dir().join(format!(
"nornir-fat-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let ws = tmp.join("workspace_nordisk");
std::fs::create_dir_all(&ws).unwrap();
let plain = ws.join("nornir-workspace.toml");
let fat = ws.join("nornir-workspace.fat.toml");
std::fs::write(&plain, "[workspace]\nname = \"nordisk\"\n").unwrap();
std::fs::write(&fat, "[workspace]\nname = \"nordisk\"\n[repos.nornir]\npath = \"/x\"\n").unwrap();
// The fat file is on disk and must be invisible to every by-name resolver.
assert_eq!(descriptor_in_dir(&ws).unwrap(), plain, "a .fat.toml beside the canonical one is ignored");
assert_eq!(resolve_descriptor_path(&ws).unwrap(), plain, "a DIR resolves to the canonical descriptor");
assert_eq!(
discover_descriptor(&ws).unwrap().canonicalize().unwrap(),
plain.canonicalize().unwrap(),
"walking up lands on the canonical descriptor, never the fat one"
);
// A descriptor FILE passed directly is returned verbatim — an explicit
// --descriptor is still the way to name a path=-member roster on purpose.
assert_eq!(resolve_descriptor_path(&plain).unwrap(), plain, "an explicit file is returned as-is");
assert_eq!(resolve_descriptor_path(&fat).unwrap(), fat, "an explicit fat file is still honored by path");
// Removing the fat file changes nothing — it was never being read.
std::fs::remove_file(&fat).unwrap();
assert_eq!(descriptor_in_dir(&ws).unwrap(), plain, "the canonical descriptor is the only name");
// A non-existent path yields None (not a silent directory-as-file read).
assert!(resolve_descriptor_path(&tmp.join("nope")).is_none());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn returns_none_when_no_descriptor() {
let tmp = std::env::temp_dir().join(format!(
"nornir-discover-none-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&tmp).unwrap();
// A temp dir tree with no descriptor on the way up to it — but / may have
// none either, so just assert the immediate dir yields None up to where a
// real descriptor could exist. We assert the descriptor in THIS subtree
// is absent (parents are system dirs without nornir-workspace.toml).
let sub = tmp.join("empty/here");
std::fs::create_dir_all(&sub).unwrap();
// There is no descriptor in tmp/empty/here or tmp/empty or tmp; whether a
// parent system dir has one is environment-dependent, so we only assert
// that the descriptor we did NOT create is not spuriously found within.
assert!(!sub.join("nornir-workspace.toml").exists());
let _ = std::fs::remove_dir_all(&tmp);
}
}