use std::collections::HashMap;
use std::time::SystemTime;
use uuid::Uuid;
use super::hash_vtoken;
#[derive(Debug, Clone)]
pub struct ClientInfo {
pub name: String,
pub vtoken: String,
pub label: Option<String>,
pub description: Option<String>,
pub registered_at: SystemTime,
pub online: bool,
pub persona_name: Option<String>,
pub persona_emoji: Option<String>,
}
impl ClientInfo {
pub fn new(name: String, label: Option<String>, description: Option<String>) -> (Self, String) {
let plain = format!("vhub_{}", Uuid::new_v4().simple());
let hashed = hash_vtoken(&plain);
(
Self::with_hashed_vtoken(name, label, description, hashed),
plain,
)
}
fn with_hashed_vtoken(
name: String,
label: Option<String>,
description: Option<String>,
vtoken_hash: String,
) -> Self {
Self {
name,
vtoken: vtoken_hash,
label,
description,
registered_at: SystemTime::now(),
online: false,
persona_name: None,
persona_emoji: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateClientError {
NotFound,
NameTaken,
}
#[derive(Debug)]
pub struct ClientRegistry {
by_vtoken: HashMap<String, ClientInfo>,
by_name: HashMap<String, String>,
}
impl ClientRegistry {
pub fn new() -> Self {
Self {
by_vtoken: HashMap::new(),
by_name: HashMap::new(),
}
}
pub fn register(
&mut self,
name: String,
label: Option<String>,
description: Option<String>,
) -> (String, String, bool) {
if let Some(existing_hash) = self.by_name.get(&name).cloned() {
if let Some(info) = self.by_vtoken.get_mut(&existing_hash) {
if label.is_some() {
info.label = label;
}
if description.is_some() {
info.description = description;
}
info.online = true;
}
return (String::new(), existing_hash, false);
}
let (info, plain) = ClientInfo::new(name.clone(), label, description);
let hashed = info.vtoken.clone();
self.by_name.insert(name, hashed.clone());
self.by_vtoken.insert(hashed.clone(), info);
(plain, hashed, true)
}
pub fn register_with_vtoken(
&mut self,
name: String,
label: Option<String>,
description: Option<String>,
vtoken: Option<String>,
) -> (String, bool) {
if let Some(existing_vtoken) = self.by_name.get(&name) {
let existing_vtoken = existing_vtoken.clone();
if let Some(info) = self.by_vtoken.get_mut(&existing_vtoken) {
if label.is_some() {
info.label = label;
}
if description.is_some() {
info.description = description;
}
info.online = true;
}
return (existing_vtoken, false);
}
let (info, plain_or_hash) = match vtoken {
Some(hashed) => {
let info = ClientInfo::with_hashed_vtoken(
name.clone(),
label,
description,
hashed.clone(),
);
(info, hashed)
}
None => {
let (info, plain) = ClientInfo::new(name.clone(), label, description);
(info, plain)
}
};
let stored = info.vtoken.clone();
self.by_name.insert(name, stored.clone());
self.by_vtoken.insert(stored, info);
(plain_or_hash, true)
}
pub fn register_confirmed(
&mut self,
name: String,
label: Option<String>,
description: Option<String>,
vtoken_hash: String,
) -> Result<(), UpdateClientError> {
if self.by_name.contains_key(&name) {
return Err(UpdateClientError::NameTaken);
}
let info =
ClientInfo::with_hashed_vtoken(name.clone(), label, description, vtoken_hash.clone());
self.by_name.insert(name, vtoken_hash.clone());
self.by_vtoken.insert(vtoken_hash, info);
Ok(())
}
pub fn get_by_vtoken(&self, vtoken: &str) -> Option<&ClientInfo> {
self.by_vtoken.get(vtoken)
}
pub fn get_by_name(&self, name: &str) -> Option<&ClientInfo> {
self.by_name.get(name).and_then(|vt| self.by_vtoken.get(vt))
}
pub fn get_by_alias(&self, name: &str) -> Option<&ClientInfo> {
if let Some(c) = self.get_by_name(name) {
return Some(c);
}
let n: usize = name.parse().ok()?;
if n == 0 {
return None;
}
let mut sorted: Vec<&ClientInfo> = self.all_clients();
sorted.sort_by(|a, b| a.name.cmp(&b.name));
sorted.get(n - 1).copied()
}
pub fn update_metadata(
&mut self,
vtoken: &str,
label: Option<String>,
description: Option<String>,
persona_name: Option<String>,
persona_emoji: Option<String>,
) {
if let Some(info) = self.by_vtoken.get_mut(vtoken) {
if label.is_some() {
info.label = label;
}
if description.is_some() {
info.description = description;
}
if persona_name.is_some() {
info.persona_name = persona_name;
}
if persona_emoji.is_some() {
info.persona_emoji = persona_emoji;
}
}
}
pub fn set_persona(
&mut self,
vtoken: &str,
persona_name: Option<String>,
persona_emoji: Option<String>,
) {
self.update_metadata(vtoken, None, None, persona_name, persona_emoji);
}
pub fn set_description(&mut self, vtoken: &str, description: Option<String>) {
if let Some(info) = self.by_vtoken.get_mut(vtoken) {
info.description = description;
}
}
pub fn mark_online(&mut self, vtoken: &str) {
if let Some(info) = self.by_vtoken.get_mut(vtoken) {
info.online = true;
}
}
pub fn mark_offline(&mut self, vtoken: &str) {
if let Some(info) = self.by_vtoken.get_mut(vtoken) {
info.online = false;
}
}
pub fn online_clients(&self) -> Vec<&ClientInfo> {
self.by_vtoken.values().filter(|c| c.online).collect()
}
pub fn all_clients(&self) -> Vec<&ClientInfo> {
self.by_vtoken.values().collect()
}
pub fn remove(&mut self, name: &str) -> bool {
if let Some(vtoken) = self.by_name.remove(name) {
self.by_vtoken.remove(&vtoken);
true
} else {
false
}
}
pub fn update_client(
&mut self,
old_name: &str,
new_name: &str,
label: Option<String>,
) -> Result<String, UpdateClientError> {
let vtoken = self
.by_name
.get(old_name)
.cloned()
.ok_or(UpdateClientError::NotFound)?;
if new_name != old_name && self.by_name.contains_key(new_name) {
return Err(UpdateClientError::NameTaken);
}
let info = self
.by_vtoken
.get_mut(&vtoken)
.ok_or(UpdateClientError::NotFound)?;
if new_name != old_name {
self.by_name.remove(old_name);
info.name = new_name.to_string();
self.by_name.insert(new_name.to_string(), vtoken.clone());
}
if let Some(l) = label {
info.label = Some(l);
}
Ok(vtoken)
}
pub fn pick_default_after_remove(&self, removed_vtoken: &str) -> Option<String> {
self.online_clients()
.iter()
.find(|c| c.vtoken != removed_vtoken)
.map(|c| c.vtoken.clone())
.or_else(|| {
self.all_clients()
.iter()
.find(|c| c.vtoken != removed_vtoken)
.map(|c| c.vtoken.clone())
})
}
}
impl Default for ClientRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hub::is_vtoken_hash;
#[test]
fn registered_count_single_vs_multi() {
let mut reg = ClientRegistry::new();
assert_eq!(reg.all_clients().len(), 0);
let (plain1, _, is_new1) = reg.register("a".into(), None, None);
assert!(is_new1);
assert!(!plain1.is_empty());
assert!(
plain1.starts_with("vhub_"),
"plaintext vtoken must be returned exactly once"
);
assert_eq!(reg.all_clients().len(), 1);
let (plain2, _, is_new2) = reg.register("b".into(), Some("B".into()), None);
assert!(is_new2);
assert!(!plain2.is_empty());
assert!(plain2.starts_with("vhub_"));
assert_eq!(reg.all_clients().len(), 2);
}
#[test]
fn register_same_name_reuses_vtoken() {
let mut reg = ClientRegistry::new();
let (_plain1, hash1, is_new1) = reg.register("w".into(), None, None);
assert!(is_new1);
let (plain2, hash2, is_new2) = reg.register("w".into(), Some("lbl".into()), None);
assert!(!is_new2, "second register of same name is NOT new");
assert!(plain2.is_empty(), "no new plaintext on re-registration");
assert_eq!(hash1, hash2, "same name reuses hashed vtoken");
assert_eq!(reg.all_clients().len(), 1);
}
#[test]
fn stored_vtoken_is_hash_not_plaintext() {
let mut reg = ClientRegistry::new();
let (plain, hashed, _) = reg.register("echo".into(), Some("echo test".into()), None);
let c = reg.get_by_name("echo").expect("client");
assert_ne!(
c.vtoken, plain,
"ClientInfo.vtoken must NOT hold the plaintext"
);
assert_eq!(
c.vtoken, hashed,
"stored vtoken must equal the hashed return value"
);
assert!(
is_vtoken_hash(&c.vtoken),
"stored vtoken must be a SHA-256 hex; got {:?}",
c.vtoken
);
assert_eq!(c.vtoken, crate::hub::hash_vtoken(&plain));
}
#[test]
fn get_by_vtoken_uses_hash() {
let mut reg = ClientRegistry::new();
let (plain, _, _) = reg.register("a".into(), None, None);
let hashed = crate::hub::hash_vtoken(&plain);
assert!(reg.get_by_vtoken(&hashed).is_some());
assert!(
reg.get_by_vtoken(&plain).is_none(),
"get_by_vtoken must NOT match plaintext"
);
}
#[test]
fn get_by_name_roundtrip() {
let mut reg = ClientRegistry::new();
reg.register("echo".into(), Some("echo test".into()), None);
let c = reg.get_by_name("echo").expect("client");
assert_eq!(c.name, "echo");
assert_eq!(c.label.as_deref(), Some("echo test"));
assert!(reg.get_by_vtoken(&c.vtoken).is_some());
}
#[test]
fn get_by_alias_matches_exact_name_first() {
let mut reg = ClientRegistry::new();
reg.register("charlie".into(), None, None);
reg.register("alpha".into(), None, None);
reg.register("bravo".into(), None, None);
assert_eq!(reg.get_by_alias("alpha").unwrap().name, "alpha");
assert_eq!(reg.get_by_alias("bravo").unwrap().name, "bravo");
}
#[test]
fn get_by_alias_numeric_uses_sorted_index() {
let mut reg = ClientRegistry::new();
reg.register("charlie".into(), None, None);
reg.register("alpha".into(), None, None);
reg.register("bravo".into(), None, None);
assert_eq!(reg.get_by_alias("1").unwrap().name, "alpha");
assert_eq!(reg.get_by_alias("2").unwrap().name, "bravo");
assert_eq!(reg.get_by_alias("3").unwrap().name, "charlie");
}
#[test]
fn get_by_alias_rejects_zero_and_overflow() {
let mut reg = ClientRegistry::new();
reg.register("alpha".into(), None, None);
assert!(reg.get_by_alias("0").is_none(), "0 is not a valid alias");
assert!(
reg.get_by_alias("2").is_none(),
"out-of-range index must return None"
);
}
#[test]
fn get_by_alias_unknown_name_returns_none() {
let mut reg = ClientRegistry::new();
reg.register("alpha".into(), None, None);
assert!(reg.get_by_alias("nope").is_none());
assert!(
reg.get_by_alias("999").is_none(),
"non-existent numeric alias must return None"
);
}
#[test]
fn get_by_alias_numeric_works_on_empty_registry() {
let reg = ClientRegistry::new();
assert!(reg.get_by_alias("1").is_none());
}
#[test]
fn mark_online_offline_uses_hash() {
let mut reg = ClientRegistry::new();
let (_plain, hashed, _) = reg.register("a".into(), None, None);
reg.mark_online(&hashed);
assert!(reg.get_by_name("a").unwrap().online);
reg.mark_offline(&hashed);
assert!(!reg.get_by_name("a").unwrap().online);
let (plain2, _, _) = reg.register("b".into(), None, None);
reg.mark_online(&plain2);
assert!(
!reg.get_by_name("b").unwrap().online,
"mark_online with plaintext must be a no-op"
);
}
#[test]
fn update_client_renames_and_updates_label() {
let mut reg = ClientRegistry::new();
let (_, hashed, _) = reg.register("old".into(), Some("old label".into()), None);
reg.update_client("old", "new", Some("new label".into()))
.unwrap();
assert!(reg.get_by_name("old").is_none());
let c = reg.get_by_name("new").expect("renamed");
assert_eq!(c.vtoken, hashed);
assert_eq!(c.label.as_deref(), Some("new label"));
}
#[test]
fn update_client_rejects_duplicate_name() {
let mut reg = ClientRegistry::new();
reg.register("a".into(), None, None);
reg.register("b".into(), None, None);
assert_eq!(
reg.update_client("a", "b", None),
Err(UpdateClientError::NameTaken)
);
}
#[test]
fn register_with_vtoken_load_path_does_not_rehash() {
let mut reg = ClientRegistry::new();
let (_, _, _) = reg.register("first".into(), None, None);
let stored_first_hash = reg.get_by_name("first").unwrap().vtoken.clone();
let (stored, is_new) =
reg.register_with_vtoken("second".into(), None, None, Some(stored_first_hash.clone()));
assert!(is_new);
assert_eq!(
stored, stored_first_hash,
"register_with_vtoken must return the supplied value verbatim"
);
let c = reg.get_by_name("second").unwrap();
assert_eq!(
c.vtoken, stored_first_hash,
"register_with_vtoken must store the supplied value verbatim"
);
}
#[test]
fn register_with_description() {
let mut reg = ClientRegistry::new();
let (plain, _hashed, _) = reg.register(
"agent".into(),
Some("Test Agent".into()),
Some("An agent for testing".into()),
);
assert!(!plain.is_empty());
let c = reg.get_by_name("agent").expect("client");
assert_eq!(c.label.as_deref(), Some("Test Agent"));
assert_eq!(c.description.as_deref(), Some("An agent for testing"));
}
#[test]
fn register_updates_description() {
let mut reg = ClientRegistry::new();
let (_, hash1, _) = reg.register("agent".into(), None, None);
let (_, hash2, _) = reg.register("agent".into(), None, Some("New description".into()));
assert_eq!(hash1, hash2, "same name reuses hashed vtoken");
let c = reg.get_by_name("agent").expect("client");
assert_eq!(c.description.as_deref(), Some("New description"));
}
#[test]
fn update_metadata_persists_all_fields() {
let mut reg = ClientRegistry::new();
let (_, hash, _) = reg.register("bot".into(), None, None);
reg.update_metadata(
&hash,
Some("my-label".into()),
Some("desc".into()),
Some("Aria".into()),
Some("🤖".into()),
);
let c = reg.get_by_vtoken(&hash).expect("client");
assert_eq!(
c.label.as_deref(),
Some("my-label"),
"label must be updated"
);
assert_eq!(
c.description.as_deref(),
Some("desc"),
"description must be updated"
);
assert_eq!(
c.persona_name.as_deref(),
Some("Aria"),
"persona_name must be updated"
);
assert_eq!(
c.persona_emoji.as_deref(),
Some("🤖"),
"persona_emoji must be updated"
);
}
#[test]
fn set_persona_persists_persona_fields() {
let mut reg = ClientRegistry::new();
let (_, hash, _) = reg.register("bot".into(), None, None);
reg.set_persona(&hash, Some("Claude".into()), Some("🧠".into()));
let c = reg.get_by_vtoken(&hash).expect("client");
assert_eq!(c.persona_name.as_deref(), Some("Claude"));
assert_eq!(c.persona_emoji.as_deref(), Some("🧠"));
}
#[test]
fn set_description_persists_description() {
let mut reg = ClientRegistry::new();
let (_, hash, _) = reg.register("bot".into(), None, None);
reg.set_description(&hash, Some("helpful assistant".into()));
assert_eq!(
reg.get_by_vtoken(&hash).unwrap().description.as_deref(),
Some("helpful assistant")
);
reg.set_description(&hash, None);
assert_eq!(
reg.get_by_vtoken(&hash).unwrap().description,
None,
"description must be cleared"
);
}
#[test]
fn online_clients_returns_only_online() {
let mut reg = ClientRegistry::new();
let (_, ha, _) = reg.register("a".into(), None, None);
let (_, hb, _) = reg.register("b".into(), None, None);
let (_, _hc, _) = reg.register("c".into(), None, None);
reg.mark_online(&ha);
reg.mark_online(&hb);
let online: Vec<_> = reg
.online_clients()
.iter()
.map(|c| c.name.as_str())
.collect();
assert_eq!(online.len(), 2, "only 2 online clients");
assert!(online.contains(&"a"));
assert!(online.contains(&"b"));
assert!(!online.contains(&"c"), "offline client must not appear");
}
#[test]
fn pick_default_after_remove_returns_another_online_client() {
let mut reg = ClientRegistry::new();
let (_, ha, _) = reg.register("a".into(), None, None);
let (_, hb, _) = reg.register("b".into(), None, None);
reg.mark_online(&ha);
reg.mark_online(&hb);
let new_default = reg.pick_default_after_remove(&ha);
assert_eq!(
new_default.as_deref(),
Some(hb.as_str()),
"another online client should be picked"
);
}
#[test]
fn pick_default_after_remove_falls_back_to_offline_client() {
let mut reg = ClientRegistry::new();
let (_, ha, _) = reg.register("a".into(), None, None);
let (_, hb, _) = reg.register("b".into(), None, None);
let new_default = reg.pick_default_after_remove(&ha);
assert_eq!(
new_default.as_deref(),
Some(hb.as_str()),
"falls back to offline client when no online clients remain"
);
}
#[test]
fn pick_default_after_remove_returns_none_when_no_other_client() {
let mut reg = ClientRegistry::new();
let (_, ha, _) = reg.register("a".into(), None, None);
let new_default = reg.pick_default_after_remove(&ha);
assert_eq!(
new_default, None,
"must return None when no other client exists"
);
}
#[test]
fn pick_default_after_remove_skips_the_removed_token() {
let mut reg = ClientRegistry::new();
let (_, ha, _) = reg.register("a".into(), None, None);
reg.mark_online(&ha);
let new_default = reg.pick_default_after_remove(&ha);
assert_eq!(
new_default, None,
"the removed token must not be picked as its own replacement"
);
}
}