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