nornir 0.4.43

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! `nornir-workspace.toml` parsing.
//!
//! ```toml
//! [workspace]
//! name = "holger-suite"
//!
//! [repos.holger]
//! path = "../holger"
//!
//! [repos.znippy]
//! git = "https://codeberg.org/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 = "git@codeberg.org: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`. See `crate::deepscan`.
    #[serde(default)]
    pub deep_scan: bool,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RepoSpec {
    #[serde(default)]
    pub path: Option<String>,
    #[serde(default)]
    pub git: Option<String>,
    #[serde(default)]
    pub branch: Option<String>,
}

#[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 {
    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)
    }

    /// 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()), git: None, branch: None };
        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 {
            path: None,
            git: Some("git@codeberg.org:nordisk/foo.git".into()),
            branch: Some("main".into()),
        };
        match spec.source(Path::new("/ws")).unwrap() {
            RepoSource::Git(gr) => {
                assert_eq!(gr.url, "git@codeberg.org: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("git@codeberg.org:nordisk/korp.git".into()),
            branch: Some("dev".into()),
        };
        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, "git@codeberg.org: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 = "git@codeberg.org: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("git@codeberg.org: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 { path: None, git: None, branch: None };
        assert!(spec.source(Path::new("/ws")).is_err());
    }
}