use serde::{Deserialize, Serialize};
use super::error::AppError;
use super::store::{KS_ASSIGNMENTS, Store};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AssignmentEntry {
pub domain: String,
pub assigned_at: u64,
pub assigner: String,
}
fn assignment_key(domain: &str) -> String {
format!("assignments:{domain}")
}
#[derive(Debug, PartialEq, Eq)]
pub enum AssignOutcome {
Created(AssignmentEntry),
Existing(AssignmentEntry),
}
#[derive(Debug, PartialEq, Eq)]
pub enum UnassignOutcome {
Removed(AssignmentEntry),
Missing,
}
pub async fn assign(
store: &Store,
domain: &str,
assigner: &str,
now_epoch: u64,
) -> Result<AssignOutcome, AppError> {
let ks = store.keyspace(KS_ASSIGNMENTS)?;
let key = assignment_key(domain);
if let Some(existing) = ks.get::<AssignmentEntry>(key.clone()).await? {
return Ok(AssignOutcome::Existing(existing));
}
let entry = AssignmentEntry {
domain: domain.to_string(),
assigned_at: now_epoch,
assigner: assigner.to_string(),
};
ks.insert(key, &entry).await?;
Ok(AssignOutcome::Created(entry))
}
pub async fn unassign(store: &Store, domain: &str) -> Result<UnassignOutcome, AppError> {
let ks = store.keyspace(KS_ASSIGNMENTS)?;
let key = assignment_key(domain);
let Some(existing) = ks.get::<AssignmentEntry>(key.clone()).await? else {
return Ok(UnassignOutcome::Missing);
};
ks.remove(key).await?;
Ok(UnassignOutcome::Removed(existing))
}
pub async fn get(store: &Store, domain: &str) -> Result<Option<AssignmentEntry>, AppError> {
let ks = store.keyspace(KS_ASSIGNMENTS)?;
ks.get(assignment_key(domain)).await
}
pub async fn list(store: &Store) -> Result<Vec<AssignmentEntry>, AppError> {
let ks = store.keyspace(KS_ASSIGNMENTS)?;
let raw = ks.prefix_iter_raw(b"assignments:".to_vec()).await?;
let mut out = Vec::with_capacity(raw.len());
for (_k, v) in raw {
if let Ok(entry) = serde_json::from_slice::<AssignmentEntry>(&v) {
out.push(entry);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::config::StoreConfig;
async fn fjall_store() -> Store {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
};
std::mem::forget(dir);
Store::open(&cfg).await.expect("open fjall")
}
#[tokio::test]
async fn assign_creates_then_idempotent() {
let store = fjall_store().await;
let first = assign(&store, "example.com", "did:example:control", 100)
.await
.unwrap();
assert!(matches!(first, AssignOutcome::Created(_)));
let second = assign(&store, "example.com", "did:example:control", 200)
.await
.unwrap();
let AssignOutcome::Existing(entry) = second else {
panic!("expected Existing on re-assign, got {second:?}");
};
assert_eq!(
entry.assigned_at, 100,
"re-assign must preserve original timestamp"
);
assert_eq!(entry.assigner, "did:example:control");
}
#[tokio::test]
async fn unassign_round_trip() {
let store = fjall_store().await;
let missing = unassign(&store, "example.com").await.unwrap();
assert!(matches!(missing, UnassignOutcome::Missing));
assign(&store, "example.com", "did:example:control", 1)
.await
.unwrap();
let removed = unassign(&store, "example.com").await.unwrap();
let UnassignOutcome::Removed(entry) = removed else {
panic!("expected Removed, got {removed:?}");
};
assert_eq!(entry.domain, "example.com");
let missing2 = unassign(&store, "example.com").await.unwrap();
assert!(matches!(missing2, UnassignOutcome::Missing));
}
#[tokio::test]
async fn list_returns_every_assigned_domain() {
let store = fjall_store().await;
assign(&store, "a.example", "did:c", 1).await.unwrap();
assign(&store, "b.example", "did:c", 1).await.unwrap();
assign(&store, "c.example", "did:c", 1).await.unwrap();
let all = list(&store).await.unwrap();
let mut names: Vec<String> = all.into_iter().map(|e| e.domain).collect();
names.sort();
assert_eq!(names, vec!["a.example", "b.example", "c.example"]);
}
#[tokio::test]
async fn get_returns_none_for_unknown() {
let store = fjall_store().await;
assert!(get(&store, "unknown.example").await.unwrap().is_none());
}
}