use serde::{Deserialize, Serialize};
use super::CouncilDecisionRecord;
use crate::enums::GovernanceLogEntryType;
use crate::ids::{GovernanceLogId, PostId};
pub const TEST_ENTRY_VERSION: u32 = 1;
pub const TEST_LABEL: &str = "TEST: a test sitting; not binding";
pub const TEST_INNER_POINTER: &str = "/inner";
pub const TEST_MACHINE_FIELDS: [&str; 3] =
["/agora_test", "/post", "/inner_type"];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct TestEntry {
pub agora_test: u32,
pub title: String,
pub description: String,
pub post: PostId,
pub inner_type: GovernanceLogEntryType,
pub inner: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TestEntryError {
#[error("agora_test is not the version this writer writes")]
Version,
#[error("a test entry's title and description must both say something")]
Blank,
#[error(
"a test entry wraps a decision-like entry (council_decision, \
appeals_court_decision, emergency_action, policy_change, \
steward_veto), not {0}"
)]
InnerType(GovernanceLogEntryType),
#[error("a test entry's inner data must be a JSON object")]
InnerNotObject,
#[error(
"the inner data carries its own _blind; the test entry's blind \
covers it, and the writer supplies that"
)]
InnerBlinded,
#[error("the inner record's id is {inner}, not this entry's id {outer}")]
InnerId {
inner: GovernanceLogId,
outer: GovernanceLogId,
},
#[error("the inner record does not parse as a {0}: {1}")]
Inner(GovernanceLogEntryType, String),
}
impl TestEntry {
pub const fn wraps(inner_type: GovernanceLogEntryType) -> bool {
matches!(
inner_type,
GovernanceLogEntryType::CouncilDecision
| GovernanceLogEntryType::AppealsCourtDecision
| GovernanceLogEntryType::EmergencyAction
| GovernanceLogEntryType::PolicyChange
| GovernanceLogEntryType::StewardVeto
)
}
pub fn council_decision(
title: impl Into<String>,
description: impl Into<String>,
post: PostId,
record: &CouncilDecisionRecord,
) -> Self {
Self {
agora_test: TEST_ENTRY_VERSION,
title: title.into(),
description: description.into(),
post,
inner_type: GovernanceLogEntryType::CouncilDecision,
inner: serde_json::to_value(record)
.expect("a CouncilDecisionRecord always serializes"),
}
}
pub fn validate(&self, id: &GovernanceLogId) -> Result<(), TestEntryError> {
if self.agora_test != TEST_ENTRY_VERSION {
return Err(TestEntryError::Version);
}
if self.title.trim().is_empty() || self.description.trim().is_empty() {
return Err(TestEntryError::Blank);
}
if !Self::wraps(self.inner_type) {
return Err(TestEntryError::InnerType(self.inner_type));
}
let inner = self
.inner
.as_object()
.ok_or(TestEntryError::InnerNotObject)?;
if inner.contains_key(super::BLIND_KEY) {
return Err(TestEntryError::InnerBlinded);
}
if let Some(written) = inner.get("id") {
let inner_id: GovernanceLogId =
serde_json::from_value(written.clone()).map_err(|e| {
TestEntryError::Inner(self.inner_type, e.to_string())
})?;
if &inner_id != id {
return Err(TestEntryError::InnerId {
inner: inner_id,
outer: id.clone(),
});
}
}
if let Some(Err(e)) = self.council_record() {
return Err(TestEntryError::Inner(self.inner_type, e.to_string()));
}
Ok(())
}
pub fn council_record(
&self,
) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
(self.inner_type == GovernanceLogEntryType::CouncilDecision)
.then(|| CouncilDecisionRecord::deserialize(&self.inner))
}
pub fn labelled_title(title: &str) -> String {
if title.starts_with(TEST_LABEL) {
title.to_string()
} else {
format!("{TEST_LABEL}. {title}")
}
}
pub fn may_change(pointer: &str) -> bool {
!pointer.is_empty()
&& pointer != TEST_INNER_POINTER
&& !TEST_MACHINE_FIELDS.iter().any(|field| {
pointer == *field
|| pointer
.strip_prefix(field)
.is_some_and(|rest| rest.starts_with('/'))
})
}
}
pub fn decision_data(
entry_type: GovernanceLogEntryType,
data: &serde_json::Value,
) -> Option<&serde_json::Value> {
if entry_type == GovernanceLogEntryType::Test {
data.get("inner")
} else {
Some(data)
}
}
pub fn decision_type(
entry_type: GovernanceLogEntryType,
data: &serde_json::Value,
) -> GovernanceLogEntryType {
if entry_type == GovernanceLogEntryType::Test {
data.get("inner_type")
.and_then(|t| serde_json::from_value(t.clone()).ok())
.unwrap_or(GovernanceLogEntryType::Unknown)
} else {
entry_type
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use serde_json::json;
pub(crate) fn test_id() -> GovernanceLogId {
"TEST-2026-0001".parse().unwrap()
}
pub(crate) fn inner() -> serde_json::Value {
json!({
"id": "TEST-2026-0001",
"meeting_id": "00000000-0000-4000-8000-0000000000aa",
"category": "Routine",
"title": "A test of the witness toolset",
"rounds": [],
"final_votes": {
"artist": "yes", "philosopher": "yes", "lawyer": "yes",
"engineer": "yes", "steward": "yes"
},
"outcome": "approved",
"vote_tally": "5-0",
"landmark": false,
"attachments": [{
"name": "provenance.json",
"note": "What ran",
"content": "{\"commit\":\"0000000\"}"
}]
})
}
pub(crate) fn entry() -> TestEntry {
TestEntry {
agora_test: TEST_ENTRY_VERSION,
title: "A test of the witness toolset".into(),
description: "Heard by a rehearsal composition; nothing binds."
.into(),
post: "00000000-0000-4000-8000-0000000000bb".parse().unwrap(),
inner_type: GovernanceLogEntryType::CouncilDecision,
inner: inner(),
}
}
#[test]
fn round_trips_and_validates() {
let e = entry();
let value = serde_json::to_value(&e).unwrap();
assert_eq!(value["inner_type"], "council_decision");
let back: TestEntry = serde_json::from_value(value).unwrap();
assert_eq!(back, e);
back.validate(&test_id()).unwrap();
let record = back.council_record().unwrap().unwrap();
assert_eq!(record.id, test_id());
assert_eq!(record.attachments[0].name, "provenance.json");
}
#[test]
fn a_writer_refuses_what_is_not_a_test_of_a_decision() {
let refused = |f: &dyn Fn(&mut TestEntry)| {
let mut e = entry();
f(&mut e);
e.validate(&test_id()).unwrap_err()
};
assert_eq!(refused(&|e| e.agora_test = 2), TestEntryError::Version);
assert_eq!(refused(&|e| e.title = " ".into()), TestEntryError::Blank);
for inner_type in [
GovernanceLogEntryType::Test,
GovernanceLogEntryType::Amendment,
GovernanceLogEntryType::KeyRotation,
GovernanceLogEntryType::DataSet,
GovernanceLogEntryType::Unknown,
] {
assert_eq!(
refused(&|e| e.inner_type = inner_type),
TestEntryError::InnerType(inner_type)
);
}
assert_eq!(
refused(&|e| e.inner = json!("x")),
TestEntryError::InnerNotObject
);
assert_eq!(
refused(&|e| e.inner["_blind"] = json!("00")),
TestEntryError::InnerBlinded
);
assert!(matches!(
refused(&|e| e.inner["id"] = json!("GOV-2026-0040")),
TestEntryError::InnerId { .. }
));
assert!(matches!(
refused(&|e| e.inner["rounds"] = json!("none")),
TestEntryError::Inner(GovernanceLogEntryType::CouncilDecision, _)
));
}
#[test]
fn labels_and_pointers() {
let t = TestEntry::labelled_title("A test");
assert_eq!(t, "TEST: a test sitting; not binding. A test");
assert_eq!(TestEntry::labelled_title(&t), t);
for ok in [
"/title",
"/description",
"/inner/summary/text",
"/inner/attachments/0/content",
] {
assert!(TestEntry::may_change(ok), "{ok}");
}
for no in [
"",
"/post",
"/inner_type",
"/agora_test",
"/inner",
"/post/x",
] {
assert!(!TestEntry::may_change(no), "{no}");
}
assert!(TestEntry::may_change("/posts"));
let data = serde_json::to_value(entry()).unwrap();
assert_eq!(
decision_data(GovernanceLogEntryType::Test, &data),
Some(&data["inner"])
);
assert_eq!(
decision_type(GovernanceLogEntryType::Test, &data),
GovernanceLogEntryType::CouncilDecision
);
assert_eq!(
decision_data(GovernanceLogEntryType::CouncilDecision, &data),
Some(&data)
);
}
}