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>,
#[serde(skip)]
pub descriptor_dir: PathBuf,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorkspaceMeta {
pub name: String,
#[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 {
Path {
resolved: PathBuf,
fallback: Option<GitRef>,
},
Git(GitRef),
}
#[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) {
(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)
}
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::*;
#[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:?}"),
}
}
#[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:?}"),
}
}
#[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:?}"),
}
}
#[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"));
}
#[test]
fn member_with_neither_errors() {
let spec = RepoSpec { path: None, git: None, branch: None };
assert!(spec.source(Path::new("/ws")).is_err());
}
}