use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const PALACE_ALIASES_JSON: &str = "palace_aliases.json";
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct PalaceAliasesFile {
#[serde(default = "default_alias_schema_version")]
version: u32,
#[serde(default)]
aliases: BTreeMap<String, String>,
}
fn default_alias_schema_version() -> u32 {
1
}
pub struct PalaceAliasStore;
impl PalaceAliasStore {
pub fn load_aliases(registry_dir: &Path) -> Result<BTreeMap<String, String>> {
let path = registry_dir.join(PALACE_ALIASES_JSON);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(e) => {
return Err(e)
.with_context(|| format!("read palace aliases at {}", path.display()));
}
};
if bytes.iter().all(u8::is_ascii_whitespace) {
return Ok(BTreeMap::new());
}
match serde_json::from_slice::<PalaceAliasesFile>(&bytes) {
Ok(file) => Ok(file.aliases),
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"palace alias file is unparseable; ignoring (treating as no aliases)"
);
Ok(BTreeMap::new())
}
}
}
pub fn register_alias(registry_dir: &Path, alias: &str, target: &str) -> Result<()> {
let alias = alias.trim();
let target = target.trim();
if alias.is_empty() || target.is_empty() {
anyhow::bail!(
"palace alias and target must both be non-empty (alias={alias:?}, target={target:?})"
);
}
if alias == target {
anyhow::bail!(
"refusing to register a self-referential palace alias {alias:?} -> {target:?}"
);
}
std::fs::create_dir_all(registry_dir)
.with_context(|| format!("create registry dir {}", registry_dir.display()))?;
let mut aliases = Self::load_aliases(registry_dir)?;
aliases.insert(alias.to_string(), target.to_string());
let file = PalaceAliasesFile {
version: default_alias_schema_version(),
aliases,
};
let bytes = serde_json::to_vec_pretty(&file).context("serialize palace aliases")?;
let target_path = registry_dir.join(PALACE_ALIASES_JSON);
let tmp_path = registry_dir.join(format!("{PALACE_ALIASES_JSON}.tmp"));
std::fs::write(&tmp_path, &bytes)
.with_context(|| format!("write palace aliases tmp {}", tmp_path.display()))?;
std::fs::rename(&tmp_path, &target_path)
.with_context(|| format!("rename palace aliases into {}", target_path.display()))?;
Ok(())
}
pub fn resolve_alias(registry_dir: &Path, alias: &str) -> Result<Option<String>> {
Ok(Self::load_aliases(registry_dir)?.get(alias.trim()).cloned())
}
}
pub fn palace_registry_dir_from(data_dir: PathBuf) -> PathBuf {
let nested = data_dir.join("palaces");
if nested.is_dir() { nested } else { data_dir }
}
pub fn default_palace_registry_dir() -> Result<PathBuf> {
let data_dir = crate::resolve_data_dir("trusty-memory")?;
Ok(palace_registry_dir_from(data_dir))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn load_missing_is_empty() {
let tmp = tempdir().unwrap();
let aliases = PalaceAliasStore::load_aliases(tmp.path()).expect("load");
assert!(aliases.is_empty());
}
#[test]
fn register_then_resolve_round_trips() {
let tmp = tempdir().unwrap();
PalaceAliasStore::register_alias(tmp.path(), "bobmatnyc-trusty-tools", "trusty-tools")
.expect("register");
assert_eq!(
PalaceAliasStore::resolve_alias(tmp.path(), "bobmatnyc-trusty-tools")
.expect("resolve")
.as_deref(),
Some("trusty-tools")
);
let all = PalaceAliasStore::load_aliases(tmp.path()).expect("load");
assert_eq!(
all.get("bobmatnyc-trusty-tools").map(String::as_str),
Some("trusty-tools")
);
assert!(tmp.path().join(PALACE_ALIASES_JSON).exists());
}
#[test]
fn register_is_idempotent() {
let tmp = tempdir().unwrap();
PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all.get("a").map(String::as_str), Some("b"));
PalaceAliasStore::register_alias(tmp.path(), "a", "c").unwrap();
let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all.get("a").map(String::as_str), Some("c"));
}
#[test]
fn register_self_alias_is_rejected() {
let tmp = tempdir().unwrap();
assert!(PalaceAliasStore::register_alias(tmp.path(), "same", "same").is_err());
assert!(PalaceAliasStore::register_alias(tmp.path(), "same", " same ").is_err());
}
#[test]
fn register_rejects_empty() {
let tmp = tempdir().unwrap();
assert!(PalaceAliasStore::register_alias(tmp.path(), "", "b").is_err());
assert!(PalaceAliasStore::register_alias(tmp.path(), "a", " ").is_err());
}
#[test]
fn resolve_unknown_is_none() {
let tmp = tempdir().unwrap();
PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
assert_eq!(
PalaceAliasStore::resolve_alias(tmp.path(), "zzz").unwrap(),
None
);
}
#[test]
fn corrupt_file_degrades_to_empty() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join(PALACE_ALIASES_JSON), b"{ not json ]").unwrap();
let all =
PalaceAliasStore::load_aliases(tmp.path()).expect("load never errors on corruption");
assert!(all.is_empty());
}
#[test]
fn palace_registry_dir_prefers_palaces_subdir() {
let tmp = tempdir().unwrap();
let nested = tmp.path().join("palaces");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(palace_registry_dir_from(tmp.path().to_path_buf()), nested);
}
#[test]
fn palace_registry_dir_falls_back_to_data_dir() {
let tmp = tempdir().unwrap();
assert_eq!(
palace_registry_dir_from(tmp.path().to_path_buf()),
tmp.path().to_path_buf()
);
}
}