repoverse 0.1.1

Multi-repo workspace tool: keep many git repos in sync and roll changes up across dependency boundaries
//! `.repoverse.yaml` — intent (committed, hand-edited).

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

pub const CONFIG_FILE: &str = ".repoverse.yaml";

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Config {
    pub version: u32,
    #[serde(default)]
    pub defaults: Defaults,
    #[serde(default)]
    pub remotes: BTreeMap<String, Remote>,
    #[serde(default)]
    pub projects: Vec<Project>,
    /// Repos this workspace materializes once and lends to consumers
    /// (explicit, not inferred — predictable under nested worktrees).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub provides: Vec<String>,
    /// Central shared-link table: every place a provided repo is symlinked.
    /// Source of truth; a `.repoverse.yaml` in a sub-repo overrides its
    /// subtree (nearest-config-wins).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub links: Vec<Link>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Link {
    /// `owner/repo` of the provided/shared repo.
    pub repo: String,
    /// Workspace-relative path where it is symlinked.
    pub at: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub branch: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Defaults {
    #[serde(default = "default_remote")]
    pub remote: String,
    #[serde(default = "default_revision")]
    pub revision: String,
    #[serde(default)]
    pub scheme: Scheme,
}

impl Default for Defaults {
    fn default() -> Self {
        Defaults {
            remote: default_remote(),
            revision: default_revision(),
            scheme: Scheme::default(),
        }
    }
}

fn default_remote() -> String {
    "github".to_string()
}
fn default_revision() -> String {
    "main".to_string()
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum Scheme {
    #[default]
    Ssh,
    Https,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Remote {
    /// Scheme-agnostic host, e.g. `github.com`.
    pub host: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum Submodules {
    #[default]
    None,
    Shallow,
    Recursive,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Project {
    /// `owner/repo`
    pub name: String,
    /// Workspace-relative path; `.` is the root/parent repo.
    pub path: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub revision: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub remote: Option<String>,
    #[serde(default, skip_serializing_if = "is_default_submodules")]
    pub submodules: Submodules,
    /// Repos whose merged commits this project consumes (rollup graph).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub consumes: Vec<String>,
    /// CI mapping: which workflow/jobs map to local tasks.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub ci: Option<CiMap>,
    /// Explicit task overrides (escape hatch).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub setup: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lint: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub test: Option<String>,
    /// Transitive deps lifted to one shared copy (fan-in >= 2).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub shared: Vec<SharedDep>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SharedDep {
    /// `owner/repo` — matched to a provider by normalized URL.
    pub name: String,
    /// Local path the consumer's build expects it at (the submodule path).
    pub path: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub revision: Option<String>,
}

fn is_default_submodules(s: &Submodules) -> bool {
    *s == Submodules::None
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CiMap {
    pub workflow: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub setup_job: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lint_job: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub test_job: Option<String>,
}

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

    /// Find `.repoverse.yaml` from `start` upward.
    pub fn discover(start: &Path) -> Option<PathBuf> {
        let mut dir = Some(start);
        while let Some(d) = dir {
            let c = d.join(CONFIG_FILE);
            if c.is_file() {
                return Some(c);
            }
            dir = d.parent();
        }
        None
    }

    pub fn validate(&self) -> Result<()> {
        if self.version != 1 {
            bail!("unsupported config version {} (expected 1)", self.version);
        }
        if !self.remotes.contains_key(&self.defaults.remote) {
            bail!(
                "defaults.remote `{}` is not defined in remotes",
                self.defaults.remote
            );
        }
        let mut seen_paths = std::collections::HashSet::new();
        for p in &self.projects {
            if p.name.is_empty() {
                bail!("project with empty name");
            }
            if !seen_paths.insert(&p.path) {
                bail!("duplicate project path `{}`", p.path);
            }
            if let Some(r) = &p.remote {
                if !self.remotes.contains_key(r) {
                    bail!("project `{}` references unknown remote `{}`", p.name, r);
                }
            }
        }
        for p in &self.projects {
            for c in &p.consumes {
                if !self.projects.iter().any(|x| &x.path == c || &x.name == c) {
                    bail!(
                        "project `{}` consumes `{}` which is not a known project",
                        p.name,
                        c
                    );
                }
            }
        }
        Ok(())
    }

    pub fn project_revision<'a>(&'a self, p: &'a Project) -> &'a str {
        p.revision.as_deref().unwrap_or(&self.defaults.revision)
    }

    pub fn project_remote<'a>(&'a self, p: &'a Project) -> Option<&'a Remote> {
        let key = p.remote.as_deref().unwrap_or(&self.defaults.remote);
        self.remotes.get(key)
    }
}

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

    fn sample() -> &'static str {
        r#"
version: 1
defaults:
  remote: github
  revision: main
  scheme: ssh
remotes:
  github:
    host: github.com
projects:
  - name: acme/lib
    path: lib
  - name: acme/app
    path: .
    consumes: [lib]
"#
    }

    #[test]
    fn parses_and_validates() {
        let cfg: Config = serde_yaml::from_str(sample()).unwrap();
        cfg.validate().unwrap();
        assert_eq!(cfg.projects.len(), 2);
        assert_eq!(cfg.project_revision(&cfg.projects[0]), "main");
    }

    #[test]
    fn rejects_bad_version() {
        let mut cfg: Config = serde_yaml::from_str(sample()).unwrap();
        cfg.version = 2;
        assert!(cfg.validate().is_err());
    }

    #[test]
    fn rejects_unknown_consumes() {
        let cfg: Config = serde_yaml::from_str(
            r#"
version: 1
remotes:
  github: { host: github.com }
projects:
  - name: acme/app
    path: .
    consumes: [ghost]
"#,
        )
        .unwrap();
        assert!(cfg.validate().is_err());
    }

    #[test]
    fn rejects_duplicate_paths() {
        let cfg: Config = serde_yaml::from_str(
            r#"
version: 1
remotes: { github: { host: github.com } }
projects:
  - name: acme/a
    path: x
  - name: acme/b
    path: x
"#,
        )
        .unwrap();
        assert!(cfg.validate().is_err());
    }
}