#![allow(dead_code)]
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
pub const REPO_METADATA_FILE: &str = "repo.toml";
const REPO_REGISTRY_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RepoAliases {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub remote_urls: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub root_commits: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repo_basenames: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct RepoRegistryEntry {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default)]
pub aliases: RepoAliases,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub known_clone_roots: Vec<String>,
}
impl RepoRegistryEntry {
pub fn new(display_name: Option<String>, aliases: RepoAliases) -> Result<Self> {
Self::new_with_known_clone_roots(display_name, aliases, Vec::new())
}
pub fn new_with_known_clone_roots(
display_name: Option<String>,
aliases: RepoAliases,
known_clone_roots: Vec<String>,
) -> Result<Self> {
let entry = Self {
version: REPO_REGISTRY_SCHEMA_VERSION,
display_name,
aliases,
known_clone_roots,
};
entry.validate()?;
Ok(entry)
}
pub fn from_toml(contents: &str) -> Result<Self> {
let entry: Self = toml::from_str(contents).context("failed to parse repo registry TOML")?;
entry.validate()?;
Ok(entry)
}
pub fn to_toml(&self) -> Result<String> {
self.validate()?;
toml::to_string(self).context("failed to serialize repo registry TOML")
}
fn validate(&self) -> Result<()> {
if let Some(display_name) = &self.display_name {
if display_name.is_empty() {
bail!("display_name cannot be empty");
}
}
validate_string_values("remote_urls", &self.aliases.remote_urls)?;
validate_string_values("root_commits", &self.aliases.root_commits)?;
validate_string_values("repo_basenames", &self.aliases.repo_basenames)?;
validate_string_values("known_clone_roots", &self.known_clone_roots)?;
Ok(())
}
pub fn add_known_clone_root(&mut self, clone_root: String) {
if self
.known_clone_roots
.iter()
.any(|known| known == &clone_root)
{
return;
}
self.known_clone_roots.push(clone_root);
self.known_clone_roots.sort();
}
pub fn remove_known_clone_root(&mut self, clone_root: &str) -> bool {
let before = self.known_clone_roots.len();
self.known_clone_roots.retain(|known| known != clone_root);
before != self.known_clone_roots.len()
}
}
pub fn load(path: &Path) -> Result<Option<RepoRegistryEntry>> {
match fs::read_to_string(path) {
Ok(contents) => Ok(Some(RepoRegistryEntry::from_toml(&contents)?)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
}
}
pub fn write(path: &Path, entry: &RepoRegistryEntry) -> Result<PathBuf> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
let contents = entry.to_toml()?;
fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))?;
Ok(path.to_path_buf())
}
fn default_version() -> u32 {
REPO_REGISTRY_SCHEMA_VERSION
}
pub fn normalize_clone_root(path: &Path) -> Result<String> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
env::current_dir()?.join(path)
};
let normalized = absolute.canonicalize().unwrap_or(absolute);
Ok(normalized.display().to_string())
}
fn validate_string_values(label: &str, values: &[String]) -> Result<()> {
if values.iter().any(|value| value.is_empty()) {
bail!("{label} cannot contain empty values");
}
Ok(())
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
#[test]
fn parses_repo_registry_aliases() {
let entry = RepoRegistryEntry::from_toml(
r#"
version = 1
display_name = "ccd-guide"
known_clone_roots = ["/tmp/ccd-guide"]
[aliases]
remote_urls = ["git@github.com:example/ccd-guide.git"]
root_commits = ["abc123"]
repo_basenames = ["ccd-guide"]
"#,
)
.expect("repo registry parses");
assert_eq!(entry.display_name.as_deref(), Some("ccd-guide"));
assert_eq!(
entry.aliases.remote_urls,
vec!["git@github.com:example/ccd-guide.git"]
);
assert_eq!(entry.aliases.root_commits, vec!["abc123"]);
assert_eq!(entry.aliases.repo_basenames, vec!["ccd-guide"]);
assert_eq!(entry.known_clone_roots, vec!["/tmp/ccd-guide"]);
}
#[test]
fn ignores_unknown_top_level_registry_fields() {
let entry = RepoRegistryEntry::from_toml(
r#"
version = 1
display_name = "ccd-guide"
future_field = true
"#,
)
.expect("unknown top-level field should be ignored");
assert_eq!(entry.display_name.as_deref(), Some("ccd-guide"));
}
#[test]
fn writes_and_loads_registry_entry() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("repos/ccdrepo_123/repo.toml");
let entry = RepoRegistryEntry::new(
Some("ccd-guide".to_owned()),
RepoAliases {
remote_urls: vec!["git@github.com:example/ccd-guide.git".to_owned()],
root_commits: vec!["abc123".to_owned()],
repo_basenames: vec!["ccd-guide".to_owned()],
},
)
.expect("entry");
write(&path, &entry).expect("entry written");
let reloaded = load(&path).expect("entry loaded");
assert_eq!(reloaded, Some(entry));
}
#[test]
fn rejects_empty_alias_values() {
let error = RepoRegistryEntry::new(
None,
RepoAliases {
remote_urls: vec![String::new()],
root_commits: Vec::new(),
repo_basenames: Vec::new(),
},
)
.expect_err("empty alias should fail");
assert!(error.to_string().contains("remote_urls"));
}
#[test]
fn rejects_unknown_alias_fields() {
let error = RepoRegistryEntry::from_toml(
r#"
version = 1
[aliases]
future_alias = "value"
"#,
)
.expect_err("unknown alias field should fail");
let message = format!("{error:#}");
assert!(message.contains("future_alias"));
}
#[test]
fn add_known_clone_root_deduplicates_and_sorts() {
let mut entry = RepoRegistryEntry::new(None, RepoAliases::default()).expect("entry");
entry.add_known_clone_root("/tmp/z-repo".to_owned());
entry.add_known_clone_root("/tmp/a-repo".to_owned());
entry.add_known_clone_root("/tmp/z-repo".to_owned());
assert_eq!(entry.known_clone_roots, vec!["/tmp/a-repo", "/tmp/z-repo"]);
}
}