use std::{
collections::BTreeMap,
io,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use crate::{runtime, status::ProjectRunMode};
pub const REGISTRY_FILE_NAME: &str = "loose_registry.json";
pub const REGISTRY_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LooseEntry {
pub config_path: String,
pub project_id: String,
#[serde(default)]
pub mode: ProjectRunMode,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LooseRegistry {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub entries: BTreeMap<String, LooseEntry>,
}
#[derive(Debug)]
pub enum RegistryError {
Malformed(String),
UnsupportedVersion(u32),
Io(io::Error),
}
impl std::fmt::Display for RegistryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Malformed(detail) => write!(f, "loose registry is malformed: {detail}"),
Self::UnsupportedVersion(version) => write!(
f,
"loose registry schema v{version} is newer than supported v{REGISTRY_VERSION}"
),
Self::Io(err) => write!(f, "loose registry io error: {err}"),
}
}
}
impl std::error::Error for RegistryError {}
pub fn registry_path() -> PathBuf {
runtime::state_dir().join(REGISTRY_FILE_NAME)
}
fn temp_path(path: &Path) -> PathBuf {
let name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| REGISTRY_FILE_NAME.to_string());
let temp_name = format!(".{name}.{}.tmp", std::process::id());
path.with_file_name(temp_name)
}
impl LooseRegistry {
pub fn load() -> Result<Self, RegistryError> {
Self::load_from(®istry_path())
}
pub fn load_from(path: &Path) -> Result<Self, RegistryError> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::empty()),
Err(err) => return Err(RegistryError::Io(err)),
};
let registry: Self = serde_json::from_str(&raw)
.map_err(|err| RegistryError::Malformed(err.to_string()))?;
if registry.version != REGISTRY_VERSION {
return Err(RegistryError::UnsupportedVersion(registry.version));
}
Ok(registry)
}
pub fn empty() -> Self {
Self {
version: REGISTRY_VERSION,
entries: BTreeMap::new(),
}
}
pub fn insert(&mut self, entry: LooseEntry) {
self.entries.insert(entry.config_path.clone(), entry);
}
pub fn remove(&mut self, config_path: &str) -> bool {
self.entries.remove(config_path).is_some()
}
pub fn remove_project(&mut self, project_id: &str) -> bool {
let before = self.entries.len();
self.entries
.retain(|_, entry| entry.project_id != project_id);
self.entries.len() != before
}
pub fn by_project(&self, project_id: &str) -> Option<&LooseEntry> {
self.entries
.values()
.find(|entry| entry.project_id == project_id)
}
pub fn entries(&self) -> impl Iterator<Item = &LooseEntry> {
self.entries.values()
}
pub fn save(&self) -> io::Result<()> {
self.save_to(®istry_path())
}
pub fn save_to(&self, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
runtime::create_private_dir(parent)?;
}
let mut serialized = serde_json::to_vec_pretty(self)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
serialized.push(b'\n');
let temp = temp_path(path);
runtime::write_private_file(&temp, &serialized)?;
match std::fs::rename(&temp, path) {
Ok(()) => Ok(()),
Err(err) => {
let _ = std::fs::remove_file(&temp);
Err(err)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: &str, path: &str) -> LooseEntry {
LooseEntry {
config_path: path.to_string(),
project_id: id.to_string(),
mode: ProjectRunMode::Daemon,
}
}
#[test]
fn absent_registry_loads_empty() {
let dir = tempfile::tempdir().unwrap();
let registry =
LooseRegistry::load_from(&dir.path().join("missing.json")).unwrap();
assert_eq!(registry.entries().count(), 0);
assert_eq!(registry.version, REGISTRY_VERSION);
}
#[test]
fn entries_round_trip_through_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(REGISTRY_FILE_NAME);
let mut registry = LooseRegistry::empty();
registry.insert(entry("ngrok-tunnel-8d7b", "/units/ngrok-tunnel-8d7b.yaml"));
registry.insert(entry(
"gamecast-tunnel-3a2a",
"/units/gamecast-tunnel-3a2a.yaml",
));
registry.save_to(&path).unwrap();
let loaded = LooseRegistry::load_from(&path).unwrap();
let ids: Vec<&str> = loaded.entries().map(|e| e.project_id.as_str()).collect();
assert_eq!(ids, vec!["gamecast-tunnel-3a2a", "ngrok-tunnel-8d7b"]);
}
#[test]
fn many_loose_manifests_coexist() {
let mut registry = LooseRegistry::empty();
for i in 0..8 {
registry.insert(entry(
&format!("unit-{i}"),
&format!("/units/unit-{i}.yaml"),
));
}
assert_eq!(registry.entries().count(), 8);
}
#[test]
fn sibling_units_sharing_a_stem_each_keep_a_slot() {
let mut registry = LooseRegistry::empty();
for hash in ["3a2a1f8c6425", "5a9ce32857ea", "6df683933138"] {
registry.insert(entry(
&format!("gamecast-tunnel-{hash}-deadbeefdeadbeef"),
&format!("/units/gamecast-tunnel-{hash}.yaml"),
));
}
assert_eq!(registry.entries().count(), 3);
}
#[test]
fn reinserting_the_same_path_replaces_rather_than_duplicates() {
let mut registry = LooseRegistry::empty();
registry.insert(entry("unit", "/units/unit.yaml"));
registry.insert(entry("unit", "/units/unit.yaml"));
assert_eq!(registry.entries().count(), 1);
}
#[test]
fn two_paths_sharing_a_filename_do_not_share_a_slot() {
let mut registry = LooseRegistry::empty();
registry.insert(entry("svc-aaaa", "/a/svc.yaml"));
registry.insert(entry("svc-bbbb", "/b/svc.yaml"));
assert_eq!(registry.entries().count(), 2);
}
#[test]
fn remove_reports_whether_an_entry_was_present() {
let mut registry = LooseRegistry::empty();
registry.insert(entry("unit", "/units/unit.yaml"));
assert!(registry.remove("/units/unit.yaml"));
assert!(!registry.remove("/units/unit.yaml"));
}
#[test]
fn entries_are_addressable_by_derived_project_id() {
let mut registry = LooseRegistry::empty();
registry.insert(entry("unit-abcd", "/units/unit.yaml"));
assert_eq!(
registry
.by_project("unit-abcd")
.map(|e| e.config_path.as_str()),
Some("/units/unit.yaml")
);
assert!(registry.by_project("missing").is_none());
assert!(registry.remove_project("unit-abcd"));
assert!(!registry.remove_project("unit-abcd"));
}
#[test]
fn a_malformed_registry_is_an_error_not_an_empty_overwrite() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(REGISTRY_FILE_NAME);
std::fs::write(&path, b"{ not json").unwrap();
assert!(matches!(
LooseRegistry::load_from(&path),
Err(RegistryError::Malformed(_))
));
}
#[test]
fn a_future_version_is_an_error_rather_than_being_misparsed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(REGISTRY_FILE_NAME);
std::fs::write(&path, br#"{"version":999,"entries":{}}"#).unwrap();
assert!(matches!(
LooseRegistry::load_from(&path),
Err(RegistryError::UnsupportedVersion(999))
));
}
#[test]
fn saving_leaves_no_temp_file_behind() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(REGISTRY_FILE_NAME);
LooseRegistry::empty().save_to(&path).unwrap();
assert!(path.exists());
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|name| name.ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "left temp files: {leftovers:?}");
}
}