use serde::{Deserialize, Serialize};
use tracing::debug;
use vta_sdk::client::VtaClient;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextContents {
pub persona_dids: Vec<String>,
pub sub_context_count: usize,
}
impl ContextContents {
#[must_use]
pub fn is_empty(&self) -> bool {
self.persona_dids.is_empty() && self.sub_context_count == 0
}
#[must_use]
pub fn summary(&self) -> String {
fn plural(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
let mut parts = Vec::new();
if !self.persona_dids.is_empty() {
parts.push(plural(self.persona_dids.len(), "persona", "personas"));
}
if self.sub_context_count > 0 {
parts.push(plural(
self.sub_context_count,
"sub-context",
"sub-contexts",
));
}
match parts.len() {
0 => "nothing".to_string(),
1 => parts.remove(0),
_ => {
let last = parts.pop().expect("len >= 2");
format!("{} and {last}", parts.join(", "))
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProbeOutcome {
Empty,
Occupied(Box<ContextContents>),
Unknown(String),
}
impl ProbeOutcome {
#[must_use]
pub fn contents(&self) -> Option<&ContextContents> {
match self {
ProbeOutcome::Occupied(c) => Some(c),
_ => None,
}
}
#[must_use]
pub fn needs_confirmation(&self) -> bool {
matches!(self, ProbeOutcome::Occupied(_))
}
}
pub async fn probe(client: &VtaClient, context_id: &str) -> ProbeOutcome {
let dids = match client.list_dids_webvh(Some(context_id), None).await {
Ok(body) => body.dids,
Err(e) => {
return ProbeOutcome::Unknown(format!("could not list DIDs in {context_id}: {e}"));
}
};
let sub_context_count = match client.list_contexts().await {
Ok(resp) => resp
.contexts
.iter()
.filter(|c| is_sub_context_of(&c.id, context_id))
.count(),
Err(e) => {
debug!("contexts not enumerable during probe: {e}");
0
}
};
let contents = ContextContents {
persona_dids: dids.into_iter().map(|d| d.did).collect(),
sub_context_count,
};
if contents.is_empty() {
ProbeOutcome::Empty
} else {
ProbeOutcome::Occupied(Box::new(contents))
}
}
fn is_sub_context_of(candidate: &str, parent: &str) -> bool {
candidate
.strip_prefix(parent)
.is_some_and(|rest| rest.starts_with('/') && rest.len() > 1)
}
#[cfg(test)]
mod tests {
use super::*;
fn contents(personas: usize, subs: usize) -> ContextContents {
ContextContents {
persona_dids: (0..personas)
.map(|i| format!("did:webvh:Qm:example.com:p{i}"))
.collect(),
sub_context_count: subs,
}
}
#[test]
fn an_empty_context_is_empty() {
assert!(ContextContents::default().is_empty());
}
#[test]
fn any_single_signal_makes_it_occupied() {
assert!(!contents(1, 0).is_empty());
assert!(!contents(0, 1).is_empty());
}
#[test]
fn the_summary_counts_rather_than_gesturing() {
assert_eq!(contents(3, 2).summary(), "3 personas and 2 sub-contexts");
}
#[test]
fn the_summary_is_singular_when_it_should_be() {
assert_eq!(contents(1, 1).summary(), "1 persona and 1 sub-context");
}
#[test]
fn the_summary_omits_what_is_absent() {
assert_eq!(contents(2, 0).summary(), "2 personas");
assert_eq!(contents(0, 3).summary(), "3 sub-contexts");
}
#[test]
fn unknown_is_not_empty_and_does_not_gate_setup() {
let unknown = ProbeOutcome::Unknown("refused".to_string());
assert!(!unknown.needs_confirmation());
assert!(unknown.contents().is_none());
assert_ne!(unknown, ProbeOutcome::Empty);
}
#[test]
fn only_an_occupied_context_stops_setup() {
assert!(!ProbeOutcome::Empty.needs_confirmation());
assert!(ProbeOutcome::Occupied(Box::new(contents(1, 0))).needs_confirmation());
}
#[test]
fn sub_context_matching_requires_the_separator() {
assert!(is_sub_context_of("openvtc/acme", "openvtc"));
assert!(!is_sub_context_of("openvtc", "openvtc"));
assert!(!is_sub_context_of("openvtc-2", "openvtc"));
assert!(!is_sub_context_of("openvtc/", "openvtc"));
assert!(!is_sub_context_of("other/acme", "openvtc"));
}
#[test]
fn a_context_with_only_personas_is_still_occupied() {
assert!(!contents(1, 0).is_empty());
assert_eq!(contents(1, 0).summary(), "1 persona");
}
}