use serde::Deserialize;
pub mod category {
pub const CREDENTIAL_EXCHANGE: &str = "CredentialRecord";
pub const PROOF_EXCHANGE: &str = "ProofRecord";
pub const OUT_OF_BAND: &str = "OutOfBandRecord";
pub const DIDCOMM_MESSAGE: &str = "DidCommMessageRecord";
pub const BASIC_MESSAGE: &str = "BasicMessageRecord";
pub const QUESTION_ANSWER: &str = "QuestionAnswerRecord";
pub const TENANT: &str = "TenantRecord";
pub const CONNECTION: &str = "ConnectionRecord";
pub const DID: &str = "DidRecord";
pub const PURGEABLE: [&str; 6] = [
CREDENTIAL_EXCHANGE,
PROOF_EXCHANGE,
OUT_OF_BAND,
DIDCOMM_MESSAGE,
BASIC_MESSAGE,
QUESTION_ANSWER,
];
pub const PROTECTED: [&str; 2] = [CONNECTION, DID];
}
pub mod tag {
pub const STATE: &str = "state";
pub const ASSOCIATED_RECORD_ID: &str = "associatedRecordId";
}
pub const TERMINAL_STATES: [&str; 3] = ["done", "abandoned", "declined"];
pub const CREDENTIAL_STATES: [&str; 11] = [
"proposal-sent",
"proposal-received",
"offer-sent",
"offer-received",
"declined",
"request-sent",
"request-received",
"credential-issued",
"credential-received",
"done",
"abandoned",
];
pub const PROOF_STATES: [&str; 9] = [
"proposal-sent",
"proposal-received",
"request-sent",
"request-received",
"presentation-sent",
"presentation-received",
"declined",
"abandoned",
"done",
];
#[allow(dead_code)]
pub const OOB_STATE_INITIAL: &str = "initial";
pub const OOB_STATE_AWAIT_RESPONSE: &str = "await-response";
#[allow(dead_code)]
pub const OOB_STATE_PREPARE_RESPONSE: &str = "prepare-response";
pub const OOB_STATE_DONE: &str = "done";
fn non_terminal(all: &[&'static str]) -> Vec<&'static str> {
all.iter()
.copied()
.filter(|s| !TERMINAL_STATES.contains(s))
.collect()
}
pub fn credential_incomplete_states() -> Vec<&'static str> {
non_terminal(&CREDENTIAL_STATES)
}
pub fn proof_incomplete_states() -> Vec<&'static str> {
non_terminal(&PROOF_STATES)
}
pub fn tenant_profile(tenant_id: &str) -> String {
debug_assert!(
!tenant_id.starts_with("tenant-"),
"tenant id already starts with 'tenant-' — that's a context correlation id"
);
format!("tenant-{tenant_id}")
}
#[derive(Debug, Default, Deserialize)]
pub struct RecordValue {
#[serde(rename = "updatedAt")]
pub updated_at: Option<String>,
#[serde(rename = "createdAt")]
pub created_at: Option<String>,
#[serde(rename = "associatedRecordId")]
pub associated_record_id: Option<String>,
pub reusable: Option<bool>,
}
pub fn parse_date(raw: &str) -> Option<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(raw)
.ok()
.map(|d| d.with_timezone(&chrono::Utc))
}
impl RecordValue {
pub fn older_than(&self, cutoff: chrono::DateTime<chrono::Utc>) -> bool {
match self.updated_at.as_deref().and_then(parse_date) {
Some(updated) => updated <= cutoff,
None => false,
}
}
pub fn predates(&self, run_start: chrono::DateTime<chrono::Utc>) -> bool {
match self.created_at.as_deref() {
None => true,
Some(raw) => match parse_date(raw) {
Some(created) => created < run_start,
None => false,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn incomplete_states_exclude_terminal() {
for s in credential_incomplete_states() {
assert!(!TERMINAL_STATES.contains(&s), "{s} is terminal");
}
for s in proof_incomplete_states() {
assert!(!TERMINAL_STATES.contains(&s), "{s} is terminal");
}
assert_eq!(credential_incomplete_states().len(), 8);
assert_eq!(proof_incomplete_states().len(), 6);
}
#[test]
fn terminal_states_are_real_states() {
for s in TERMINAL_STATES {
assert!(CREDENTIAL_STATES.contains(&s), "{s} not a credential state");
assert!(PROOF_STATES.contains(&s), "{s} not a proof state");
}
}
#[test]
fn profile_naming() {
assert_eq!(tenant_profile("abc-123"), "tenant-abc-123");
}
#[test]
fn dates_parse_the_way_credo_writes_them() {
assert!(parse_date("2026-09-14T22:44:34.123Z").is_some());
assert!(parse_date("2026-09-14T22:44:34Z").is_some());
assert!(parse_date("garbage").is_none());
}
#[test]
fn malformed_dates_fail_safe() {
let cutoff = chrono::Utc::now();
let r = RecordValue { updated_at: Some("nope".into()), ..Default::default() };
assert!(!r.older_than(cutoff));
let r = RecordValue::default();
assert!(!r.older_than(cutoff));
let r = RecordValue { created_at: Some("nope".into()), ..Default::default() };
assert!(!r.predates(cutoff));
let r = RecordValue::default();
assert!(r.predates(cutoff));
}
#[test]
fn partial_deserialization_ignores_unknown_fields() {
let v: RecordValue = serde_json::from_str(
r#"{"updatedAt":"2026-01-01T00:00:00.000Z","somethingNew":{"a":1},"reusable":true}"#,
)
.unwrap();
assert!(v.reusable.unwrap());
assert!(v.updated_at.is_some());
}
}