use serde_json::Value;
use vta_sdk::client::VtaClient;
use vta_sdk::protocols::persona::ProfileEntry;
use crate::errors::OpenVTCError;
use crate::persona::claim_types::Registry;
use crate::persona::pool::ProvenanceKind;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ProfileSummary {
pub profile_id: String,
pub name: String,
pub entry_count: usize,
pub referenced: Vec<String>,
pub credential_ref_count: usize,
pub version: u64,
pub updated_at: String,
pub retired: bool,
pub reach_only: Option<Vec<String>>,
}
impl ProfileSummary {
fn from_wire(value: &Value) -> Self {
Self {
profile_id: string_at(value, "profileId"),
name: string_at(value, "name"),
entry_count: value
.get("entries")
.and_then(Value::as_array)
.map_or(0, Vec::len),
referenced: value
.get("entries")
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|e| e.get("ref").and_then(Value::as_str))
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
credential_ref_count: value
.get("credentialRefs")
.and_then(Value::as_array)
.map_or(0, Vec::len),
version: value.get("version").and_then(Value::as_u64).unwrap_or(0),
updated_at: string_at(value, "updatedAt"),
retired: value.get("status").and_then(Value::as_str) == Some("retired"),
reach_only: value
.get("reach")
.filter(|r| r.get("kind").and_then(Value::as_str) == Some("only"))
.and_then(|r| r.get("contextIds"))
.and_then(Value::as_array)
.map(|ids| {
ids.iter()
.filter_map(|i| i.as_str().map(str::to_string))
.collect()
}),
}
}
pub(crate) fn from_wire_pub(value: &Value) -> Self {
Self::from_wire(value)
}
#[must_use]
pub fn display_name(&self) -> &str {
if self.name.trim().is_empty() {
"unnamed face"
} else {
&self.name
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ResolvedClaim {
pub claim_type: String,
pub value: Option<Value>,
pub value_type: String,
pub provenance: ProvenanceKind,
pub stale: bool,
pub attribute_id: Option<String>,
}
impl ResolvedClaim {
fn from_wire(value: &Value) -> Self {
Self {
claim_type: string_at(value, "type"),
value: value.get("value").cloned().filter(|v| !v.is_null()),
value_type: string_at(value, "valueType"),
provenance: ProvenanceKind::parse_wire(value.get("provenance")),
stale: value.get("stale").and_then(Value::as_bool).unwrap_or(false),
attribute_id: value
.get("attributeId")
.and_then(Value::as_str)
.map(str::to_string),
}
}
#[must_use]
pub fn display_value(&self, registry: &Registry) -> String {
self.value_line(registry, false)
}
#[must_use]
pub fn revealed_value(&self, registry: &Registry) -> String {
self.value_line(registry, true)
}
fn value_line(&self, registry: &Registry, reveal: bool) -> String {
if self.stale {
return "stale — can no longer be proven".to_string();
}
let shown = |text: String| {
if reveal {
text
} else {
registry.resolve(&self.claim_type).render(&text)
}
};
match &self.value {
Some(Value::String(s)) => shown(s.clone()),
Some(other) => shown(other.to_string()),
None => "(no value)".to_string(),
}
}
#[must_use]
pub fn is_masked(&self, registry: &Registry) -> bool {
!self.stale && self.value.is_some() && registry.resolve(&self.claim_type).masks_by_default()
}
}
#[derive(Clone, Debug, Default)]
pub struct ProfileDetail {
pub summary: ProfileSummary,
pub live_refs: Vec<String>,
pub other_entries: Vec<ProfileEntry>,
pub unreadable_entries: usize,
pub resolved: Vec<ResolvedClaim>,
pub disclosed_to: Option<(u64, u64)>,
}
impl ProfileDetail {
#[must_use]
pub fn is_editable_here(&self) -> bool {
self.unreadable_entries == 0
}
#[must_use]
pub fn refusal(&self) -> String {
format!(
"This face has {} entr{} this version of OpenVTC cannot read, so saving would drop \
{}. Edit it with `pnm persona profile`, or upgrade.",
self.unreadable_entries,
if self.unreadable_entries == 1 {
"y"
} else {
"ies"
},
if self.unreadable_entries == 1 {
"it"
} else {
"them"
},
)
}
}
pub async fn list(client: &VtaClient) -> Result<Vec<ProfileSummary>, OpenVTCError> {
let value = client
.persona_profile_list(None, None, false)
.await
.map_err(|e| OpenVTCError::Vta(format!("persona profile list failed: {e}")))?;
let mut profiles: Vec<ProfileSummary> = value
.get("profiles")
.and_then(Value::as_array)
.map(|rows| rows.iter().map(ProfileSummary::from_wire).collect())
.unwrap_or_default();
profiles.sort_by(|a, b| a.display_name().cmp(b.display_name()));
Ok(profiles)
}
pub async fn get(
client: &VtaClient,
profile_id: &str,
resolve: bool,
) -> Result<ProfileDetail, OpenVTCError> {
let value = client
.persona_profile_get(profile_id, resolve)
.await
.map_err(|e| OpenVTCError::Vta(format!("persona profile read failed: {e}")))?;
let profile = value.get("profile").unwrap_or(&Value::Null).clone();
let mut detail = ProfileDetail {
summary: ProfileSummary::from_wire(&profile),
resolved: value
.get("resolved")
.and_then(Value::as_array)
.map(|rows| rows.iter().map(ResolvedClaim::from_wire).collect())
.unwrap_or_default(),
disclosed_to: value.get("disclosedTo").map(|d| {
(
d.get("partyCount").and_then(Value::as_u64).unwrap_or(0),
d.get("contextCount").and_then(Value::as_u64).unwrap_or(0),
)
}),
..ProfileDetail::default()
};
for entry in profile
.get("entries")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
{
file_entry(&mut detail, entry);
}
Ok(detail)
}
fn file_entry(detail: &mut ProfileDetail, entry: &Value) {
match serde_json::from_value::<ProfileEntry>(entry.clone()) {
Ok(ProfileEntry::Ref {
attribute_id,
slot: None,
}) => detail.live_refs.push(attribute_id),
Ok(other) => detail.other_entries.push(other),
Err(_) => detail.unreadable_entries += 1,
}
}
pub async fn put(
client: &VtaClient,
profile_id: Option<&str>,
name: &str,
live_refs: &[String],
other_entries: &[ProfileEntry],
expected_version: Option<u64>,
) -> Result<String, OpenVTCError> {
let entries: Vec<ProfileEntry> = live_refs
.iter()
.map(|id| ProfileEntry::Ref {
attribute_id: id.clone(),
slot: None,
})
.chain(other_entries.iter().cloned())
.collect();
let response = client
.persona_profile_put(
name,
entries,
Vec::new(),
None,
profile_id,
expected_version,
)
.await
.map_err(|e| OpenVTCError::Vta(format!("persona profile write failed: {e}")))?;
Ok(response
.get("profileId")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| profile_id.map(str::to_string))
.unwrap_or_default())
}
pub async fn delete(
client: &VtaClient,
profile_id: &str,
unbind: bool,
) -> Result<Deleted, OpenVTCError> {
match client
.persona_profile_delete(profile_id, unbind, None)
.await
{
Ok(_) => Ok(Deleted::Done),
Err(e) => match worn_by(&e.to_string()) {
Some(n) if !unbind => Ok(Deleted::Worn(n)),
_ => Err(OpenVTCError::Vta(format!(
"persona profile delete failed: {e}"
))),
},
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Deleted {
Done,
Worn(usize),
}
fn worn_by(error: &str) -> Option<usize> {
let (_, rest) = error.split_once("delete:bound]")?;
let count = rest
.trim_start_matches(':')
.trim_start()
.split(|c: char| !c.is_ascii_digit())
.next()
.and_then(|n| n.parse().ok())
.unwrap_or(1);
Some(count)
}
fn string_at(value: &Value, key: &str) -> String {
value
.get(key)
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use crate::persona::claim_types::Registry;
#[test]
fn a_bound_refusal_reads_as_worn_with_its_count() {
let refusal = |n: &str| {
format!(
"protocol error: trust task failed [persona/profile/delete:bound]: {n} \
persona(s) are bound to this profile"
)
};
assert_eq!(super::worn_by(&refusal("1")), Some(1));
assert_eq!(super::worn_by(&refusal("3")), Some(3));
assert_eq!(
super::worn_by("trust task failed [persona/profile/delete:bound]: in use"),
Some(1)
);
assert_eq!(
super::worn_by("trust task failed [persona/profile/put:versionConflict]: 1 behind"),
None
);
}
fn reg() -> Registry {
Registry::vendored()
}
use super::*;
fn profile_wire(entries: Value) -> Value {
serde_json::json!({
"profile": {
"profileId": "01J9",
"name": "Work",
"entries": entries,
"version": 2,
"updatedAt": "2026-09-06T00:00:00Z",
}
})
}
#[test]
fn entries_split_into_editable_refs_and_preserved_forms() {
let wire = profile_wire(serde_json::json!([
{ "ref": "01A" },
{ "ref": "01B", "pinVersion": 3 },
{ "ref": "01C", "override": { "value": "other" } },
{ "inline": {
"type": "nickname",
"valueType": "string",
"value": "Ace",
"provenance": { "kind": "selfAsserted" }
}},
]));
let profile = wire.get("profile").unwrap().clone();
let mut detail = ProfileDetail {
summary: ProfileSummary::from_wire(&profile),
..ProfileDetail::default()
};
for entry in profile.get("entries").unwrap().as_array().unwrap() {
file_entry(&mut detail, entry);
}
assert_eq!(detail.live_refs, vec!["01A".to_string()]);
assert_eq!(
detail.other_entries.len(),
3,
"a pin, an override and an inline value are not the editor's to rewrite"
);
assert!(detail.is_editable_here());
assert_eq!(detail.summary.entry_count, 4);
}
#[test]
fn an_unreadable_entry_makes_the_profile_read_only() {
let detail = ProfileDetail {
unreadable_entries: 2,
..ProfileDetail::default()
};
assert!(!detail.is_editable_here());
assert!(detail.refusal().contains("2 entries"));
assert!(detail.refusal().contains("pnm"));
}
#[test]
fn a_resolved_claim_without_an_attribute_id_is_inline() {
let claim = ResolvedClaim::from_wire(&serde_json::json!({
"type": "name.display",
"value": "Ace",
"valueType": "string",
"provenance": { "kind": "selfAsserted" },
"stale": false,
}));
assert!(claim.attribute_id.is_none());
assert_eq!(claim.display_value(®()), "Ace");
}
#[test]
fn a_resolved_claim_with_no_value_says_so() {
let claim = ResolvedClaim::from_wire(&serde_json::json!({
"type": "email.work",
"value": Value::Null,
"stale": true,
}));
assert!(
claim
.display_value(®())
.contains("can no longer be proven")
);
}
#[test]
fn a_masked_claim_is_only_whole_when_it_is_asked_for() {
let claim = ResolvedClaim::from_wire(&serde_json::json!({
"type": "phone.mobile",
"value": "+61400123456",
"valueType": "string",
"provenance": { "kind": "selfAsserted" },
}));
assert_eq!(claim.display_value(®()), "••••••••••56");
assert_eq!(claim.revealed_value(®()), "+61400123456");
}
#[test]
fn a_stale_claim_still_says_it_is_stale_when_revealed() {
let claim = ResolvedClaim::from_wire(&serde_json::json!({
"type": "phone.mobile",
"value": "+61400123456",
"stale": true,
}));
assert!(
claim
.revealed_value(®())
.contains("can no longer be proven")
);
}
#[test]
fn a_masked_claim_is_masked_on_a_face_too() {
let claim = ResolvedClaim::from_wire(&serde_json::json!({
"type": "phone.mobile",
"value": "+61400123456",
"valueType": "string",
"provenance": { "kind": "selfAsserted" },
}));
assert!(claim.is_masked(®()));
assert_eq!(claim.display_value(®()), "••••••••••56");
let normal = ResolvedClaim::from_wire(&serde_json::json!({
"type": "name.given",
"value": "Alice",
"valueType": "string",
}));
assert!(!normal.is_masked(®()));
assert_eq!(normal.display_value(®()), "Alice");
}
#[test]
fn a_masked_claim_is_not_an_absent_one() {
let absent = ResolvedClaim::from_wire(&serde_json::json!({
"type": "phone.mobile",
"value": Value::Null,
}));
assert!(!absent.is_masked(®()));
assert_eq!(absent.display_value(®()), "(no value)");
}
#[test]
fn a_saved_profile_puts_the_ticked_entries_first() {
let refs = ["01A".to_string(), "01B".to_string()];
let other = [ProfileEntry::Inline {
inline: serde_json::from_value(serde_json::json!({
"type": "nickname",
"valueType": "string",
"value": "Ace",
"provenance": { "kind": "selfAsserted" }
}))
.unwrap(),
slot: None,
}];
let entries: Vec<ProfileEntry> = refs
.iter()
.map(|id| ProfileEntry::Ref {
attribute_id: id.clone(),
slot: None,
})
.chain(other.iter().cloned())
.collect();
assert_eq!(entries.len(), 3);
assert!(
matches!(&entries[0], ProfileEntry::Ref { attribute_id, .. } if attribute_id == "01A")
);
assert!(matches!(entries[2], ProfileEntry::Inline { .. }));
}
#[test]
fn a_slotted_reference_is_carried_through_not_rebuilt() {
let mut detail = ProfileDetail::default();
file_entry(
&mut detail,
&serde_json::json!({ "ref": "01N", "slot": "displayName" }),
);
file_entry(&mut detail, &serde_json::json!({ "ref": "01A" }));
assert_eq!(detail.live_refs, vec!["01A".to_string()]);
assert!(matches!(
detail.other_entries.as_slice(),
[ProfileEntry::Ref { attribute_id, slot: Some(s) }]
if attribute_id == "01N" && s == "displayName"
));
}
}