use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vta_sdk::protocols::vetting::vetters::list::v0_1 as list;
use vta_sdk::protocols::vetting::vetters::profile::v0_1::{
self as profile, CalendarDate, CountryCode, LanguageTag, PlaceName, VetterAcceptsDocumentation,
VetterEvent, VetterLocation, VetterMethods, VettingDocumentation,
};
use vta_sdk::protocols::vetting::{CheckShape, ShapeError, VettingMethod};
use super::book::{VetterPolicy, VettingBook};
use crate::config::account::PersonaId;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DraftError {
#[error("{0} must be a date written YYYY-MM-DD, like 2026-10-05")]
Date(&'static str),
#[error("Country: a region or city needs a country too — a two-letter code such as CZ")]
LocationWithoutCountry,
#[error("I vet: choose at least one way — in person, on video, or people you already know")]
NoMethods,
#[error("Country: write it as a two-letter code, like US or CZ — not the country's name")]
Country,
#[error("{field}: the community would refuse this: {problem}")]
Field {
field: &'static str,
problem: ShapeError,
},
#[error("event {}: {source}", .index + 1)]
Event {
index: usize,
source: Box<DraftError>,
},
#[error("the community would refuse this: {0}")]
Shape(ShapeError),
}
impl DraftError {
#[must_use]
pub fn row(&self) -> Option<&'static str> {
match self {
DraftError::Date(field) | DraftError::Field { field, .. } => Some(field),
DraftError::LocationWithoutCountry | DraftError::Country => Some("Country"),
DraftError::NoMethods => Some("I vet"),
DraftError::Event { .. } | DraftError::Shape(_) => None,
}
}
#[must_use]
pub fn event(&self) -> Option<usize> {
match self {
DraftError::Event { index, .. } => Some(*index),
_ => None,
}
}
}
impl From<ShapeError> for DraftError {
fn from(e: ShapeError) -> Self {
DraftError::Shape(e)
}
}
fn split_list(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for item in text.split(|c: char| c == ',' || c.is_whitespace()) {
let item = item.trim();
if !item.is_empty() && !out.iter().any(|o| o == item) {
out.push(item.to_string());
}
}
out
}
fn optional(text: &str) -> Option<String> {
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}
fn date(field: &'static str, text: &str) -> Result<NaiveDate, DraftError> {
NaiveDate::parse_from_str(text.trim(), "%Y-%m-%d").map_err(|_| DraftError::Date(field))
}
fn refused<E: std::fmt::Display>(field: &'static str) -> impl Fn(E) -> DraftError {
move |e| DraftError::Field {
field,
problem: ShapeError::Schema(e.to_string()),
}
}
fn refused_payload(e: impl std::fmt::Display) -> DraftError {
DraftError::Shape(ShapeError::Schema(e.to_string()))
}
fn place(field: &'static str, text: &str) -> Result<Option<PlaceName>, DraftError> {
optional(text)
.map(|t| PlaceName::try_from(t).map_err(refused(field)))
.transpose()
}
fn location(country: &str, region: &str, city: &str) -> Result<Option<VetterLocation>, DraftError> {
match optional(country) {
Some(country) => {
let country =
CountryCode::try_from(country.to_uppercase()).map_err(|_| DraftError::Country)?;
let built = VetterLocation::try_from(
VetterLocation::builder()
.country(country)
.region(place("Region", region)?)
.city(place("City", city)?),
)
.map_err(refused_payload)?;
Ok(Some(built))
}
None if optional(region).is_some() || optional(city).is_some() => {
Err(DraftError::LocationWithoutCountry)
}
None => Ok(None),
}
}
fn check_event(event: &VetterEvent) -> Result<(), DraftError> {
let passport =
VettingDocumentation::try_from(vta_sdk::protocols::vetting::documentation::PASSPORT)
.map_err(refused_payload)?;
let probe = profile::Payload::try_from(
profile::Payload::builder()
.listed(false)
.languages(Vec::new())
.methods(VetterMethods(vec![profile::VettingMethod::InPerson]))
.accepts_documentation(VetterAcceptsDocumentation(vec![passport]))
.events(vec![event.clone()]),
)
.map_err(refused_payload)?;
probe.check_shape().map_err(DraftError::Shape)
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EventDraft {
pub name: String,
pub start_date: String,
pub end_date: String,
pub country: String,
pub region: String,
pub city: String,
pub url: String,
}
impl EventDraft {
#[must_use]
pub fn from_event(event: &VetterEvent) -> Self {
let place = event.location.as_ref();
Self {
name: event.name.as_str().to_string(),
start_date: event.start_date.0.format("%Y-%m-%d").to_string(),
end_date: event.end_date.0.format("%Y-%m-%d").to_string(),
country: place
.map(|l| l.country.as_str().to_string())
.unwrap_or_default(),
region: place
.and_then(|l| l.region.as_ref())
.map(|r| r.as_str().to_string())
.unwrap_or_default(),
city: place
.and_then(|l| l.city.as_ref())
.map(|c| c.as_str().to_string())
.unwrap_or_default(),
url: event.url.clone().unwrap_or_default(),
}
}
pub fn to_event(&self) -> Result<VetterEvent, DraftError> {
let name = profile::VetterEventName::try_from(self.name.trim()).map_err(refused("Name"))?;
let event = VetterEvent::try_from(
VetterEvent::builder()
.name(name)
.start_date(CalendarDate(date("First day", &self.start_date)?))
.end_date(CalendarDate(date("Last day", &self.end_date)?))
.location(location(&self.country, &self.region, &self.city)?)
.url(optional(&self.url)),
)
.map_err(refused_payload)?;
check_event(&event)?;
Ok(event)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProfileDraft {
pub listed: bool,
pub display_name: String,
pub languages: String,
pub country: String,
pub region: String,
pub city: String,
pub methods: Vec<VettingMethod>,
pub accepts_documentation: String,
pub availability: String,
pub contact_hint: String,
pub events: Vec<EventDraft>,
}
const METHOD_ORDER: [VettingMethod; 3] = [
VettingMethod::InPerson,
VettingMethod::Video,
VettingMethod::PriorAcquaintance,
];
impl ProfileDraft {
#[must_use]
pub fn new(policy: &VetterPolicy) -> Self {
Self {
listed: false,
display_name: String::new(),
languages: String::new(),
country: String::new(),
region: String::new(),
city: String::new(),
methods: vec![VettingMethod::InPerson, VettingMethod::Video],
accepts_documentation: policy.accepts_documentation.join(", "),
availability: String::new(),
contact_hint: String::new(),
events: Vec::new(),
}
}
#[must_use]
pub fn from_body(body: &profile::Payload) -> Self {
let place = body.location.as_ref();
let join = |items: Vec<String>| items.join(", ");
Self {
listed: body.listed,
display_name: body
.display_name
.as_ref()
.map(|n| n.as_str().to_string())
.unwrap_or_default(),
languages: join(
body.languages
.iter()
.map(|l| l.as_str().to_string())
.collect(),
),
country: place
.map(|l| l.country.as_str().to_string())
.unwrap_or_default(),
region: place
.and_then(|l| l.region.as_ref())
.map(|r| r.as_str().to_string())
.unwrap_or_default(),
city: place
.and_then(|l| l.city.as_ref())
.map(|c| c.as_str().to_string())
.unwrap_or_default(),
methods: METHOD_ORDER
.into_iter()
.filter(|m| {
body.methods
.0
.iter()
.any(|published| published.to_string() == m.to_string())
})
.collect(),
accepts_documentation: join(
body.accepts_documentation
.0
.iter()
.map(|d| d.as_str().to_string())
.collect(),
),
availability: body
.availability
.as_ref()
.map(|a| a.as_str().to_string())
.unwrap_or_default(),
contact_hint: body
.contact_hint
.as_ref()
.map(|c| c.as_str().to_string())
.unwrap_or_default(),
events: body.events.iter().map(EventDraft::from_event).collect(),
}
}
pub fn toggle_method(&mut self, method: VettingMethod) {
let mut on: Vec<VettingMethod> = self.methods.clone();
if on.contains(&method) {
on.retain(|m| *m != method);
} else {
on.push(method);
}
self.methods = METHOD_ORDER
.into_iter()
.filter(|m| on.contains(m))
.collect();
}
pub fn to_body(&self) -> Result<profile::Payload, DraftError> {
if self.methods.is_empty() {
return Err(DraftError::NoMethods);
}
let languages = split_list(&self.languages)
.into_iter()
.map(|l| LanguageTag::try_from(l).map_err(refused("Languages")))
.collect::<Result<Vec<_>, _>>()?;
let documentation = split_list(&self.accepts_documentation)
.into_iter()
.map(|d| VettingDocumentation::try_from(d).map_err(refused("Documents I accept")))
.collect::<Result<Vec<_>, _>>()?;
let methods = self
.methods
.iter()
.map(|m| super::same_token::<_, profile::VettingMethod>(m).map_err(refused("I vet")))
.collect::<Result<Vec<_>, _>>()?;
let display_name = optional(&self.display_name)
.map(|n| profile::VetterDisplayName::try_from(n).map_err(refused("Display name")))
.transpose()?;
let availability = optional(&self.availability)
.map(|a| profile::VetterAvailability::try_from(a).map_err(refused("Availability")))
.transpose()?;
let contact_hint = optional(&self.contact_hint)
.map(|c| {
profile::VetterContactHint::try_from(c).map_err(refused("How to get a ticket"))
})
.transpose()?;
let body = profile::Payload::try_from(
profile::Payload::builder()
.listed(self.listed)
.display_name(display_name)
.languages(languages)
.location(location(&self.country, &self.region, &self.city)?)
.methods(VetterMethods(methods))
.accepts_documentation(VetterAcceptsDocumentation(documentation))
.availability(availability)
.contact_hint(contact_hint)
.events(
self.events
.iter()
.enumerate()
.map(|(index, event)| {
event.to_event().map_err(|source| DraftError::Event {
index,
source: Box::new(source),
})
})
.collect::<Result<Vec<_>, _>>()?,
),
)
.map_err(refused_payload)?;
body.check_shape()?;
Ok(body)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DirectoryFilter {
pub language: String,
pub country: String,
pub region: String,
pub city: String,
pub method: Option<VettingMethod>,
pub event_from: String,
pub event_to: String,
pub event_name: String,
}
impl DirectoryFilter {
pub fn to_body(&self, cursor: Option<String>) -> Result<list::Payload, DraftError> {
let list_place =
|field: &'static str, text: &str| -> Result<Option<list::PlaceName>, DraftError> {
optional(text)
.map(|t| list::PlaceName::try_from(t).map_err(refused(field)))
.transpose()
};
let language = optional(&self.language)
.map(|l| list::LanguageTag::try_from(l).map_err(refused("Language")))
.transpose()?;
let country = optional(&self.country)
.map(|c| list::CountryCode::try_from(c.to_uppercase()))
.transpose()
.map_err(|_| DraftError::Country)?;
let event_name = optional(&self.event_name)
.map(|n| list::PayloadEventName::try_from(n).map_err(refused("Event name")))
.transpose()?;
let cursor = cursor
.map(|c| list::PayloadCursor::try_from(c).map_err(refused_payload))
.transpose()?;
let method = self
.method
.map(|m| super::same_token::<_, list::VettingMethod>(&m).map_err(refused("Method")))
.transpose()?;
let day =
|what: &'static str, text: &str| -> Result<Option<list::CalendarDate>, DraftError> {
optional(text)
.map(|d| date(what, &d).map(list::CalendarDate))
.transpose()
};
let body = list::Payload::try_from(
list::Payload::builder()
.language(language)
.country(country)
.region(list_place("Region", &self.region)?)
.city(list_place("City", &self.city)?)
.method(method)
.event_from(day("Events from", &self.event_from)?)
.event_to(day("Events until", &self.event_to)?)
.event_name(event_name)
.cursor(cursor),
)
.map_err(refused_payload)?;
body.check_shape()?;
Ok(body)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum ProfileState {
Sent {
sent_at: DateTime<Utc>,
},
Stored {
listed: bool,
updated_at: DateTime<Utc>,
},
Refused {
code: String,
at: DateTime<Utc>,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VetterProfileRecord {
pub community: String,
pub persona: PersonaId,
pub profile: Value,
pub state: ProfileState,
}
impl VetterProfileRecord {
#[must_use]
pub fn draft(&self, policy: &VetterPolicy) -> ProfileDraft {
serde_json::from_value::<profile::Payload>(self.profile.clone()).map_or_else(
|_| ProfileDraft::new(policy),
|b| ProfileDraft::from_body(&b),
)
}
}
impl VettingBook {
#[must_use]
pub fn vetter_profile(
&self,
community: &str,
persona: PersonaId,
) -> Option<&VetterProfileRecord> {
self.vetter_profiles
.iter()
.find(|r| r.community == community && r.persona == persona)
}
fn vetter_profile_mut(
&mut self,
community: &str,
persona: PersonaId,
) -> Option<&mut VetterProfileRecord> {
self.vetter_profiles
.iter_mut()
.find(|r| r.community == community && r.persona == persona)
}
pub fn record_profile_sent(
&mut self,
community: &str,
persona: PersonaId,
body: &profile::Payload,
now: DateTime<Utc>,
) -> Option<VetterProfileRecord> {
let record = VetterProfileRecord {
community: community.to_string(),
persona,
profile: serde_json::to_value(body).unwrap_or(Value::Null),
state: ProfileState::Sent { sent_at: now },
};
match self.vetter_profile_mut(community, persona) {
Some(existing) => Some(std::mem::replace(existing, record)),
None => {
self.vetter_profiles.push(record);
None
}
}
}
pub fn restore_profile(
&mut self,
community: &str,
persona: PersonaId,
previous: Option<VetterProfileRecord>,
) {
self.vetter_profiles
.retain(|r| !(r.community == community && r.persona == persona));
self.vetter_profiles.extend(previous);
}
pub fn on_profile_stored(
&mut self,
community: &str,
persona: PersonaId,
response: &profile::Response,
) -> bool {
match self.vetter_profile_mut(community, persona) {
Some(record) => {
record.state = ProfileState::Stored {
listed: response.listed,
updated_at: response.updated_at,
};
true
}
None => false,
}
}
pub fn on_profile_refused(
&mut self,
community: &str,
persona: PersonaId,
code: &str,
now: DateTime<Utc>,
) -> bool {
match self.vetter_profile_mut(community, persona) {
Some(record) => {
record.state = ProfileState::Refused {
code: code.to_string(),
at: now,
};
true
}
None => false,
}
}
}
#[must_use]
pub fn location_line(location: &VetterLocation) -> String {
place_line(
location.city.as_ref().map(|c| c.as_str()),
location.region.as_ref().map(|r| r.as_str()),
location.country.as_str(),
)
}
#[must_use]
pub fn listed_location_line(location: &list::VetterLocation) -> String {
place_line(
location.city.as_ref().map(|c| c.as_str()),
location.region.as_ref().map(|r| r.as_str()),
location.country.as_str(),
)
}
fn place_line(city: Option<&str>, region: Option<&str>, country: &str) -> String {
[city, region, Some(country)]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(", ")
}
#[must_use]
pub fn event_line(event: &VetterEvent) -> String {
event_line_parts(
event.name.as_str(),
event.start_date.0,
event.end_date.0,
event.location.as_ref().map(location_line),
)
}
#[must_use]
pub fn listed_event_line(event: &list::VetterEvent) -> String {
event_line_parts(
event.name.as_str(),
event.start_date.0,
event.end_date.0,
event.location.as_ref().map(listed_location_line),
)
}
fn event_line_parts(name: &str, start: NaiveDate, end: NaiveDate, place: Option<String>) -> String {
let dates = if start == end {
start.format("%Y-%m-%d").to_string()
} else {
format!("{} to {}", start.format("%Y-%m-%d"), end.format("%Y-%m-%d"))
};
match place {
Some(place) => format!("{name} — {dates}, {place}"),
None => format!("{name} — {dates}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use vta_sdk::protocols::vetting::has_event_filter;
fn event() -> EventDraft {
EventDraft {
name: "Linux Plumbers".into(),
start_date: "2026-10-05".into(),
end_date: "2026-10-07".into(),
country: "cz".into(),
region: String::new(),
city: "Prague".into(),
url: "https://lpc.events".into(),
}
}
#[test]
fn a_first_profile_is_unlisted_and_accepts_what_the_vetter_already_accepts() {
let policy = VetterPolicy::default();
let draft = ProfileDraft::new(&policy);
assert!(!draft.listed, "being listed is opt-in");
let body = draft.to_body().unwrap();
let accepted: Vec<String> = body
.accepts_documentation
.0
.iter()
.map(|d| d.as_str().to_string())
.collect();
assert_eq!(accepted, policy.accepts_documentation);
assert!(body.location.is_none());
}
#[test]
fn typed_lists_and_places_become_the_body() {
let mut draft = ProfileDraft::new(&VetterPolicy::default());
draft.listed = true;
draft.display_name = " Carol ".into();
draft.languages = "en, de-AT en".into();
draft.country = "de".into();
draft.city = "Berlin".into();
draft.events = vec![event()];
let body = draft.to_body().unwrap();
assert_eq!(
body.display_name.as_ref().map(|n| n.as_str()),
Some("Carol")
);
assert_eq!(
body.languages
.iter()
.map(|l| l.as_str())
.collect::<Vec<_>>(),
vec!["en", "de-AT"]
);
assert_eq!(body.location.as_ref().unwrap().country.as_str(), "DE");
assert_eq!(
body.events[0].location.as_ref().unwrap().country.as_str(),
"CZ"
);
assert_eq!(
serde_json::to_value(ProfileDraft::from_body(&body).to_body().unwrap()).unwrap(),
serde_json::to_value(&body).unwrap()
);
}
#[test]
fn event_dates_are_checked_before_anything_is_sent() {
let mut bad = event();
bad.start_date = "5 Oct".into();
assert!(matches!(bad.to_event(), Err(DraftError::Date("First day"))));
let mut backwards = event();
backwards.end_date = "2026-10-01".into();
assert!(matches!(backwards.to_event(), Err(DraftError::Shape(_))));
let mut too_long = event();
too_long.end_date = "2026-11-30".into();
assert!(matches!(too_long.to_event(), Err(DraftError::Shape(_))));
let mut http = event();
http.url = "http://lpc.events".into();
assert!(matches!(http.to_event(), Err(DraftError::Shape(_))));
}
#[test]
fn a_refusal_names_the_row_it_came_from() {
let profile = |edit: fn(&mut ProfileDraft)| {
let mut draft = ProfileDraft::new(&VetterPolicy::default());
draft.toggle_method(VettingMethod::InPerson);
edit(&mut draft);
draft.to_body().unwrap_err().to_string()
};
let err = profile(|d| d.country = "USA".into());
assert_eq!(
err,
"Country: write it as a two-letter code, like US or CZ — not the country's name"
);
assert_eq!(
DraftError::Country.row(),
Some("Country"),
"the row the message names is the row the cursor is sent to"
);
for (row, edit) in [
(
"Languages",
(|d: &mut ProfileDraft| d.languages = "english".into()) as fn(&mut ProfileDraft),
),
("Documents I accept", |d: &mut ProfileDraft| {
d.accepts_documentation = "Passport!".into()
}),
("Display name", |d: &mut ProfileDraft| {
d.display_name = "x".repeat(500)
}),
("City", |d: &mut ProfileDraft| {
d.country = "de".into();
d.city = "x".repeat(500);
}),
] {
let err = profile(edit);
assert!(
err.starts_with(&format!("{row}:")),
"a refusal must name its row: {err}"
);
}
assert_eq!(DraftError::NoMethods.row(), Some("I vet"));
assert!(DraftError::NoMethods.to_string().starts_with("I vet:"));
assert_eq!(DraftError::LocationWithoutCountry.row(), Some("Country"));
let mut bad_name = event();
bad_name.name = String::new();
let err = bad_name.to_event().unwrap_err().to_string();
assert!(err.starts_with("Name:"), "got: {err}");
let err = DirectoryFilter {
country: "Czechia".into(),
..DirectoryFilter::default()
}
.to_body(None)
.unwrap_err();
assert_eq!(err, DraftError::Country);
}
#[test]
fn a_place_needs_a_country_and_a_method_is_required() {
let mut draft = ProfileDraft::new(&VetterPolicy::default());
draft.city = "Berlin".into();
assert!(matches!(
draft.to_body(),
Err(DraftError::LocationWithoutCountry)
));
let mut draft = ProfileDraft::new(&VetterPolicy::default());
draft.toggle_method(VettingMethod::InPerson);
draft.toggle_method(VettingMethod::Video);
assert!(matches!(draft.to_body(), Err(DraftError::NoMethods)));
draft.toggle_method(VettingMethod::PriorAcquaintance);
draft.toggle_method(VettingMethod::InPerson);
assert_eq!(
draft.methods,
vec![VettingMethod::InPerson, VettingMethod::PriorAcquaintance],
"ticks keep a stable order"
);
}
#[test]
fn directory_filters_are_optional_and_checked() {
assert_eq!(
serde_json::to_value(DirectoryFilter::default().to_body(None).unwrap()).unwrap(),
serde_json::json!({})
);
let filter = DirectoryFilter {
country: "cz".into(),
event_from: "2026-10-01".into(),
event_to: "2026-10-10".into(),
..DirectoryFilter::default()
};
let body = filter.to_body(Some("next".into())).unwrap();
assert_eq!(body.country.as_ref().map(|c| c.as_str()), Some("CZ"));
assert!(has_event_filter(&body));
assert_eq!(body.cursor.as_ref().map(|c| c.as_str()), Some("next"));
let backwards = DirectoryFilter {
event_from: "2026-10-10".into(),
event_to: "2026-10-01".into(),
..DirectoryFilter::default()
};
assert!(matches!(backwards.to_body(None), Err(DraftError::Shape(_))));
let bad_date = DirectoryFilter {
event_to: "soon".into(),
..DirectoryFilter::default()
};
assert!(matches!(
bad_date.to_body(None),
Err(DraftError::Date("Events until"))
));
}
#[test]
fn the_last_profile_sent_is_kept_and_follows_the_communitys_answer() {
let mut book = VettingBook::default();
let persona = PersonaId::new();
let now = Utc::now();
let policy = VetterPolicy::default();
let mut draft = ProfileDraft::new(&policy);
draft.display_name = "Carol".into();
let body = draft.to_body().unwrap();
assert!(
book.record_profile_sent("did:web:a", persona, &body, now)
.is_none()
);
let record = book.vetter_profile("did:web:a", persona).unwrap();
assert_eq!(
record.draft(&policy),
draft,
"edited later from what was sent"
);
let stored =
profile::Response::try_from(profile::Response::builder().listed(false).updated_at(now))
.unwrap();
assert!(book.on_profile_stored("did:web:a", persona, &stored));
assert!(matches!(
book.vetter_profile("did:web:a", persona).unwrap().state,
ProfileState::Stored { listed: false, .. }
));
let previous = book.record_profile_sent("did:web:a", persona, &body, now);
book.restore_profile("did:web:a", persona, previous);
assert!(matches!(
book.vetter_profile("did:web:a", persona).unwrap().state,
ProfileState::Stored { .. }
));
assert!(book.on_profile_refused("did:web:a", persona, "x:notEligible", now));
assert!(!book.on_profile_refused("did:web:b", persona, "x:notEligible", now));
let unreadable = VetterProfileRecord {
community: "did:web:c".into(),
persona,
profile: serde_json::json!({ "listed": true, "fromTheFuture": 1 }),
state: ProfileState::Sent { sent_at: now },
};
assert_eq!(unreadable.draft(&policy), ProfileDraft::new(&policy));
}
#[test]
fn places_and_events_read_as_one_line() {
let e = event().to_event().unwrap();
assert_eq!(
event_line(&e),
"Linux Plumbers — 2026-10-05 to 2026-10-07, Prague, CZ"
);
}
}