repoverse 0.1.3

Multi-repo workspace tool: keep many git repos in sync and roll changes up across dependency boundaries
//! `.repoverse.lock` — exact state (committed, tool-written).

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;

pub const LOCK_FILE: &str = ".repoverse.lock";

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Lock {
    pub version: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub generated: Option<String>,
    #[serde(default)]
    pub projects: Vec<LockedProject>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedProject {
    pub path: String,
    pub name: String,
    /// The intent the SHA was locked from (branch/tag).
    pub revision: String,
    pub sha: String,
}

impl Lock {
    pub fn new() -> Lock {
        Lock {
            version: 1,
            generated: None,
            projects: Vec::new(),
        }
    }

    pub fn load(path: &Path) -> Result<Lock> {
        let text =
            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
        let lock: Lock =
            serde_yaml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
        Ok(lock)
    }

    pub fn load_or_default(path: &Path) -> Result<Lock> {
        if path.is_file() {
            Lock::load(path)
        } else {
            Ok(Lock::new())
        }
    }

    pub fn save(&self, path: &Path) -> Result<()> {
        let text = serde_yaml::to_string(self).context("serializing lock")?;
        std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
        Ok(())
    }

    #[allow(dead_code)]
    pub fn by_path(&self) -> BTreeMap<&str, &LockedProject> {
        self.projects.iter().map(|p| (p.path.as_str(), p)).collect()
    }

    pub fn sha_for(&self, path: &str) -> Option<&str> {
        self.projects
            .iter()
            .find(|p| p.path == path)
            .map(|p| p.sha.as_str())
    }
}

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

    #[test]
    fn roundtrip() {
        let dir = tempdir().unwrap();
        let path = dir.path().join(LOCK_FILE);
        let mut lock = Lock::new();
        lock.projects.push(LockedProject {
            path: "lib".into(),
            name: "acme/lib".into(),
            revision: "main".into(),
            sha: "abc123".into(),
        });
        lock.save(&path).unwrap();
        let loaded = Lock::load(&path).unwrap();
        assert_eq!(lock, loaded);
        assert_eq!(loaded.sha_for("lib"), Some("abc123"));
    }

    #[test]
    fn missing_file_defaults() {
        let dir = tempdir().unwrap();
        let lock = Lock::load_or_default(&dir.path().join(LOCK_FILE)).unwrap();
        assert!(lock.projects.is_empty());
    }
}