use serde::{Deserialize, Serialize};
use serde_json::Value;
use vta_sdk::client::VtaClient;
use crate::errors::OpenVTCError;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BindingSummary {
pub bound: bool,
pub profile_name: Option<String>,
#[serde(default)]
pub label: Option<String>,
pub profile_id: Option<String>,
pub claim_count: u64,
pub bound_at: Option<String>,
pub until: Option<String>,
pub unknown: bool,
pub parent: Option<ParentBinding>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ParentBinding {
pub context_id: String,
pub label: String,
}
impl BindingSummary {
#[must_use]
pub fn unknown() -> Self {
Self {
unknown: true,
..Self::default()
}
}
#[must_use]
pub fn describe(&self) -> String {
if self.unknown {
return "wears: unknown".to_string();
}
if !self.bound {
return match &self.parent {
Some(p) => format!(
"wears: nothing here — {} is worn in {}, which this community does not see",
p.label, p.context_id
),
None => "wears: nothing".to_string(),
};
}
let label = self
.profile_name
.clone()
.or_else(|| self.profile_id.clone())
.unwrap_or_else(|| "an unnamed face".to_string());
let attributes = if self.claim_count == 1 {
"1 attribute".to_string()
} else {
format!("{} attributes", self.claim_count)
};
match &self.label {
Some(shown) => format!("wears: {label} ({attributes}) — known here as “{shown}”"),
None => format!("wears: {label} ({attributes})"),
}
}
}
pub async fn get(
client: &VtaClient,
context_id: &str,
persona_did: &str,
) -> Result<BindingSummary, OpenVTCError> {
let value = client
.persona_binding_get(context_id, persona_did)
.await
.map_err(|e| OpenVTCError::Vta(format!("persona binding read failed: {e}")))?;
Ok(BindingSummary {
bound: value
.get("bound")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
profile_name: value
.get("profileName")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
profile_id: value
.get("profileId")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
label: value
.get("label")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
claim_count: value
.get("claimCount")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0),
bound_at: value
.get("boundAt")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
until: value
.get("until")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
unknown: false,
parent: None,
})
}
async fn worn_in_parent(
client: &VtaClient,
sub_context_id: &str,
persona_did: &str,
) -> Option<ParentBinding> {
let (parent, _) = crate::config::context_path::parse_sub_context_id(sub_context_id)?;
let summary = get(client, parent, persona_did).await.ok()?;
if !summary.bound {
return None;
}
Some(ParentBinding {
context_id: parent.to_string(),
label: summary
.profile_name
.or(summary.profile_id)
.unwrap_or_else(|| "an unnamed face".to_string()),
})
}
pub async fn get_or_unknown(
client: &VtaClient,
context_id: &str,
persona_did: &str,
) -> BindingSummary {
match get(client, context_id, persona_did).await {
Ok(mut summary) => {
if !summary.bound {
summary.parent = worn_in_parent(client, context_id, persona_did).await;
}
summary
}
Err(e) => {
tracing::debug!(
context_id,
persona_did,
error = %e,
"persona binding unavailable; drawing as unknown"
);
BindingSummary::unknown()
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KnownHere {
pub persona_did: String,
pub label: Option<String>,
pub bound: bool,
pub local: bool,
pub claim_count: u64,
}
pub async fn list(client: &VtaClient, context_id: &str) -> Result<Vec<KnownHere>, OpenVTCError> {
let value = client
.persona_binding_list(context_id, None, None)
.await
.map_err(|e| OpenVTCError::Vta(format!("persona binding list failed: {e}")))?;
let mut rows: Vec<KnownHere> = value
.get("personas")
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.map(|r| KnownHere {
persona_did: r
.get("personaDid")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
label: r.get("label").and_then(Value::as_str).map(str::to_string),
bound: r.get("bound").and_then(Value::as_bool).unwrap_or(false),
local: r.get("isLocal").and_then(Value::as_bool).unwrap_or(false),
claim_count: r.get("claimCount").and_then(Value::as_u64).unwrap_or(0),
})
.collect()
})
.unwrap_or_default();
rows.sort_by(|a, b| a.persona_did.cmp(&b.persona_did));
Ok(rows)
}
pub async fn set_local(
client: &VtaClient,
context_id: &str,
persona_did: &str,
profile_id: Option<&str>,
) -> Result<(), OpenVTCError> {
client
.persona_local_binding_set(context_id, persona_did, profile_id, None, None, None)
.await
.map_err(|e| OpenVTCError::Vta(format!("persona local binding write failed: {e}")))?;
Ok(())
}
pub async fn set(
client: &VtaClient,
context_id: &str,
persona_did: &str,
profile_id: Option<&str>,
) -> Result<(), OpenVTCError> {
let (label, until) = match profile_id {
None => (None, None),
Some(_) => {
let current = get(client, context_id, persona_did).await?;
(current.label, current.until)
}
};
client
.persona_binding_set(
context_id,
Some(persona_did),
profile_id,
Vec::new(),
label.as_deref(),
until.as_deref(),
None,
)
.await
.map_err(|e| OpenVTCError::Vta(format!("persona binding write failed: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_is_not_the_same_as_unbound() {
assert_eq!(BindingSummary::unknown().describe(), "wears: unknown");
assert_eq!(BindingSummary::default().describe(), "wears: nothing");
}
#[test]
fn a_face_worn_one_level_up_is_named_rather_than_hidden_behind_nothing() {
let summary = BindingSummary {
parent: Some(ParentBinding {
context_id: "openvtc".into(),
label: "OSS Developer".into(),
}),
..BindingSummary::default()
};
let line = summary.describe();
assert!(line.contains("nothing here"), "{line}");
assert!(line.contains("OSS Developer"), "{line}");
assert!(line.contains("openvtc"), "{line}");
assert!(
line.contains("does not see"),
"the community must not be implied to see it: {line}"
);
}
#[test]
fn a_parent_binding_does_not_override_unknown() {
let summary = BindingSummary {
parent: Some(ParentBinding {
context_id: "openvtc".into(),
label: "OSS Developer".into(),
}),
..BindingSummary::unknown()
};
assert_eq!(summary.describe(), "wears: unknown");
}
#[test]
fn a_bound_context_reports_what_it_wears_not_the_parent() {
let summary = BindingSummary {
bound: true,
profile_name: Some("Work".into()),
claim_count: 3,
parent: Some(ParentBinding {
context_id: "openvtc".into(),
label: "OSS Developer".into(),
}),
..BindingSummary::default()
};
assert_eq!(summary.describe(), "wears: Work (3 attributes)");
}
#[test]
fn a_default_summary_never_reads_as_unknown() {
assert!(!BindingSummary::default().unknown);
assert!(BindingSummary::unknown().unknown);
}
#[test]
fn a_bound_profile_is_described_by_label_and_count() {
let s = BindingSummary {
bound: true,
profile_name: Some("work".into()),
claim_count: 3,
..Default::default()
};
assert_eq!(s.describe(), "wears: work (3 attributes)");
}
#[test]
fn a_labelled_binding_says_what_the_community_is_told() {
let s = BindingSummary {
bound: true,
profile_name: Some("the divorce".into()),
label: Some("Ada at the co-op".into()),
claim_count: 2,
..Default::default()
};
assert_eq!(
s.describe(),
"wears: the divorce (2 attributes) — known here as “Ada at the co-op”"
);
}
#[test]
fn a_single_fact_is_singular() {
let s = BindingSummary {
bound: true,
profile_name: Some("gaming".into()),
claim_count: 1,
..Default::default()
};
assert_eq!(s.describe(), "wears: gaming (1 attribute)");
}
#[test]
fn an_unlabelled_profile_still_describes_itself() {
let by_id = BindingSummary {
bound: true,
profile_id: Some("01J8".into()),
claim_count: 2,
..Default::default()
};
assert_eq!(by_id.describe(), "wears: 01J8 (2 attributes)");
let bare = BindingSummary {
bound: true,
claim_count: 2,
..Default::default()
};
assert_eq!(bare.describe(), "wears: an unnamed face (2 attributes)");
}
}