use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PeerEntry {
pub slug: String,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
fn slugify_host(host: &str) -> String {
let mapped: String = host
.to_ascii_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '.' {
c
} else {
'-'
}
})
.collect();
let trimmed = mapped.trim_matches(['-', '.'].as_ref());
if trimmed.is_empty() {
"peer".to_string()
} else {
trimmed.to_string()
}
}
fn unique_slug(base: &str, existing: &[PeerEntry]) -> String {
if !existing.iter().any(|p| p.slug == base) {
return base.to_string();
}
(2..)
.map(|n| format!("{base}-{n}"))
.find(|s| !existing.iter().any(|p| &p.slug == s))
.expect("infinite range yields a free slug")
}
pub struct PeerRegistry {
path: PathBuf,
}
impl PeerRegistry {
pub fn user_default() -> Result<Self, String> {
if let Some(p) = std::env::var_os("CAR_A2A_PEERS_PATH") {
return Ok(Self { path: PathBuf::from(p) });
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.ok_or("cannot resolve home directory (HOME/USERPROFILE unset)")?;
Ok(Self {
path: PathBuf::from(home).join(".car").join("a2a-peers.json"),
})
}
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
fn read_all(&self) -> Vec<PeerEntry> {
std::fs::read_to_string(&self.path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn write_all(&self, peers: &[PeerEntry]) -> Result<(), String> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create {}: {e}", parent.display()))?;
}
let json = serde_json::to_string_pretty(peers).map_err(|e| e.to_string())?;
let tmp = self.path.with_extension("json.tmp");
std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &self.path)
.map_err(|e| format!("rename into {}: {e}", self.path.display()))
}
pub fn add(&self, url: &str, label: Option<String>) -> Result<PeerEntry, String> {
let parsed = reqwest::Url::parse(url).map_err(|e| format!("invalid url: {e}"))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err("peer url must be http or https".to_string());
}
let host = parsed.host_str().ok_or("peer url has no host")?.to_string();
let canonical = parsed.as_str().trim_end_matches('/').to_string();
let mut all = self.read_all();
if all.iter().any(|p| p.url == canonical) {
return Err(format!("peer '{canonical}' is already registered"));
}
let entry = PeerEntry {
slug: unique_slug(&slugify_host(&host), &all),
url: canonical,
label,
};
all.push(entry.clone());
self.write_all(&all)?;
Ok(entry)
}
pub fn list(&self) -> Vec<PeerEntry> {
self.read_all()
}
pub fn remove(&self, slug: &str) -> Result<bool, String> {
let mut all = self.read_all();
let before = all.len();
all.retain(|p| p.slug != slug);
let removed = all.len() != before;
if removed {
self.write_all(&all)?;
}
Ok(removed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_handles_host_port_and_specials() {
assert_eq!(slugify_host("peer.example.com"), "peer.example.com");
assert_eq!(slugify_host("peer.example.com:8080"), "peer.example.com-8080");
assert_eq!(slugify_host("UPPER_Case"), "upper-case");
}
#[test]
fn add_derives_slug_and_rejects_dupes() {
let dir = tempfile::tempdir().unwrap();
let reg = PeerRegistry::at(dir.path().join("a2a-peers.json"));
let e = reg.add("https://peer.example.com/a2a", Some("Peer".into())).unwrap();
assert_eq!(e.slug, "peer.example.com");
assert_eq!(reg.list().len(), 1);
assert!(reg.add("https://peer.example.com/a2a", None).is_err());
}
#[test]
fn add_uniquifies_slug_on_host_collision() {
let dir = tempfile::tempdir().unwrap();
let reg = PeerRegistry::at(dir.path().join("a2a-peers.json"));
let a = reg.add("https://peer.example.com/one", None).unwrap();
let b = reg.add("https://peer.example.com/two", None).unwrap();
assert_eq!(a.slug, "peer.example.com");
assert_eq!(b.slug, "peer.example.com-2"); }
#[test]
fn canonicalizes_url_so_trailing_slash_dedups() {
let dir = tempfile::tempdir().unwrap();
let reg = PeerRegistry::at(dir.path().join("a2a-peers.json"));
let e = reg.add("https://x.example.com", None).unwrap();
assert_eq!(e.url, "https://x.example.com"); assert!(reg.add("https://x.example.com/", None).is_err());
assert_eq!(reg.list().len(), 1);
}
#[test]
fn rejects_non_http_scheme() {
let dir = tempfile::tempdir().unwrap();
let reg = PeerRegistry::at(dir.path().join("a2a-peers.json"));
assert!(reg.add("ftp://peer.example.com", None).is_err());
assert!(reg.add("not a url", None).is_err());
}
#[test]
fn remove_by_slug() {
let dir = tempfile::tempdir().unwrap();
let reg = PeerRegistry::at(dir.path().join("a2a-peers.json"));
reg.add("https://a.example.com", None).unwrap();
assert!(reg.remove("a.example.com").unwrap());
assert!(!reg.remove("a.example.com").unwrap()); assert!(reg.list().is_empty());
}
}