nornir 0.4.45

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Resolve every repo in a [`WorkspaceDescriptor`] to a concrete local
//! filesystem path that the dep-graph builder and release pipeline can
//! consume uniformly.
//!
//! - `path = "…"` → resolved relative to the descriptor's parent dir. A fat
//!   member may ALSO carry a `git` fallback: when the local `path` checkout is
//!   missing, nornir auto-materializes it by cloning the fallback remote into
//!   that path (honoring the member's `branch`), then proceeds as if it had
//!   been there all along. A path-only member with no fallback still errors
//!   when its checkout is absent (the original behavior).
//! - `git  = "…"` → mapped to a deterministic cache path under
//!   `$NORNIR_CACHE_DIR` (default `~/.cache/nornir/workspaces/<ws>/<repo>`).
//!   If the cache path doesn't exist, returns an error containing the
//!   exact `git clone` command the operator should run. Actual cloning
//!   over the network is intentionally **not** done here yet — gix
//!   requires extra features for HTTPS transport and we don't want to
//!   pull reqwest/curl into nornir's tree just for the bootstrap path.
//!   `nornir workspace fetch` will fill this in once that decision lands.
//!
//! Pure Rust, sync.

use std::collections::BTreeMap;
use std::path::PathBuf;

use anyhow::{Context, Result, anyhow};

use crate::workspace::descriptor::{GitRef, RepoSource, WorkspaceDescriptor};

/// What to do with a fat (`path = …`) member when resolving its checkout —
/// the pure decision the materialize hook acts on. Factored out so the logic
/// is unit-testable without touching the filesystem or the network.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathPlan {
    /// The checkout exists on disk (a non-empty dir) → use it as-is.
    Use,
    /// The checkout is missing but a fallback git remote is declared → clone it
    /// into `path` from this remote/branch, then use it.
    Materialize { url: String, branch: Option<String> },
    /// The checkout is missing and there is NO fallback remote → error.
    Missing,
}

/// Decide what to do with a fat member's local checkout, given whether the
/// `path` already holds a non-empty checkout and the optional git fallback.
/// Pure: no IO. `present` should be `true` iff the path exists as a non-empty
/// directory (see [`path_is_present`]).
pub fn plan_path(present: bool, fallback: Option<&GitRef>) -> PathPlan {
    if present {
        PathPlan::Use
    } else if let Some(gr) = fallback {
        PathPlan::Materialize { url: gr.url.clone(), branch: gr.branch.clone() }
    } else {
        PathPlan::Missing
    }
}

/// A checkout is "present" iff `path` is an existing directory with at least
/// one entry (an empty dir left behind by a failed clone is NOT present, so we
/// can re-materialize into it).
fn path_is_present(path: &std::path::Path) -> bool {
    path.is_dir()
        && std::fs::read_dir(path)
            .map(|mut it| it.next().is_some())
            .unwrap_or(false)
}

/// Deterministic cache dir for one (workspace, repo) git source.
///
/// Respects `$NORNIR_CACHE_DIR`; falls back to `$XDG_CACHE_HOME` then
/// `$HOME/.cache`. Final layout: `…/nornir/workspaces/<ws>/<repo>`.
pub fn git_cache_dir(workspace_name: &str, repo_name: &str) -> Result<PathBuf> {
    let base = if let Some(d) = std::env::var_os("NORNIR_CACHE_DIR") {
        PathBuf::from(d)
    } else if let Some(d) = std::env::var_os("XDG_CACHE_HOME") {
        PathBuf::from(d).join("nornir")
    } else if let Some(home) = std::env::var_os("HOME") {
        PathBuf::from(home).join(".cache").join("nornir")
    } else {
        return Err(anyhow!("no $HOME and no $NORNIR_CACHE_DIR — can't pick cache dir"));
    };
    Ok(base.join("workspaces").join(workspace_name).join(repo_name))
}

/// Resolve every repo to a local path. For `git:` sources, errors out
/// (with a clone hint) if the cache path is missing.
pub fn resolve_sources(desc: &WorkspaceDescriptor) -> Result<BTreeMap<String, PathBuf>> {
    let mut out = BTreeMap::new();
    for (name, src) in desc.sources()? {
        let path = match src {
            RepoSource::Path { resolved, fallback } => {
                match plan_path(path_is_present(&resolved), fallback.as_ref()) {
                    PathPlan::Use => resolved,
                    PathPlan::Materialize { url, branch } => {
                        // Auto-materialize: the fat member's local checkout is
                        // absent, so clone it from the declared fallback remote
                        // into `resolved` and proceed as if it had been there.
                        // Reuses the pure-Rust `gitio::clone_or_fetch` primitive
                        // (HTTPS via gix, SSH via russh) — no shell-out.
                        eprintln!(
                            "nornir-workspace: materializing fat member `{name}` \
                             from {url} into {}",
                            resolved.display()
                        );
                        materialize_member(&name, &url, branch.as_deref(), &resolved)?;
                        resolved
                    }
                    PathPlan::Missing => {
                        return Err(anyhow!(
                            "fat member `{name}` has no local checkout at {} and no \
                             `git` fallback remote in the descriptor — add a `git = …` \
                             to auto-materialize it, or check it out manually.",
                            resolved.display()
                        ));
                    }
                }
            }
            RepoSource::Git(GitRef { url, branch }) => {
                let cache = git_cache_dir(&desc.workspace.name, &name)?;
                if !cache.join(".git").exists() {
                    let br = branch
                        .as_deref()
                        .map(|b| format!(" --branch {b}"))
                        .unwrap_or_default();
                    return Err(anyhow!(
                        "repo `{name}` is git-sourced and not yet in cache.\n\
                         Bootstrap once with:\n\
                            mkdir -p {parent}\n\
                            git clone{br} {url} {cache}\n\
                         (network clone via gix is not yet wired; \
                         once it is, `nornir workspace fetch {name}` will do this for you.)",
                        parent = cache.parent().unwrap().display(),
                        cache = cache.display(),
                    ));
                }
                cache
            }
        };
        out.insert(name, path);
    }
    Ok(out)
}

/// Clone a fat member's fallback remote into its `path` location (the
/// auto-materialize action). Delegates the transport to the pure-Rust
/// [`crate::gitio::clone_or_fetch`] (HTTPS via gix, SSH via russh) — no
/// shell-out. After the clone, if a specific `branch` is requested, best-effort
/// check it out so the materialized tree matches the descriptor's pinned ref;
/// a checkout failure is non-fatal (we keep the default branch and warn) so a
/// stale/renamed branch never blocks the member entirely.
fn materialize_member(
    name: &str,
    url: &str,
    branch: Option<&str>,
    dest: &std::path::Path,
) -> Result<()> {
    crate::gitio::clone_or_fetch(url, dest, None)
        .with_context(|| format!("clone fat member `{name}` from {url} into {}", dest.display()))?;
    if let Some(branch) = branch {
        if let Err(e) = checkout_branch(dest, branch) {
            eprintln!(
                "nornir-workspace: member `{name}` materialized, but checking out \
                 branch `{branch}` failed ({e:#}); staying on the default branch"
            );
        }
    }
    Ok(())
}

/// Best-effort: point `HEAD` at `branch` in the freshly cloned repo at `dest`
/// and materialize its worktree, using only public gix APIs (no shell-out).
/// Resolves the branch from the local clone (`refs/heads/<b>`) or the fetched
/// remote-tracking ref (`refs/remotes/origin/<b>`).
fn checkout_branch(dest: &std::path::Path, branch: &str) -> Result<()> {
    let repo = gix::open(dest).with_context(|| format!("gix::open {}", dest.display()))?;
    // Try the local branch first, then the remote-tracking ref.
    let local = format!("refs/heads/{branch}");
    let remote = format!("refs/remotes/origin/{branch}");
    let target = repo
        .try_find_reference(local.as_str())
        .ok()
        .flatten()
        .or_else(|| repo.try_find_reference(remote.as_str()).ok().flatten())
        .ok_or_else(|| anyhow!("branch `{branch}` not found in {}", dest.display()))?;
    let id = target
        .into_fully_peeled_id()
        .with_context(|| format!("peel branch `{branch}`"))?
        .to_string();
    crate::gitio::set_head_and_checkout(dest, branch, &id)
}

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

    #[test]
    fn cache_dir_respects_explicit_env() {
        // SAFETY: single-threaded test; we restore env at end.
        let prev = std::env::var_os("NORNIR_CACHE_DIR");
        unsafe { std::env::set_var("NORNIR_CACHE_DIR", "/tmp/nornir-cache-test"); }
        let d = git_cache_dir("ws1", "repo1").unwrap();
        assert_eq!(d, PathBuf::from("/tmp/nornir-cache-test/workspaces/ws1/repo1"));
        unsafe {
            match prev {
                Some(v) => std::env::set_var("NORNIR_CACHE_DIR", v),
                None => std::env::remove_var("NORNIR_CACHE_DIR"),
            }
        }
    }

    fn git(url: &str, branch: Option<&str>) -> GitRef {
        GitRef { url: url.into(), branch: branch.map(str::to_string) }
    }

    /// A present checkout is used as-is regardless of any fallback (no clone).
    #[test]
    fn plan_present_uses_path() {
        assert_eq!(plan_path(true, None), PathPlan::Use);
        assert_eq!(plan_path(true, Some(&git("git@h:o/r.git", Some("main")))), PathPlan::Use);
    }

    /// A MISSING checkout WITH a fallback → materialize from that remote/branch.
    #[test]
    fn plan_missing_with_fallback_materializes_with_branch() {
        let plan = plan_path(false, Some(&git("git@codeberg.org:nordisk/korp.git", Some("dev"))));
        assert_eq!(
            plan,
            PathPlan::Materialize {
                url: "git@codeberg.org:nordisk/korp.git".into(),
                branch: Some("dev".into()),
            },
        );
    }

    /// A MISSING checkout with NO fallback → the original missing/error case.
    #[test]
    fn plan_missing_without_fallback_is_missing() {
        assert_eq!(plan_path(false, None), PathPlan::Missing);
    }

    /// `path_is_present` treats an empty dir as ABSENT (so a partial clone can be
    /// re-materialized) and a non-empty dir as present.
    #[test]
    fn empty_dir_is_not_present() {
        let td = tempfile::tempdir().unwrap();
        let empty = td.path().join("empty");
        std::fs::create_dir_all(&empty).unwrap();
        assert!(!path_is_present(&empty), "empty dir must not count as a checkout");
        std::fs::write(empty.join("file"), b"x").unwrap();
        assert!(path_is_present(&empty), "non-empty dir is a present checkout");
        assert!(!path_is_present(&td.path().join("nope")), "missing path is absent");
    }

    /// End-to-end resolver behavior WITHOUT a network: a path-only fat member
    /// whose checkout is absent and which carries NO `git` fallback must still
    /// error (backward-compatible), and the error names the member + path.
    #[test]
    fn resolve_missing_path_no_fallback_errors() {
        let td = tempfile::tempdir().unwrap();
        let toml = "[workspace]\nname = \"demo\"\n\n[repos.foo]\npath = \"foo-checkout\"\n";
        let toml_path = td.path().join("nornir-workspace.toml");
        std::fs::write(&toml_path, toml).unwrap();
        let desc = WorkspaceDescriptor::load(&toml_path).unwrap();

        let err = resolve_sources(&desc).expect_err("missing path with no fallback must error");
        let msg = format!("{err:#}");
        assert!(msg.contains("foo"), "error names the member: {msg}");
        assert!(msg.contains("no `git` fallback"), "error explains the fix: {msg}");
    }

    /// A present path-only fat member resolves to its checkout with no error and
    /// no network — the steady-state fat case.
    #[test]
    fn resolve_present_path_uses_checkout() {
        let td = tempfile::tempdir().unwrap();
        let checkout = td.path().join("foo-checkout");
        std::fs::create_dir_all(&checkout).unwrap();
        std::fs::write(checkout.join("Cargo.toml"), b"# present\n").unwrap();
        let toml = "[workspace]\nname = \"demo\"\n\n[repos.foo]\npath = \"foo-checkout\"\n";
        let toml_path = td.path().join("nornir-workspace.toml");
        std::fs::write(&toml_path, toml).unwrap();
        let desc = WorkspaceDescriptor::load(&toml_path).unwrap();

        let resolved = resolve_sources(&desc).expect("present checkout resolves");
        // The descriptor_dir is canonicalized on load, so compare canonical paths.
        assert_eq!(
            resolved.get("foo").unwrap().canonicalize().unwrap(),
            checkout.canonicalize().unwrap(),
        );
    }
}