use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RepoRecord {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub head: HashMap<String, String>,
pub origin: String,
#[serde(default)]
pub trusted_sources: Vec<String>,
#[serde(default)]
pub local_paths: Vec<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_ref: Option<String>,
pub created: String,
pub updated: String,
}
pub fn registry_dir() -> PathBuf {
std::env::var("HOME")
.ok()
.map(PathBuf::from)
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
.join(".void")
.join("repos")
}
pub fn load_record(id: &str) -> Result<Option<RepoRecord>, String> {
let path = registry_dir().join(format!("{}.json", id));
if !path.exists() {
return Ok(None);
}
let content =
fs::read_to_string(&path).map_err(|e| format!("failed to read registry record: {}", e))?;
let mut record: RepoRecord = serde_json::from_str(&content)
.map_err(|e| format!("failed to parse registry record: {}", e))?;
if prune_local_paths(&mut record) {
let _ = save_record(&record);
}
Ok(Some(record))
}
pub fn save_record(record: &RepoRecord) -> Result<(), String> {
let dir = registry_dir();
fs::create_dir_all(&dir).map_err(|e| format!("failed to create registry dir: {}", e))?;
let path = dir.join(format!("{}.json", record.id));
let content = serde_json::to_string_pretty(record)
.map_err(|e| format!("failed to serialize registry record: {}", e))?;
fs::write(&path, content).map_err(|e| format!("failed to write registry record: {}", e))?;
Ok(())
}
pub fn delete_record(id: &str) -> Result<bool, String> {
let path = registry_dir().join(format!("{}.json", id));
if !path.exists() {
return Ok(false);
}
fs::remove_file(&path).map_err(|e| format!("failed to delete registry record: {}", e))?;
Ok(true)
}
pub fn list_records() -> Result<Vec<RepoRecord>, String> {
let dir = registry_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut records = Vec::new();
for entry in fs::read_dir(&dir).map_err(|e| format!("failed to read registry dir: {}", e))? {
let entry = entry.map_err(|e| format!("failed to read dir entry: {}", e))?;
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
match serde_json::from_str::<RepoRecord>(&content) {
Ok(mut record) => {
if prune_local_paths(&mut record) {
let _ = save_record(&record);
}
records.push(record);
}
Err(_) => continue,
}
}
}
records.sort_by(|a, b| a.name.cmp(&b.name));
Ok(records)
}
pub fn prune_local_paths(record: &mut RepoRecord) -> bool {
let before = record.local_paths.len();
record
.local_paths
.retain(|p| p.is_absolute() && p.join(".void").exists());
record.local_paths.len() < before
}
pub fn register_repo(
id: &str,
name: &str,
local_path: &Path,
origin: &str,
signing_pubkey: Option<&str>,
) -> Result<RepoRecord, String> {
let now = chrono::Utc::now().to_rfc3339();
let abs_path = local_path
.canonicalize()
.unwrap_or_else(|_| local_path.to_path_buf());
let record = match load_record(id)? {
Some(mut existing) => {
if abs_path.is_absolute()
&& !abs_path.as_os_str().is_empty()
&& !existing.local_paths.contains(&abs_path)
{
existing.local_paths.push(abs_path);
}
if let Some(spk) = signing_pubkey {
if !existing.trusted_sources.contains(&spk.to_string()) {
existing.trusted_sources.push(spk.to_string());
}
}
existing.updated = now;
existing
}
None => {
let mut local_paths = Vec::new();
if abs_path.is_absolute() && !abs_path.as_os_str().is_empty() {
local_paths.push(abs_path);
}
let mut trusted_sources = Vec::new();
if let Some(spk) = signing_pubkey {
trusted_sources.push(spk.to_string());
}
RepoRecord {
id: id.to_string(),
name: name.to_string(),
description: None,
head: HashMap::new(),
origin: origin.to_string(),
trusted_sources,
local_paths,
key_ref: None,
created: now.clone(),
updated: now,
}
}
};
save_record(&record)?;
Ok(record)
}
pub fn update_head(id: &str, branch: &str, cid: &str) -> Result<(), String> {
let mut record =
load_record(id)?.ok_or_else(|| format!("no registry record for repo {}", id))?;
record.head.insert(branch.to_string(), cid.to_string());
record.updated = chrono::Utc::now().to_rfc3339();
save_record(&record)
}
pub fn resolve_target(target: &str) -> Result<RepoRecord, String> {
match resolve_target_candidates(target)? {
candidates if candidates.is_empty() => {
Err(format!("no repo matching '{}' in registry", target))
}
candidates if candidates.len() == 1 => Ok(candidates.into_iter().next().unwrap()),
candidates => Err(format!(
"ambiguous: {} repos match '{}' in registry",
candidates.len(),
target
)),
}
}
pub fn resolve_target_interactive(target: &str) -> Result<RepoRecord, String> {
let candidates = resolve_target_candidates(target)?;
match candidates.len() {
0 => Err(format!("no repo matching '{}' in registry", target)),
1 => Ok(candidates.into_iter().next().unwrap()),
_ => {
let items: Vec<String> = candidates
.iter()
.map(|r| {
let short_id = &r.id[..8.min(r.id.len())];
let paths = if r.local_paths.is_empty() {
String::new()
} else {
format!(" ({})", r.local_paths[0].display())
};
format!("{} [{}]{}", r.name, short_id, paths)
})
.collect();
let selection = dialoguer::Select::new()
.with_prompt(format!(
"Multiple repos match '{}'. Which one?",
target
))
.items(&items)
.default(0)
.interact_opt()
.map_err(|e| format!("selection failed: {}", e))?;
match selection {
Some(idx) => Ok(candidates.into_iter().nth(idx).unwrap()),
None => Err("selection cancelled".to_string()),
}
}
}
}
fn resolve_target_candidates(target: &str) -> Result<Vec<RepoRecord>, String> {
if let Ok(Some(record)) = load_record(target) {
return Ok(vec![record]);
}
let all = list_records()?;
let prefix_matches: Vec<RepoRecord> = all
.iter()
.filter(|r| r.id.starts_with(target))
.cloned()
.collect();
if !prefix_matches.is_empty() {
return Ok(prefix_matches);
}
let lower = target.to_lowercase();
let name_matches: Vec<RepoRecord> = all
.into_iter()
.filter(|r| r.name.to_lowercase() == lower)
.collect();
Ok(name_matches)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn setup_test_registry() -> (tempfile::TempDir, PathBuf) {
let dir = tempdir().unwrap();
let repos_dir = dir.path().join(".void").join("repos");
fs::create_dir_all(&repos_dir).unwrap();
(dir, repos_dir)
}
fn save_test_record(repos_dir: &Path, record: &RepoRecord) {
let path = repos_dir.join(format!("{}.json", record.id));
let content = serde_json::to_string_pretty(record).unwrap();
fs::write(path, content).unwrap();
}
fn make_record(id: &str, name: &str) -> RepoRecord {
let now = chrono::Utc::now().to_rfc3339();
RepoRecord {
id: id.to_string(),
name: name.to_string(),
description: None,
head: HashMap::new(),
origin: "self".to_string(),
trusted_sources: Vec::new(),
local_paths: Vec::new(),
key_ref: None,
created: now.clone(),
updated: now,
}
}
#[test]
fn test_repo_record_serialization() {
let mut record = make_record("test-uuid", "my-project");
record
.head
.insert("trunk".to_string(), "bafyabc123".to_string());
record.trusted_sources.push("deadbeef".to_string());
let json = serde_json::to_string(&record).unwrap();
assert!(json.contains("\"id\":\"test-uuid\""));
assert!(json.contains("\"name\":\"my-project\""));
assert!(json.contains("\"origin\":\"self\""));
let restored: RepoRecord = serde_json::from_str(&json).unwrap();
assert_eq!(restored.id, "test-uuid");
assert_eq!(restored.name, "my-project");
assert_eq!(restored.head.get("trunk"), Some(&"bafyabc123".to_string()));
}
#[test]
fn test_prune_local_paths() {
let mut record = make_record("test", "proj");
record.local_paths.push(PathBuf::from("/nonexistent/path1"));
record.local_paths.push(PathBuf::from("/nonexistent/path2"));
let pruned = prune_local_paths(&mut record);
assert!(pruned);
assert!(record.local_paths.is_empty());
}
#[test]
fn test_prune_local_paths_removes_relative() {
let mut record = make_record("test", "proj");
record.local_paths.push(PathBuf::from("."));
record.local_paths.push(PathBuf::from("relative/path"));
let pruned = prune_local_paths(&mut record);
assert!(pruned);
assert!(record.local_paths.is_empty());
}
#[test]
fn test_prune_local_paths_keeps_valid() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".void")).unwrap();
let mut record = make_record("test", "proj");
record.local_paths.push(dir.path().to_path_buf());
record.local_paths.push(PathBuf::from("/nonexistent"));
record.local_paths.push(PathBuf::from("."));
let pruned = prune_local_paths(&mut record);
assert!(pruned);
assert_eq!(record.local_paths.len(), 1);
assert_eq!(record.local_paths[0], dir.path());
}
}