use crate::config::account::PersonaId;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DegradedReason {
MissingKeyInfo {
key_id: String,
},
KeyUnavailable {
detail: String,
},
DocumentUnresolvable {
detail: String,
},
MessagingUnavailable {
detail: String,
},
}
impl DegradedReason {
#[must_use]
pub fn summary(&self) -> String {
match self {
DegradedReason::MissingKeyInfo { key_id } => {
format!("no key material recorded for {key_id}")
}
DegradedReason::KeyUnavailable { detail } => {
format!("its keys could not be retrieved: {detail}")
}
DegradedReason::DocumentUnresolvable { detail } => {
format!("its DID document could not be resolved: {detail}")
}
DegradedReason::MessagingUnavailable { detail } => {
format!("its messaging profile could not be built: {detail}")
}
}
}
#[must_use]
pub fn is_incomplete_write(&self) -> bool {
matches!(self, DegradedReason::MissingKeyInfo { .. })
}
#[must_use]
pub fn is_transient(&self) -> bool {
matches!(
self,
DegradedReason::KeyUnavailable { .. } | DegradedReason::DocumentUnresolvable { .. }
)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DegradedPersona {
pub persona_id: PersonaId,
pub did: String,
pub label: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub reason: DegradedReason,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoadIntegrity {
pub degraded_personas: Vec<DegradedPersona>,
pub stranded_memberships: Vec<StrandedMembership>,
pub orphaned_key_ids: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StrandedMembership {
pub vtc_did: String,
pub persona_id: PersonaId,
pub label: Option<String>,
}
impl LoadIntegrity {
#[must_use]
pub fn is_clean(&self) -> bool {
self.degraded_personas.is_empty()
&& self.stranded_memberships.is_empty()
&& self.orphaned_key_ids.is_empty()
}
#[must_use]
pub fn has_incomplete_write(&self) -> bool {
!self.orphaned_key_ids.is_empty()
|| self
.degraded_personas
.iter()
.any(|p| p.reason.is_incomplete_write())
}
#[must_use]
pub fn is_all_transient(&self) -> bool {
!self.degraded_personas.is_empty()
&& self.orphaned_key_ids.is_empty()
&& self
.degraded_personas
.iter()
.all(|p| p.reason.is_transient())
}
#[must_use]
pub fn headline(&self) -> String {
let personas = self.degraded_personas.len();
let memberships = self.stranded_memberships.len();
let mut parts = Vec::new();
if personas > 0 {
parts.push(format!(
"{personas} persona{} unavailable",
if personas == 1 { "" } else { "s" }
));
}
if memberships > 0 {
parts.push(format!(
"{memberships} membership{} inactive",
if memberships == 1 { "" } else { "s" }
));
}
if !self.orphaned_key_ids.is_empty() {
parts.push(format!(
"{} orphaned key record{}",
self.orphaned_key_ids.len(),
if self.orphaned_key_ids.len() == 1 {
""
} else {
"s"
}
));
}
if parts.is_empty() {
"Configuration loaded".to_string()
} else {
format!("Loaded with problems: {}", parts.join(", "))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn persona(reason: DegradedReason) -> DegradedPersona {
DegradedPersona {
persona_id: PersonaId(Uuid::nil()),
did: "did:webvh:Qm:example.com:alice".to_string(),
label: Some("Alice".to_string()),
created_at: chrono::Utc::now(),
reason,
}
}
#[test]
fn a_healthy_load_is_clean() {
assert!(LoadIntegrity::default().is_clean());
assert_eq!(LoadIntegrity::default().headline(), "Configuration loaded");
}
#[test]
fn missing_key_info_reads_as_an_interrupted_write() {
let integrity = LoadIntegrity {
degraded_personas: vec![persona(DegradedReason::MissingKeyInfo {
key_id: "did:webvh:Qm:example.com:alice#key-1".to_string(),
})],
..Default::default()
};
assert!(!integrity.is_clean());
assert!(integrity.has_incomplete_write());
assert!(!integrity.is_all_transient());
assert!(integrity.headline().contains("1 persona unavailable"));
}
#[test]
fn an_unreachable_vta_is_transient_not_loss() {
let integrity = LoadIntegrity {
degraded_personas: vec![persona(DegradedReason::KeyUnavailable {
detail: "connection refused".to_string(),
})],
..Default::default()
};
assert!(integrity.is_all_transient());
assert!(!integrity.has_incomplete_write());
}
#[test]
fn orphaned_keys_alone_still_signal_an_interrupted_write() {
let integrity = LoadIntegrity {
orphaned_key_ids: vec!["did:webvh:Qm:example.com:ghost#key-1".to_string()],
..Default::default()
};
assert!(!integrity.is_clean());
assert!(integrity.has_incomplete_write());
assert!(!integrity.is_all_transient());
assert!(integrity.headline().contains("1 orphaned key record"));
}
#[test]
fn headline_counts_every_category() {
let integrity = LoadIntegrity {
degraded_personas: vec![
persona(DegradedReason::MissingKeyInfo {
key_id: "a".to_string(),
}),
persona(DegradedReason::KeyUnavailable {
detail: "b".to_string(),
}),
],
stranded_memberships: vec![StrandedMembership {
vtc_did: "did:webvh:Qm:vtc.example.com:community".to_string(),
persona_id: PersonaId(Uuid::nil()),
label: Some("Example".to_string()),
}],
orphaned_key_ids: vec!["x".to_string(), "y".to_string()],
};
let headline = integrity.headline();
assert!(headline.contains("2 personas unavailable"), "{headline}");
assert!(headline.contains("1 membership inactive"), "{headline}");
assert!(headline.contains("2 orphaned key records"), "{headline}");
}
}