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, 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>,
#[serde(default)]
pub projects: Vec<Project>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub provides: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<Link>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Link {
pub repo: String,
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, skip_serializing_if = "Option::is_none")]
pub main_branch: Option<String>,
#[serde(default = "default_revision")]
pub revision: String,
#[serde(default)]
pub scheme: Scheme,
}
impl Default for Defaults {
fn default() -> Self {
Defaults {
remote: default_remote(),
main_branch: None,
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 {
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 {
pub name: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub main_branch: Option<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 = "Option::is_none")]
pub upstream: Option<Upstream>,
#[serde(default, skip_serializing_if = "is_default_submodules")]
pub submodules: Submodules,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub consumes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ci: Option<CiMap>,
#[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>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub shared: Vec<SharedDep>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Upstream {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SharedDep {
pub name: String,
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)
}
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);
}
}
if let Some(upstream) = &p.upstream {
if let Some(r) = upstream.remote.as_deref() {
if !self.remotes.contains_key(r) {
bail!(
"project `{}` references unknown upstream 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_main_branch<'a>(&'a self, p: &'a Project) -> &'a str {
p.main_branch
.as_deref()
.or(self.defaults.main_branch.as_deref())
.unwrap_or_else(|| self.project_revision(p))
}
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");
assert_eq!(cfg.project_main_branch(&cfg.projects[0]), "main");
}
#[test]
fn main_branch_is_separate_from_revision() {
let cfg: Config = serde_yaml::from_str(
r#"
version: 1
defaults:
main_branch: main
revision: develop
remotes:
github: { host: github.com }
projects:
- name: acme/lib
path: lib
- name: acme/app
path: app
main_branch: stable
revision: feature/app
"#,
)
.unwrap();
cfg.validate().unwrap();
assert_eq!(cfg.project_revision(&cfg.projects[0]), "develop");
assert_eq!(cfg.project_main_branch(&cfg.projects[0]), "main");
assert_eq!(cfg.project_revision(&cfg.projects[1]), "feature/app");
assert_eq!(cfg.project_main_branch(&cfg.projects[1]), "stable");
}
#[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());
}
#[test]
fn parses_project_upstream() {
let cfg: Config = serde_yaml::from_str(
r#"
version: 1
remotes:
github: { host: github.com }
upstream: { host: github.com }
projects:
- name: fork/lib
path: lib
upstream:
name: source/lib
remote: upstream
revision: stable
"#,
)
.unwrap();
cfg.validate().unwrap();
let upstream = cfg.projects[0].upstream.as_ref().unwrap();
assert_eq!(upstream.name.as_deref(), Some("source/lib"));
assert_eq!(upstream.remote.as_deref(), Some("upstream"));
assert_eq!(upstream.revision.as_deref(), Some("stable"));
}
#[test]
fn rejects_unknown_upstream_remote() {
let cfg: Config = serde_yaml::from_str(
r#"
version: 1
remotes:
github: { host: github.com }
projects:
- name: fork/lib
path: lib
upstream:
remote: missing
"#,
)
.unwrap();
assert!(cfg.validate().is_err());
}
}