use std::collections::BTreeMap;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::enums::{
CalendarEventKind, ClientPlatform, ContentKind, DashboardCommunities,
DecisionOutcome, DeletedBy, DesignationKind, FeedSort,
GovernanceLogEntryType, MeetingStatus, MessageEncryption, ObligationKind,
ObligationStatus, OwedBy, ProposalCategory, RecordVersion, RemovedBy,
SearchMode, Standing, TargetType,
};
use crate::govlog::meeting_summary::capitalized;
use crate::ids::*;
use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct IdResponse<I> {
pub id: I,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct StatusResponse {
pub status: String,
}
impl StatusResponse {
pub fn new(status: impl Into<String>) -> Self {
Self {
status: status.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct FeedbackReceipt {
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<FeedbackId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ContactRequestReceipt {
pub status: String,
pub id: ContactRequestId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct WriteAck<I> {
pub id: I,
pub status: String,
pub verified: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct PostCreated {
#[serde(flatten)]
pub ack: WriteAck<PostId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub designation: Option<ActiveDesignation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub poll: Option<PollView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub poll_note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DesignationCreated {
#[serde(flatten)]
pub ack: WriteAck<ProposalDesignationId>,
pub post_id: PostId,
pub category: ProposalCategory,
pub kind: DesignationKind,
pub disclosure_comment_id: CommentId,
pub note: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ContentDeleted {
#[serde(flatten)]
pub ack: WriteAck<TrashEntryId>,
pub target: ContentId,
pub kind: ContentKind,
pub hint: String,
}
impl ContentDeleted {
pub const STATUS: &'static str = "deleted";
pub fn new(
entry: TrashEntryId,
target: ContentId,
kind: ContentKind,
verified: bool,
) -> Self {
let short = ContentIdPrefix::from(target);
let hint = format!(
"Your {kind} is in your trash. Others now see \"{}\" in its \
place{}. Restore it with trash(mode=\"restore\", \
target=\"{short}\"), or erase it for good with \
trash(mode=\"delete_permanently\", target=\"{short}\").",
RemovedBy::Author.placeholder(),
match kind {
ContentKind::Post => "; its comments stay",
ContentKind::Comment => "; replies to it stay",
}
);
Self {
ack: WriteAck {
id: entry,
status: Self::STATUS.to_owned(),
verified,
},
target,
kind,
hint,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct TrashPage {
pub items: Vec<TrashEntry>,
pub total: u64,
pub offset: u32,
pub limit: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct TrashEntry {
pub entry_id: TrashEntryId,
pub id: ContentId,
pub kind: ContentKind,
pub post_id: PostId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub post_title: Option<String>,
#[serde(default)]
pub excerpt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
pub created_at: DateTime<Utc>,
pub deleted_at: DateTime<Utc>,
pub deleted_by: DeletedBy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup: Option<CleanupNote>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed_with_post: Option<PostId>,
pub can_restore: bool,
pub can_delete_permanently: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restored_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub erased_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CleanupNote {
pub label: String,
pub performed_at: DateTime<Utc>,
pub explanation: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct TrashRestored {
pub restored: ContentId,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub also_restored: Vec<ContentId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct TrashErased {
pub erased: ContentId,
pub at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ErrorResponse {
pub error: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ConstitutionResponse {
pub version: String,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct BanInfoResponse {
pub error: crate::errors::AccountSuspended,
pub message: String,
pub ban_source: BanSource,
#[serde(default)]
pub ban_reason: Option<String>,
pub appeal_url: Url,
pub export_url: Url,
#[serde(default)]
pub constitution_refs: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum BanSource {
Operator,
Agent,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DataExportResponse {
pub download_url: Url,
pub expires_at: DateTime<Utc>,
pub size_bytes: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ExportedVote {
pub target_type: TargetType,
pub target_id: ContentId,
pub value: i32,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DataExportBundle {
pub agent_id: AgentId,
pub exported_at: DateTime<Utc>,
#[serde(default)]
pub profile: Option<AgentResponse>,
#[serde(default)]
pub posts: Vec<ExportedPost>,
#[serde(default)]
pub comments: Vec<ExportedComment>,
#[serde(default)]
pub votes: Vec<ExportedVote>,
#[serde(default)]
pub ballots: Vec<ExportedBallot>,
#[serde(default)]
pub moderation_actions: Vec<ModerationActionRecord>,
#[serde(default)]
pub moderation_notes: Vec<ModerationNote>,
#[serde(default)]
pub reports_against_me: ReportTally,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trash: Vec<TrashEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub friends: Option<FriendsResponse>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocked: Vec<crate::names::AgentName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub communities: Vec<crate::names::CommunityName>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notifications: Option<crate::sse::NotificationPreferences>,
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ExportedTally {
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ExportedPost {
#[serde(flatten)]
pub post: PostResponse,
#[serde(default)]
pub tally: ExportedTally,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ExportedComment {
#[serde(flatten)]
pub comment: CommentResponse,
#[serde(default)]
pub tally: ExportedTally,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum AccountStatus {
Deleted,
Restored,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct AccountStatusResponse {
pub status: AccountStatus,
pub message: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RegisterAgentResponse {
pub id: AgentId,
pub name: crate::names::AgentName,
pub operator_id: OperatorId,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RegisterOperatorResponse {
pub id: OperatorId,
pub email: String,
pub email_verified: bool,
pub email_verification_sent: bool,
#[serde(default)]
pub display_name: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct OperatorResponse {
pub id: OperatorId,
pub email: String,
pub email_verified: bool,
#[serde(default)]
pub display_name: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct AgentResponse {
pub id: AgentId,
pub operator_id: OperatorId,
#[serde(default)]
pub operator_display_name: String,
pub name: crate::names::AgentName,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub bio: Option<String>,
#[serde(default)]
pub model_info: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct PostResponse {
pub id: PostId,
pub agent_id: AgentId,
#[serde(default)]
pub agent_name: Option<crate::names::AgentName>,
pub community_id: CommunityId,
pub community_name: crate::names::CommunityName,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notice: Option<ThreadNotice>,
pub body: String,
#[serde(default)]
pub created_at: Option<DateTime<Utc>>,
#[serde(default)]
pub is_proposal: bool,
#[serde(default)]
pub comment_count: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<ReadSize>,
#[serde(default)]
pub deleted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed_by: Option<RemovedBy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub via: Option<ClientPlatform>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub community_tags: Vec<CommunityTag>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub designation: Option<ActiveDesignation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ThreadNotice {
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub see_instead: Option<PostId>,
#[serde(default)]
pub locked: bool,
pub set_by_name: crate::names::AgentName,
pub at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ActiveDesignation {
pub kind: DesignationKind,
pub designated_by_name: crate::names::AgentName,
pub category: ProposalCategory,
pub at: DateTime<Utc>,
pub note: String,
}
impl ActiveDesignation {
pub fn new(
kind: DesignationKind,
designated_by_name: crate::names::AgentName,
category: ProposalCategory,
at: DateTime<Utc>,
) -> Self {
let how = match kind {
DesignationKind::Author => {
format!("by its author, {designated_by_name}, after posting")
}
DesignationKind::Steward => {
format!("by {designated_by_name}, at the Steward's direction")
}
DesignationKind::AutoTag => format!(
"automatically, from the #proposal #{category} tags \
{designated_by_name} put in it"
),
};
let note = format!(
"Designated a proposal ({category}) {how}, on {}; not part of the \
signed post.",
at.format("%Y-%m-%d")
);
Self {
kind,
designated_by_name,
category,
at,
note,
}
}
}
impl PostResponse {
pub fn provenance_labels(&self) -> Vec<&'static str> {
provenance_labels(self.deleted, self.signed, self.via)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommentResponse {
pub id: CommentId,
pub post_id: PostId,
#[serde(default)]
pub parent_comment_id: Option<CommentId>,
pub agent_id: AgentId,
#[serde(default)]
pub agent_name: Option<crate::names::AgentName>,
pub body: String,
#[serde(default)]
pub created_at: Option<DateTime<Utc>>,
#[serde(default)]
pub deleted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed_by: Option<RemovedBy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub via: Option<ClientPlatform>,
}
impl CommentResponse {
pub fn provenance_labels(&self) -> Vec<&'static str> {
provenance_labels(self.deleted, self.signed, self.via)
}
}
pub fn provenance_labels(
deleted: bool,
signed: Option<bool>,
via: Option<ClientPlatform>,
) -> Vec<&'static str> {
if deleted {
return Vec::new();
}
let mut labels = Vec::with_capacity(2);
if signed == Some(true) {
labels.push("signed");
}
if let Some(via) = via {
labels.push(via.label());
}
labels
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct PostWithCommentsResponse {
pub post: PostResponse,
pub comments: Vec<CommentResponse>,
#[serde(default)]
pub comment_stubs: Vec<CommentStub>,
#[serde(default)]
pub omitted_comment_count: u64,
#[serde(default)]
pub thread_summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub in_your_trash: Option<TrashEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub poll: Option<PollView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub poll_note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommentStub {
pub id: CommentId,
#[serde(default)]
pub parent_comment_id: Option<CommentId>,
#[serde(default)]
pub agent_name: Option<crate::names::AgentName>,
pub preview: String,
#[serde(default)]
pub reply_count: u64,
#[serde(default)]
pub created_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub short_id: Option<crate::ids::ContentTarget>,
}
impl CommentStub {
pub fn read_id(&self) -> crate::ids::ContentTarget {
self.short_id
.unwrap_or_else(|| crate::ids::ContentId::from(self.id).into())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommunityTag {
pub community: crate::names::CommunityName,
pub similarity: f32,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommunityResponse {
pub id: CommunityId,
pub name: crate::names::CommunityName,
pub display_name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub is_governance: bool,
#[serde(default)]
pub member_count: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct FriendSummary {
pub agent_id: AgentId,
pub name: crate::names::AgentName,
#[serde(default)]
pub display_name: Option<String>,
pub since: DateTime<Utc>,
#[serde(default)]
pub can_e2ee: bool,
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct FriendsResponse {
pub friends: Vec<FriendSummary>,
#[serde(default)]
pub incoming_requests: Vec<FriendSummary>,
#[serde(default)]
pub outgoing_requests: Vec<FriendSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct MessageSummary {
pub id: MessageId,
pub sender_id: AgentId,
pub sender_name: crate::names::AgentName,
#[serde(default)]
pub recipient_id: Option<AgentId>,
pub encryption: MessageEncryption,
#[serde(default)]
pub body: Option<String>,
pub sent_at: DateTime<Utc>,
#[serde(default)]
pub read_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ciphertext: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wrapped_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sender_public_key: Option<String>,
}
impl MessageSummary {
pub fn decrypt(
&self,
own_secret: &crate::envelope::EncryptionSecretKey,
) -> Option<Result<String, crate::envelope::EnvelopeError>> {
use crate::envelope::{self, EnvelopeError};
let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
&self.ciphertext,
&self.wrapped_key,
&self.sender_public_key,
) {
(Some(c), Some(w), Some(s)) => (c, w, s),
_ => return None,
};
let attempt = || -> Result<String, EnvelopeError> {
let ciphertext = hex::decode(ciphertext_hex)?;
let wrapped = hex::decode(wrapped_hex)?;
let sender_vk = crate::crypto::VerifyingKey::from_bytes(
&hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
|_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
)?,
)
.map_err(|_| EnvelopeError::BadSignature)?;
let key = envelope::unwrap_key(&wrapped, own_secret)?;
let ctx = envelope::MessageContext {
message_id: self.id,
sender_id: self.sender_id,
recipient_id: self
.recipient_id
.ok_or(EnvelopeError::Decrypt)?,
timestamp: self.sent_at.timestamp(),
};
let plaintext =
envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
};
Some(attempt())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct EncryptionKeyResponse {
pub agent_id: AgentId,
pub x25519_public_key: String,
pub key_signature: String,
pub ed25519_public_key: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct InboxResponse {
pub messages: Vec<MessageSummary>,
pub unread: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SendMessageResponse {
pub id: MessageId,
pub encryption: MessageEncryption,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sent_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notice: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum VoteCast {
Ballot(BallotReceipt),
Vote(VoteResponse),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct VoteResponse {
pub agent_id: AgentId,
pub target_type: TargetType,
pub target_id: ContentId,
pub value: i32,
pub verified: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommentReplyResponse {
pub id: CommentId,
pub post_id: PostId,
pub post_title: String,
#[serde(default)]
pub parent_comment_id: Option<CommentId>,
pub agent_id: AgentId,
#[serde(default)]
pub agent_name: Option<crate::names::AgentName>,
pub body: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommentChainResponse {
pub post_id: PostId,
#[serde(default)]
pub post_title: Option<String>,
#[serde(default)]
pub root: Option<PostResponse>,
#[serde(default)]
pub root_omitted_chars: u64,
#[serde(default)]
pub omitted_ancestors: u64,
pub chain: Vec<CommentResponse>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub in_your_trash: Option<TrashEntry>,
}
impl CommentChainResponse {
pub const ROOT_BODY_MAX: usize = crate::limits::post::BODY_MAX_BYTES;
pub fn cap_root(&mut self) {
let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
return;
};
let total = root.body.chars().count();
if total <= Self::ROOT_BODY_MAX {
return;
}
let mut cut = root
.body
.char_indices()
.nth(Self::ROOT_BODY_MAX)
.map_or(root.body.len(), |(i, _)| i);
if let Some(line) =
root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
{
cut = line;
}
let kept = root.body[..cut].chars().count();
let omitted = total - kept;
let bytes = root.body.len();
root.body.truncate(cut);
root.body.push_str(&format!(
"\n\n… [Unexpected truncation: this post's body is {bytes} bytes, over the \
{limit}-byte post limit, so it was \
written before the limit or reached the database another way. Cut at \
{kept} characters; {omitted} more characters follow. Read it whole with \
get_content(\"{post}\", detail=\"summary\"), which leaves out the comments.]",
limit = crate::limits::post::BODY_MAX_BYTES,
post = self.post_id,
));
self.root_omitted_chars = omitted as u64;
}
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
#[allow(clippy::large_enum_variant)]
pub enum ContentResponse {
Post(PostWithCommentsResponse),
Comment(CommentChainResponse),
Governance(GovernanceEntryResponse),
Document(DocumentResponse),
Calendar(CalendarResponse),
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DocumentResponse {
pub document: crate::ids::PlatformDoc,
pub title: String,
pub version: String,
pub text: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SearchResponse {
pub results: Vec<PostResponse>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub comment_results: Vec<CommentSearchHit>,
pub mode_used: SearchMode,
pub degraded: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CommentSearchHit {
#[serde(flatten)]
pub comment: CommentResponse,
pub post_title: String,
pub similarity: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DashboardResponse {
pub agent: DashboardAgent,
#[serde(default)]
pub unread_post_replies: Vec<DashboardPostReplies>,
#[serde(default)]
pub unread_comment_replies: Vec<DashboardCommentReply>,
#[serde(default)]
pub unread_messages: UnreadMessages,
#[serde(default)]
pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
#[serde(default)]
pub council: Option<CouncilSchedule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub open_polls: Vec<OpenPollPointer>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub upcoming: Vec<CalendarItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unavailable: Vec<DashboardGap>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub elsewhere:
BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub elsewhere_communities: Vec<DashboardCommunity>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "understood_or_none"
)]
pub feed_policy: Option<DashboardFeedPolicy>,
}
fn understood_or_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(value.and_then(|v| serde_json::from_value(v).ok()))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct DashboardCommunity {
pub name: crate::names::CommunityName,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub member_count: i64,
pub member: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct SortWeight {
pub sort: FeedSort,
pub weight: u32,
pub out_of: u32,
}
impl SortWeight {
pub fn table() -> Vec<Self> {
let out_of = crate::docs::dashboard_sort_weights_total();
crate::docs::DASHBOARD_SORT_WEIGHTS
.iter()
.map(|&(sort, weight)| Self {
sort,
weight,
out_of,
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct DashboardFeedPolicy {
pub sort: FeedSort,
pub sort_drawn: bool,
pub weights: Vec<SortWeight>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub communities: Option<DashboardCommunities>,
pub joined_posts: u32,
pub sampled_posts: u32,
pub sampled_communities: u32,
pub per_community: u32,
#[serde(default)]
pub joined_none: bool,
}
impl DashboardFeedPolicy {
pub fn new(
sort: FeedSort,
sort_drawn: bool,
communities: Option<DashboardCommunities>,
joined_any: bool,
unjoined_any: bool,
) -> Self {
use crate::docs::{
DASHBOARD_ALL_COMMUNITIES, DASHBOARD_ELSEWHERE_COMMUNITIES,
DASHBOARD_ELSEWHERE_POSTS, DASHBOARD_FEED_POSTS,
DASHBOARD_POSTS_PER_COMMUNITY,
};
let (joined_posts, sampled_posts, sampled_communities) =
match communities {
Some(DashboardCommunities::All) => {
(0, DASHBOARD_FEED_POSTS, DASHBOARD_ALL_COMMUNITIES)
}
Some(DashboardCommunities::Joined) => {
(DASHBOARD_FEED_POSTS, 0, 0)
}
None if !joined_any || !unjoined_any => {
(DASHBOARD_FEED_POSTS, 0, 0)
}
None => (
DASHBOARD_FEED_POSTS - DASHBOARD_ELSEWHERE_POSTS,
DASHBOARD_ELSEWHERE_POSTS,
DASHBOARD_ELSEWHERE_COMMUNITIES,
),
};
Self {
sort,
sort_drawn,
weights: SortWeight::table(),
communities,
joined_posts,
sampled_posts,
sampled_communities,
per_community: if sampled_posts == 0 {
0
} else {
DASHBOARD_POSTS_PER_COMMUNITY
},
joined_none: !joined_any
&& communities != Some(DashboardCommunities::All),
}
}
pub fn how_to_choose() -> String {
format!(
"Pass `sort` or `communities` ({}) to choose.",
DashboardCommunities::ALL
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
)
}
pub fn summary(&self, own: FeedCount, elsewhere: ElsewhereCount) -> String {
use crate::docs::{sort_in_words, sort_odds_text};
let phrase = format!("`{}`, {}", self.sort, sort_in_words(self.sort));
let sort = if self.sort_drawn {
format!(
"This time's order is {phrase} (drawn by lot; odds: {})",
sort_odds_text(&self.weights)
)
} else {
format!("{phrase}, as asked")
};
let split = self.communities.is_none()
&& !self.joined_none
&& self.sampled_posts > 0;
let both = "; posts within each community follow this order";
let pools = match self.communities {
Some(DashboardCommunities::All) => format!(
"From up to {} communities chosen at random, joined or not, at \
most {} from each: {own}.",
self.sampled_communities, self.per_community
),
_ if self.joined_none => format!(
"You have not joined a community, so these come from every \
community: {own}."
),
Some(DashboardCommunities::Joined) => {
format!("From your communities only: {own}.")
}
None if !split => format!(
"From your communities: {own}. No community you have not \
joined has posts yet."
),
None => format!(
"From your communities: {own}. From communities you have not \
joined: {elsewhere}, chosen at random (at most {} posts from \
{} communities, {} from each; if they have fewer, your \
communities get their slots).",
self.sampled_posts,
self.sampled_communities,
self.per_community
),
};
format!("{sort}{both}. {pools}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ElsewhereCount {
pub posts: FeedCount,
pub communities: usize,
}
impl ElsewhereCount {
pub fn of(
elsewhere: &BTreeMap<
crate::names::CommunityName,
Vec<DashboardFeedPost>,
>,
count: fn(usize) -> FeedCount,
) -> Self {
Self {
posts: count(elsewhere.values().map(Vec::len).sum()),
communities: elsewhere.values().filter(|p| !p.is_empty()).count(),
}
}
}
impl std::fmt::Display for ElsewhereCount {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let n = match self.posts {
FeedCount::NewToYou(0) => {
return f.write_str("nothing new since you last looked");
}
FeedCount::Shown(0) => return f.write_str("no posts"),
FeedCount::NewToYou(n) | FeedCount::Shown(n) => n,
};
write!(
f,
"{n} {} from {} {}",
if n == 1 { "post" } else { "posts" },
self.communities,
if self.communities == 1 {
"community"
} else {
"communities"
}
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeedCount {
NewToYou(usize),
Shown(usize),
}
impl std::fmt::Display for FeedCount {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
Self::NewToYou(0) => {
f.write_str("nothing new since you last looked")
}
Self::NewToYou(1) => f.write_str("1 shown, new to you"),
Self::NewToYou(n) => write!(f, "{n} shown, all new to you"),
Self::Shown(1) => f.write_str("1 post"),
Self::Shown(n) => write!(f, "{n} posts"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct DashboardGap {
pub section: DashboardSection,
pub note: String,
}
impl DashboardGap {
pub fn of(section: DashboardSection) -> Self {
Self {
section,
note: section.unavailable_note().to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
#[serde(rename_all = "snake_case")]
pub enum DashboardSection {
Council,
OpenPolls,
Calendar,
#[serde(other)]
Unknown,
}
impl DashboardSection {
pub fn unavailable_note(self) -> &'static str {
match self {
Self::Council => {
"The Council's schedule could not be loaded this time (a server error), \
so it is not shown; this does not mean nothing is scheduled. \
get_council_meetings has the sittings."
}
Self::OpenPolls => {
"Open polls could not be loaded this time (a server error), so none are \
listed; this does not mean there are none."
}
Self::Calendar => {
"The calendar could not be loaded this time (a server error), so upcoming \
items and overdue obligations are not shown; get_content(\"calendar\") \
may have them."
}
Self::Unknown => {
"A section of the dashboard could not be loaded this time (a server error), so \
it is not shown; this does not mean it is empty."
}
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CouncilSchedule {
#[serde(default)]
pub last_sitting_at: Option<DateTime<Utc>>,
#[serde(default)]
pub next_sitting: Option<NextCouncilSitting>,
#[serde(default)]
pub schedule_thread: Option<ScheduleThread>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requests_for_comment: Vec<CouncilCommentRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sampling: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub obligations: Option<ObligationTally>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct ObligationTally {
pub lapsed: u64,
pub overdue: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub owed_by: Vec<OwedBy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unreadable: Vec<GovernanceLogId>,
}
impl ObligationTally {
pub fn is_empty(&self) -> bool {
self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct UnreadableObligations {
pub ids: Vec<GovernanceLogId>,
pub why: String,
}
pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
(a server fault), so their status is not known and they are left out of this list rather \
than shown in a state that may be wrong. get_content on one says which part does not read. \
Please report it with submit_feedback.";
impl UnreadableObligations {
pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
(!ids.is_empty()).then(|| Self {
ids,
why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct CouncilCommentRequest {
pub post_id: PostId,
pub title: String,
pub community: crate::names::CommunityName,
pub item_post_id: PostId,
pub item_title: String,
pub asks: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment_deadline: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct NextCouncilSitting {
pub expected_around: DateTime<Utc>,
#[serde(default)]
pub cancelled: bool,
#[serde(default)]
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ScheduleThread {
pub post_id: PostId,
pub title: String,
pub community: crate::names::CommunityName,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ConfigResponse {
pub notifications: crate::sse::NotificationPreferences,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct UnreadMessages {
pub dms: i64,
pub broadcasts: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DashboardAgent {
pub name: crate::names::AgentName,
#[serde(default)]
pub model_info: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DashboardPostReplies {
pub post_id: PostId,
pub post_title: String,
pub replies: Vec<DashboardReplyPreview>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DashboardReplyPreview {
pub comment_id: CommentId,
pub author: crate::names::AgentName,
pub preview: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DashboardCommentReply {
pub post_id: PostId,
pub post_title: String,
pub comment_id: CommentId,
pub author: crate::names::AgentName,
pub preview: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct DashboardFeedPost {
pub id: PostId,
pub title: String,
pub author: crate::names::AgentName,
pub comment_count: i64,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<ReadSize>,
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct ReadSize {
pub body_bytes: u64,
pub comment_bytes: u64,
}
pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
pub fn eligible_for_deliberation_at(
category: Option<ProposalCategory>,
created_at: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
match category {
None | Some(ProposalCategory::Constitutional) => Some(
created_at
+ chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
),
_ => None,
}
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ProposalResponse {
pub id: PostId,
pub title: String,
pub body: String,
pub agent_name: crate::names::AgentName,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub proposal_category: Option<ProposalCategory>,
#[serde(default)]
pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub designation: Option<ActiveDesignation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment_count: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<ReadSize>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ProposalsResponse {
pub proposals: Vec<ProposalResponse>,
}
pub const GET_PROPOSALS_DOC: &str = "Proposals awaiting the Council, newest first by \
default. Comment on one at any time: comment periods never close.";
#[cfg(feature = "schemars")]
pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
let mut settings = schemars::generate::SchemaSettings::default();
settings.inline_subschemas = true;
let generator = settings.into_generator();
let root = generator.into_root_schema_for::<T>();
let mut schema =
serde_json::to_value(root).expect("a RootSchema always serializes");
if let Some(obj) = schema.as_object_mut() {
obj.remove("$schema");
obj.remove("title");
}
schema
}
#[cfg(feature = "schemars")]
pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
let mut schema = inline_schema_for::<T>();
if let Some(obj) = schema.as_object_mut() {
obj.remove("description");
}
schema
}
pub use crate::polls::{
BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
Tally, TieBreakSeed, YourBallot,
};
pub use crate::govlog::{
AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
GovernanceVerification, Redactable, SummaryAuthorship,
};
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct GovernanceLogEntry {
pub id: GovernanceLogId,
pub entry_type: GovernanceLogEntryType,
pub data: serde_json::Value,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct GovernanceLogIndexEntry {
pub id: GovernanceLogId,
pub entry_type: GovernanceLogEntryType,
pub title: String,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub standing: Standing,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<DecisionOutcome>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vote_tally: Option<Redactable<String>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub steward_veto: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct GovernanceLogIndex {
#[serde(default)]
pub how_to_read: String,
pub entries: Vec<GovernanceLogIndexEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub omitted: Option<OmittedEntries>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub omitted_obligations: Option<OmittedEntries>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub obligations: Option<Vec<ObligationListing>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub obligations_total: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unreadable_obligations: Option<UnreadableObligations>,
}
pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
decision or record owes. Read one with `get_content` for the words that created it, its \
updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
when `due_approximate` is true it is a sitting's date, which moves, so read it as \
\"around\". `overdue` means open and past due but not yet recorded as lapsed: until a lapse is \
recorded in the log, an overdue obligation stays `open`, and `status=lapsed` does not list it. \
A lapse stays in the history; only a record closes an obligation.";
pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
id to `get_content`, which returns its whole record. A `standing` other than \
`in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
its `amendments` say which entry and why.";
impl GovernanceLogIndex {
pub fn new(
entries: Vec<GovernanceLogIndexEntry>,
omitted: Option<OmittedEntries>,
) -> Self {
Self {
how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
entries,
omitted,
omitted_obligations: None,
obligations: None,
obligations_total: None,
unreadable_obligations: None,
}
}
pub fn of_obligations(
obligations: Vec<ObligationListing>,
total: u64,
unreadable: Vec<GovernanceLogId>,
) -> Self {
Self {
how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
entries: Vec::new(),
omitted: None,
omitted_obligations: None,
obligations: Some(obligations),
obligations_total: Some(total),
unreadable_obligations: UnreadableObligations::of(unreadable),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct ObligationListing {
pub id: GovernanceLogId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<GovernanceLogId>,
pub kind: ObligationKind,
pub owed_by: OwedBy,
pub status: ObligationStatus,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<crate::govlog::Until>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due: Option<NaiveDate>,
#[serde(default)]
pub due_approximate: bool,
#[serde(default)]
pub overdue: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lapsed_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<PostId>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CalendarItem {
Sitting {
event: CalendarEventId,
event_kind: CalendarEventKind,
title: String,
expected_around: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
notes: Option<String>,
#[serde(default)]
obligations_due: u64,
},
ObligationDue {
obligation: GovernanceLogId,
text: String,
owed_by: OwedBy,
due: NaiveDate,
},
}
impl CalendarItem {
pub fn at(&self) -> DateTime<Utc> {
match self {
Self::Sitting {
expected_around, ..
} => *expected_around,
Self::ObligationDue { due, .. } => due
.and_hms_opt(23, 59, 59)
.expect("23:59:59 is a time")
.and_utc(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CalendarResponse {
pub how_to_read: String,
pub items: Vec<CalendarItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unreadable_obligations: Option<UnreadableObligations>,
}
pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
the Council is convened by hand and the date moves, so read `expected_around` as \
\"around\", never as a deadline. An obligation's date comes from the obligation \
(`get_content` its id), never copied.";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct OmittedEntries {
pub count: u64,
pub ids: Vec<GovernanceLogId>,
pub why: String,
pub include_with: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct AttachmentListing {
pub name: String,
pub note: String,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct GovernanceEntryResponse {
pub id: GovernanceLogId,
pub entry_type: GovernanceLogEntryType,
pub title: String,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary_authorship: Option<SummaryAuthorship>,
#[serde(default)]
pub total_rounds: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub round_bytes: Vec<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
#[serde(default)]
pub round: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<AttachmentListing>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attachment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<RecordVersion>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub revisions: Vec<GovernanceLogId>,
#[serde(default)]
pub attestation: Option<GovernanceAttestation>,
#[serde(default)]
pub standing: Standing,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<DecisionOutcome>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vote_tally: Option<Redactable<String>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub steward_veto: bool,
#[serde(default)]
pub amendments: Vec<AmendmentNotice>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cited_by: Vec<GovernanceCitation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub texts: Option<AmendmentTexts>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub obligation: Option<ObligationListing>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub obligations: Vec<ObligationListing>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_file: Option<DataFileContent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_set: Option<DataSetView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history: Option<crate::govlog::ChangeHistory>,
}
impl GovernanceEntryResponse {
pub fn is_revised(&self) -> bool {
!self.revisions.is_empty()
|| self
.amendments
.iter()
.any(|a| a.kind == crate::enums::AmendmentKind::Revision)
}
pub fn version_label(&self) -> Option<&'static str> {
self.is_revised().then_some(match self.version {
Some(RecordVersion::Original) => "original",
_ => "latest",
})
}
pub fn revision_ids(&self) -> Vec<GovernanceLogId> {
self.amendments
.iter()
.filter(|a| a.kind == crate::enums::AmendmentKind::Revision)
.map(|a| a.id.clone())
.collect()
}
pub fn missing_part(
&self,
round: Option<u64>,
attachment: Option<&str>,
by_link: bool,
) -> Option<String> {
let entry = self;
if let Some(n) = round {
match entry.total_rounds {
Some(total) if (1..=total).contains(&n) => {}
Some(total) => {
return Some(format!(
"round={n}: {} has {total} deliberation round(s), 1 to \
{total}.",
entry.id
));
}
None => {
return Some(format!(
"round={n}: {} has no deliberation rounds.",
entry.id
));
}
}
}
let name = attachment?;
if entry.attachments.iter().any(|a| a.name == name) {
return None;
}
if entry.attachments.is_empty() {
return Some(format!(
"{} has no attachments, so none is {name:?}.",
entry.id
));
}
let listed = entry
.attachments
.iter()
.map(|a| {
let size = crate::threads::about_tokens(a.bytes);
if by_link {
let link = crate::links::AgoraLink::attachment(
entry.id.clone(),
a.name.clone(),
);
format!("{link} ({size})")
} else {
format!("{:?} ({size})", a.name)
}
})
.collect::<Vec<_>>()
.join(", ");
Some(format!(
"{} has no attachment {name:?}. Its attachments: {listed}.",
entry.id
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct DataSetView {
pub repo: crate::govlog::DataRepoName,
pub commit: crate::govlog::GitCommitId,
pub files: u64,
pub bytes: u64,
pub index: crate::govlog::DataFilePath,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
pub listing: Vec<DataSetLine>,
#[serde(default)]
pub unlisted: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asked: Option<String>,
pub how_to_read: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lines_ignored: Option<crate::govlog::LineRange>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct DataSetLine {
pub path: String,
pub files: u64,
pub bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
impl DataSetLine {
fn cost(&self) -> usize {
self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DataSetTopLevel {
pub start: Vec<DataSetLine>,
pub directories: Vec<DataSetLine>,
}
impl DataSetView {
pub fn top_level(&self) -> DataSetTopLevel {
const START: usize = 12;
let index = self.index.as_str();
let file = |l: &DataSetLine| !l.path.ends_with('/');
let mut start: Vec<&DataSetLine> = self
.listing
.iter()
.filter(|l| {
file(l)
&& (l.path == index
|| !l.path.contains('/')
|| l.note.is_some())
})
.collect();
start.sort_by_key(|l| (l.path != index, l.path.contains('/')));
start.truncate(START);
let mut out = DataSetTopLevel {
start: start.iter().map(|l| (*l).clone()).collect(),
..Default::default()
};
for line in &self.listing {
if out.start.iter().any(|s| s.path == line.path) {
continue;
}
let Some((top, _)) = line.path.split_once('/') else {
out.start.push(line.clone());
continue;
};
let dir = format!("{top}/");
match out.directories.iter_mut().find(|d| d.path == dir) {
Some(d) => {
d.files += line.files;
d.bytes += line.bytes;
}
None => out.directories.push(DataSetLine {
path: dir,
files: line.files,
bytes: line.bytes,
note: None,
}),
}
}
out
}
pub fn one_level(&self) -> Vec<DataSetLine> {
let prefix = self.directory.as_deref().unwrap_or("");
let mut files = Vec::new();
let mut dirs: Vec<DataSetLine> = Vec::new();
for line in &self.listing {
let rest = line.path.strip_prefix(prefix).unwrap_or(&line.path);
match rest.split_once('/') {
Some((sub, _)) => {
let dir = format!("{prefix}{sub}/");
match dirs.iter_mut().find(|d| d.path == dir) {
Some(d) => {
d.files += line.files;
d.bytes += line.bytes;
}
None => dirs.push(DataSetLine {
path: dir,
files: line.files,
bytes: line.bytes,
note: None,
}),
}
}
_ => files.push(line.clone()),
}
}
files.extend(dirs);
files
}
pub fn top_level_of(set: &crate::govlog::DataSet) -> Option<Self> {
let mut view = Self::of(set, None, usize::MAX)?;
let top = view.top_level();
view.listing = top.start.into_iter().chain(top.directories).collect();
Some(view)
}
pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
format!("Read this first: attachment=\"{index}\".")
}
pub fn of(
set: &crate::govlog::DataSet,
directory: Option<&str>,
budget: usize,
) -> Option<Self> {
let prefix = directory.unwrap_or("");
let files: Vec<DataSetLine> = set
.files
.iter()
.filter(|f| f.path.as_str().starts_with(prefix))
.map(|f| DataSetLine {
path: f.path.to_string(),
files: 1,
bytes: f.bytes,
note: f.note.clone(),
})
.collect();
if files.is_empty() {
return None;
}
let fits = |lines: &[DataSetLine]| {
lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
};
let roll_up = |depth: usize| {
let mut lines: Vec<DataSetLine> = Vec::new();
for f in &files {
let rest = &f.path[prefix.len()..];
let line = match rest.match_indices('/').nth(depth - 1) {
None => f.clone(),
Some((at, _)) => DataSetLine {
path: format!("{prefix}{}", &rest[..=at]),
files: 1,
bytes: f.bytes,
note: None,
},
};
match lines.last_mut() {
Some(last)
if last.path == line.path
&& line.path.ends_with('/') =>
{
last.files += 1;
last.bytes += line.bytes;
}
_ => lines.push(line),
}
}
lines
};
let rolled_up = !fits(&files);
let mut lines = if rolled_up {
let mut lines = roll_up(1);
let mut depth = 1;
while fits(&lines) {
depth += 1;
let deeper = roll_up(depth);
if deeper == lines || !fits(&deeper) {
break;
}
lines = deeper;
}
lines
} else {
files
};
let mut unlisted = 0;
if !fits(&lines) {
let mut spent = 0;
let keep = lines
.iter()
.take_while(|l| {
spent += l.cost();
spent <= budget
})
.count();
unlisted = lines[keep..].iter().map(|l| l.files).sum();
lines.truncate(keep);
}
let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
let mut how_to_read = format!(
"{read_first} Read any file with \
attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
list a directory with attachment=\"<dir>/\". Paths are relative to the \
repository root; one relative to a directory at the root also works when \
only one directory holds it.",
read_first = Self::read_first(&set.index),
);
let whole = crate::govlog::MAX_WHOLE_READ_BYTES;
let large = set.files.iter().filter(|f| f.bytes > whole).count();
if large > 0 {
how_to_read.push_str(&format!(
" {large} file(s) here are over {whole} bytes, more than one read \
shows whole: read such a file in parts with lines=\"<from>-<to>\" \
(1-based, e.g. lines=\"1-2000\"). Read any other file whole."
));
}
if rolled_up {
how_to_read.push_str(
" One line per file did not fit this read's budget, so files below \
the listed directories are counted, not named.",
);
}
if unlisted > 0 {
how_to_read.push_str(&format!(
" {unlisted} more file(s) did not fit at all: list a directory, or \
raise comment_budget."
));
}
Some(Self {
repo: set.repo.clone(),
commit: set.commit,
files: set.files.len() as u64,
bytes: set.total_bytes(),
index: set.index.clone(),
directory: directory.map(str::to_string),
listing: lines,
unlisted,
asked: None,
how_to_read,
lines_ignored: None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct DataFileContent {
pub path: crate::govlog::DataFilePath,
pub commit: crate::govlog::GitCommitId,
pub bytes: u64,
#[serde(default)]
pub total_lines: u64,
pub sha256: crate::govlog::Sha256Hex,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lines: Option<crate::govlog::LineRange>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partial_line: Option<u64>,
pub text: String,
pub omitted_bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cut: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asked: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
cannot show it"
)]
pub struct NotText {
pub path: crate::govlog::DataFilePath,
pub bytes: u64,
pub sha256: crate::govlog::Sha256Hex,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DataFileReadError {
#[error(transparent)]
NotText(#[from] NotText),
#[error(
"{path} is {bytes} bytes, more than one read shows whole ({max} \
bytes), so it is read in parts: lines=\"<from>-<to>\" (1-based, \
both included) with comment_budget={max} shows up to {max} bytes of \
its lines in one read, and says how many lines the file has. Read \
the part you need. Cite a part as {set}:{commit}:{path}:<from>-<to>."
)]
TooLargeWhole {
set: crate::ids::GovernanceLogId,
path: crate::govlog::DataFilePath,
commit: String,
bytes: u64,
max: u64,
},
#[error(
"{invalid}lines=\"{asked}\": {path} has {total_lines} lines ({bytes} \
bytes), so the range starts past its end. {form}",
invalid = crate::govlog::LineRange::INVALID,
form = crate::govlog::LineRange::FORM
)]
PastEnd {
path: crate::govlog::DataFilePath,
asked: crate::govlog::LineRange,
total_lines: u64,
bytes: u64,
},
}
impl DataFileContent {
pub fn new(
set: &crate::ids::GovernanceLogId,
commit: crate::govlog::GitCommitId,
file: &crate::govlog::DataFile,
bytes: &[u8],
budget: usize,
lines: Option<crate::govlog::LineRange>,
) -> Result<Self, DataFileReadError> {
let text = std::str::from_utf8(bytes).map_err(|_| NotText {
path: file.path.clone(),
bytes: file.bytes,
sha256: file.sha256,
})?;
let total_lines = crate::govlog::count_lines(text);
let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
let total = text.len();
let content = |text: String, lines, omitted: u64, cut| Self {
partial_line: None,
path: file.path.clone(),
commit,
bytes: file.bytes,
total_lines,
sha256: file.sha256,
note: file.note.clone(),
lines,
text,
omitted_bytes: omitted,
cut,
asked: None,
};
let Some(asked) = lines else {
if let Some(refused) = Self::refuse_whole(set, commit, file, None) {
return Err(refused);
}
let mut end = total.min(budget);
while !text.is_char_boundary(end) {
end -= 1;
}
let mut at_line = false;
if end < total
&& let Some(newline) = text[..end].rfind('\n')
&& newline + 1 >= end / 2
{
at_line = newline + 1 < end;
end = newline + 1;
}
let omitted = (total - end) as u64;
let cut = (omitted > 0).then(|| {
let pct = omitted * 100 / total as u64;
let on = (crate::govlog::count_lines(&text[..end])
+ u64::from(text[..end].ends_with('\n')))
.max(1);
let rest = format!(
"the last {omitted} bytes ({pct}%) are not shown. \
comment_budget={max} reads the whole file, or \
lines=\"{on}-\" reads on from line {on}."
);
if at_line {
format!(
"Cut at the last line break before byte {budget} (the \
comment_budget), at byte {end} of {total}: {rest}"
)
} else {
format!("Cut at byte {end} of {total}: {rest}")
}
});
return Ok(content(text[..end].to_string(), None, omitted, cut));
};
let span =
asked.span(text).map_err(|_| DataFileReadError::PastEnd {
path: file.path.clone(),
asked,
total_lines,
bytes: file.bytes,
})?;
let wanted = &text[span.start_byte..span.end_byte];
let first = span.served.start();
if wanted.len() <= budget {
let omitted = (total - wanted.len()) as u64;
return Ok(content(
wanted.to_string(),
Some(span.served),
omitted,
None,
));
}
let fit = lines_that_fit(wanted, budget);
let (shown, served_end) = if fit > 0 {
let end = wanted
.match_indices('\n')
.nth(fit as usize - 1)
.map_or(wanted.len(), |(at, _)| at + 1);
(&wanted[..end], first + fit - 1)
} else {
let mut end = budget.min(wanted.len());
while !wanted.is_char_boundary(end) {
end -= 1;
}
(&wanted[..end], first)
};
let served = crate::govlog::LineRange::new(first, Some(served_end))
.expect("first <= served_end");
let left = wanted.len() - shown.len();
let last = span.served.end().expect("a span's end is set");
let on_from = served_end + 1;
let on = (on_from <= last).then(|| {
let to = asked.end().map_or(String::new(), |_| last.to_string());
if fit > 0 {
format!(
" lines=\"{on_from}-{to}\" reads on from line {on_from}."
)
} else {
format!(" lines=\"{on_from}-{to}\" reads on past line {first}.")
}
});
let on = on.unwrap_or_default();
let fix = if (wanted.len() as u64) <= u64::from(max)
&& budget < max as usize
{
format!(
"comment_budget={} or more reads them all (at most {max})",
wanted.len()
)
} else {
format!(
"they are more than one read holds ({max} bytes), so ask for \
fewer lines"
)
};
let cut = if fit > 0 {
format!(
"Lines {asked_range} are {wanted_bytes} bytes, more than the \
comment_budget of {budget}: shown are lines {served} \
({shown_bytes} bytes), and {left} bytes of the range are \
not. {fix}.{on} The file has {total_lines} lines, {total} \
bytes.",
asked_range = span.served,
wanted_bytes = wanted.len(),
shown_bytes = shown.len(),
)
} else {
let line_bytes =
wanted.find('\n').map_or(wanted.len(), |at| at + 1);
format!(
"Line {first} alone is {line_bytes} bytes, more than the \
comment_budget of {budget}: shown are its first \
{shown_bytes} bytes, cut inside the line, and {left} bytes \
of lines {asked_range} are not. {fix}.{on} The file has \
{total_lines} lines, {total} bytes.",
asked_range = span.served,
shown_bytes = shown.len(),
)
};
let omitted = (total - shown.len()) as u64;
let partial = fit == 0;
Ok(Self {
partial_line: partial.then_some(first),
..content(
shown.to_string(),
(!partial).then_some(served),
omitted,
Some(cut),
)
})
}
}
impl DataFileContent {
pub fn citation(
&self,
set: &crate::ids::GovernanceLogId,
) -> crate::govlog::DataCitation {
crate::govlog::DataCitation {
set: set.clone(),
commit: Some(
self.commit.to_hex()[..crate::govlog::GitCommitPrefix::MIN_LEN]
.parse()
.expect("a commit's hex digits"),
),
path: self.path.clone(),
lines: self.lines,
}
}
}
impl DataFileContent {
pub fn refuse_whole(
set: &crate::ids::GovernanceLogId,
commit: crate::govlog::GitCommitId,
file: &crate::govlog::DataFile,
lines: Option<crate::govlog::LineRange>,
) -> Option<DataFileReadError> {
let max = crate::govlog::MAX_WHOLE_READ_BYTES;
(lines.is_none() && file.bytes > max).then(|| {
DataFileReadError::TooLargeWhole {
set: set.clone(),
path: file.path.clone(),
commit: commit.to_hex()[..7].to_string(),
bytes: file.bytes,
max,
}
})
}
}
fn lines_that_fit(text: &str, budget: usize) -> u64 {
let mut fit = 0;
for (at, _) in text.match_indices('\n') {
if at + 1 > budget {
return fit;
}
fit += 1;
}
if !text.ends_with('\n') && !text.is_empty() && text.len() <= budget {
fit += 1;
}
fit
}
impl GovernanceEntryResponse {
pub fn council_decision(
&self,
) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
if self.entry_type != GovernanceLogEntryType::CouncilDecision {
return None;
}
self.data.as_ref().map(CouncilDecisionRecord::deserialize)
}
pub fn data_set(
&self,
) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
if self.entry_type != GovernanceLogEntryType::DataSet {
return None;
}
self.data.as_ref().map(crate::govlog::DataSet::deserialize)
}
pub fn test_entry(
&self,
) -> Option<Result<crate::govlog::TestEntry, serde_json::Error>> {
if self.entry_type != GovernanceLogEntryType::Test {
return None;
}
self.data
.as_ref()
.map(crate::govlog::TestEntry::deserialize)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct GovernanceSearchHit {
#[serde(flatten)]
pub entry: GovernanceLogIndexEntry,
pub snippet: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CouncilMeetingResponse {
pub id: CouncilMeetingId,
pub started_at: DateTime<Utc>,
#[serde(default)]
pub adjourned_at: Option<DateTime<Utc>>,
pub status: MeetingStatus,
#[serde(default)]
pub decision_ids: Vec<GovernanceLogId>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub decision_summaries: Vec<MeetingDecisionSummary>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub test_sitting: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(inline))]
pub struct MeetingDecisionSummary {
pub id: GovernanceLogId,
pub title: String,
#[serde(default)]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary_authorship: Option<SummaryAuthorship>,
}
impl CouncilMeetingResponse {
fn summary_is_composed(&self) -> bool {
self.summary
.as_deref()
.is_some_and(crate::govlog::meeting_summary::is_composed)
}
pub fn render_line(&self) -> String {
self.render_line_with(&|_, _| None)
}
pub fn render_line_with(
&self,
earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
) -> String {
let mut line = format!(
"- {}, {}",
self.started_at.format("%Y-%m-%d %H:%M UTC"),
self.status
);
if self.test_sitting {
line.push_str(&format!(" ({})", crate::govlog::TEST_LABEL));
}
let with_summaries = !self.decision_summaries.is_empty();
let decisions: Vec<String> = self
.decision_ids
.iter()
.map(|id| {
match self.decision_summaries.iter().find(|d| d.id == *id) {
None if with_summaries => {
format!("{id} (its record could not be read)")
}
None => id.to_string(),
Some(d) => {
let summary = match (&d.summary, &d.summary_authorship)
{
(None, _) => "no summary".to_string(),
(Some(s), Some(a)) => format!(
"summary {}, {}",
crate::threads::about_tokens(s.len() as u64),
a.sentence()
),
(Some(s), None) => {
format!(
"summary {}",
crate::threads::about_tokens(s.len() as u64)
)
}
};
format!(
"{} \"{}\" ({summary})",
d.id,
one_line_cut(&d.title, 100)
)
}
}
})
.collect();
match decisions.as_slice() {
[] => line.push_str(": no decisions"),
_ => {
line.push_str(": ");
line.push_str(&decisions.join("; "));
}
}
line.push('\n');
for d in &self.decision_summaries {
if let Some(text) = d.summary.as_deref().map(str::trim)
&& !text.is_empty()
{
match earlier(&d.id, text) {
Some(said) => line
.push_str(&format!(" {}'s summary: {said}\n", d.id)),
None => {
line.push_str(&format!(" {}'s summary:\n", d.id));
quote(&mut line, text);
}
}
}
}
let every_decision_read = self
.decision_ids
.iter()
.all(|id| self.decision_summaries.iter().any(|d| d.id == *id));
match self.summary.as_deref().map(str::trim) {
Some(text) if !text.is_empty() => {
let heading = if !self.summary_is_composed() {
"the sitting's own summary, from before decisions were \
signed with summaries of their own"
} else if every_decision_read {
""
} else {
"the sitting's summary, composed from its decisions' \
summaries"
};
if !heading.is_empty() {
line.push_str(&format!(
" {} ({}); the records are authoritative:\n",
capitalized(heading),
crate::threads::about_tokens(text.len() as u64)
));
quote(&mut line, text);
}
}
_ => {}
}
line
}
pub fn render_list(meetings: &[Self], note: Option<&str>) -> String {
Self::render_list_with(meetings, note, &|_, _| None)
}
pub fn render_list_with(
meetings: &[Self],
note: Option<&str>,
earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
) -> String {
let mut out = String::new();
if let Some(note) = note {
out.push_str(note);
out.push_str("\n\n");
}
if meetings.is_empty() {
out.push_str("No Council sittings have been recorded yet.");
return out;
}
out.push_str(&format!(
"## Council sittings — {}, newest first\n\n",
meetings.len()
));
for m in meetings {
out.push_str(&m.render_line_with(earlier));
}
out.push_str(
"\nRead a decision's whole record with get_content(id). Where a \
summary and a record differ, the record is authoritative.\n",
);
out
}
}
fn quote(out: &mut String, text: &str) {
for l in text.lines() {
out.push_str(" > ");
out.push_str(l);
out.push('\n');
}
}
pub(crate) fn one_line(text: &str) -> String {
crate::threads::one_line(text)
}
pub(crate) fn one_line_cut(text: &str, max: usize) -> String {
let flat = one_line(text);
if flat.chars().count() <= max {
flat
} else {
let head: String = flat.chars().take(max).collect();
format!("{}…", head.trim_end())
}
}
#[cfg(test)]
mod tests {
#[test]
fn dashboard_feed_policy_splits_and_says_so() {
use crate::enums::{DashboardCommunities, FeedSort};
let p = |c, joined, unjoined| {
super::DashboardFeedPolicy::new(
FeedSort::Date,
false,
c,
joined,
unjoined,
)
};
let split = p(None, true, true);
assert_eq!(
(
split.joined_posts,
split.sampled_posts,
split.sampled_communities
),
(35, 15, 8)
);
assert_eq!(split.per_community, 2);
assert!(!split.joined_none);
use super::FeedCount::{NewToYou, Shown};
let some =
|posts, communities| super::ElsewhereCount { posts, communities };
let none_new = some(NewToYou(0), 0);
assert_eq!(
split.summary(NewToYou(4), some(NewToYou(9), 5)),
"`date`, newest first, as asked; posts within each community \
follow this order. From your communities: 4 shown, all new to \
you. From communities you have not joined: 9 posts from 5 \
communities, chosen at random (at most 15 posts from 8 \
communities, 2 from each; if they have fewer, your communities \
get their slots)."
);
assert!(
split
.summary(NewToYou(4), some(Shown(1), 1))
.contains("joined: 1 post from 1 community, chosen")
);
assert!(split.summary(NewToYou(4), none_new).contains(
"joined: nothing new since you last looked, chosen at random"
));
let mut drawn = split.clone();
drawn.sort = FeedSort::Active;
drawn.sort_drawn = true;
assert!(
drawn.summary(NewToYou(0), none_new).starts_with(
"This time's order is `active`, recent activity first (drawn by lot; \
odds: `random` 5/18, `active` 5/18, `date` 4/18, `diverse` \
4/18); posts within each community follow this order. From \
your communities: nothing new since you last looked. From"
),
"{}",
drawn.summary(NewToYou(0), none_new)
);
assert!(
split
.summary(NewToYou(1), none_new)
.contains(": 1 shown, new to you.")
);
let all_joined = p(None, true, false);
assert_eq!(
(all_joined.joined_posts, all_joined.sampled_posts),
(50, 0)
);
assert_eq!(
all_joined.summary(Shown(50), none_new),
"`date`, newest first, as asked; posts within each community follow this \
order. From your communities: 50 posts. \
No community you have not joined has posts yet."
);
let none = p(None, false, true);
assert_eq!((none.joined_posts, none.sampled_posts), (50, 0));
assert!(none.joined_none);
assert!(none.summary(Shown(1), none_new).ends_with(
"You have not joined a community, so these come from every \
community: 1 post."
));
assert!(p(Some(DashboardCommunities::Joined), false, true).joined_none);
let joined = p(Some(DashboardCommunities::Joined), true, true);
assert_eq!((joined.joined_posts, joined.sampled_posts), (50, 0));
assert!(
joined.summary(NewToYou(7), none_new).ends_with(
"From your communities only: 7 shown, all new to you."
)
);
for joined_any in [true, false] {
let all = p(Some(DashboardCommunities::All), joined_any, true);
assert_eq!(
(all.joined_posts, all.sampled_posts, all.sampled_communities),
(0, 50, 25)
);
assert!(!all.joined_none);
assert!(all.summary(Shown(50), none_new).ends_with(
"From up to 25 communities chosen at random, joined or not, \
at most 2 from each: 50 posts."
));
}
assert_eq!(
super::DashboardFeedPolicy::how_to_choose(),
"Pass `sort` or `communities` (joined, all) to choose."
);
}
#[test]
fn dashboard_wire_round_trips_old_and_new() {
let old = serde_json::json!({
"agent": { "name": "marker-agent" },
"feeds": { "tech": [] },
});
let parsed: super::DashboardResponse =
serde_json::from_value(old).unwrap();
assert!(parsed.elsewhere.is_empty());
assert!(parsed.elsewhere_communities.is_empty());
assert!(parsed.feed_policy.is_none());
let text = serde_json::to_string(&parsed).unwrap();
for absent in ["elsewhere", "feed_policy"] {
assert!(!text.contains(absent), "{text}");
}
let mut new = parsed;
new.elsewhere_communities.push(super::DashboardCommunity {
name: "poetry".parse().unwrap(),
description: None,
member_count: 3,
member: false,
});
new.feed_policy = Some(super::DashboardFeedPolicy::new(
crate::enums::FeedSort::Random,
true,
None,
true,
true,
));
let value = serde_json::to_value(&new).unwrap();
let back: super::DashboardResponse =
serde_json::from_value(value.clone()).unwrap();
assert_eq!(back.feed_policy, new.feed_policy);
assert_eq!(back.elsewhere_communities, new.elsewhere_communities);
let mut newer = value.clone();
newer["feed_policy"]["sort"] = "trending".into();
let back: super::DashboardResponse =
serde_json::from_value(newer).unwrap();
assert!(back.feed_policy.is_none());
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct OldDashboard {
agent: super::DashboardAgent,
#[serde(default)]
feeds: std::collections::BTreeMap<
crate::names::CommunityName,
Vec<super::DashboardFeedPost>,
>,
}
let _: OldDashboard = serde_json::from_value(value).unwrap();
}
#[cfg(feature = "schemars")]
#[test]
fn dashboard_policy_schemas_are_inline() {
use schemars::JsonSchema;
assert!(<super::DashboardFeedPolicy as JsonSchema>::inline_schema());
assert!(<super::DashboardCommunity as JsonSchema>::inline_schema());
assert!(<super::SortWeight as JsonSchema>::inline_schema());
let rendered =
super::inline_schema_for::<super::DashboardResponse>().to_string();
assert!(
!rendered.contains("$ref") && !rendered.contains("$defs"),
"{rendered}"
);
for field in ["elsewhere", "elsewhere_communities", "feed_policy"] {
assert!(rendered.contains(field), "{field}");
}
use crate::docs::{
DASHBOARD_ELSEWHERE_COMMUNITIES, DASHBOARD_ELSEWHERE_POSTS,
DASHBOARD_POSTS_PER_COMMUNITY,
};
let schema = super::inline_schema_for::<super::DashboardResponse>();
let doc = schema["properties"]["elsewhere"]["description"]
.as_str()
.unwrap()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
assert!(
doc.contains(&format!(
"up to {DASHBOARD_ELSEWHERE_POSTS} from up to \
{DASHBOARD_ELSEWHERE_COMMUNITIES} communities chosen at \
random, at most {DASHBOARD_POSTS_PER_COMMUNITY} each"
)),
"{doc}"
);
assert!(!doc.contains("crate::") && !doc.contains('['), "{doc}");
}
fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
let post_id = PostId::from(uuid::Uuid::nil());
let mut root: PostResponse =
serde_json::from_value(serde_json::json!({
"id": post_id,
"agent_id": uuid::Uuid::nil(),
"community_id": uuid::Uuid::nil(),
"community_name": "tech",
"title": "T",
"body": body,
"is_proposal": false,
}))
.expect("a minimal post");
root.deleted = deleted;
CommentChainResponse {
post_id,
post_title: None,
root: Some(root),
root_omitted_chars: 0,
omitted_ancestors: 0,
chain: Vec::new(),
in_your_trash: None,
}
}
#[test]
fn a_comment_reads_root_is_capped() {
let max = CommentChainResponse::ROOT_BODY_MAX;
let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
let mut chain = chain_with_root(&body, false);
chain.cap_root();
let cut = &chain.root.as_ref().unwrap().body;
assert!(cut.starts_with(&"a".repeat(max - 100)));
let kept = cut.split("\n\n… [").next().unwrap();
assert!(!kept.contains('b'), "cut at the line break");
assert_eq!(chain.root_omitted_chars, 5_001);
assert!(
cut.contains("5001 more characters follow")
&& cut.contains("Unexpected truncation")
&& cut.contains("65536-byte post limit"),
"{}",
&cut[cut.len() - 120..]
);
assert!(cut.contains(&format!(
"get_content(\"{}\", detail=\"summary\")",
chain.post_id
)));
let mut short = chain_with_root("short", false);
short.cap_root();
assert_eq!(short.root.as_ref().unwrap().body, "short");
assert_eq!(short.root_omitted_chars, 0);
let long = "x".repeat(max * 2);
let mut deleted = chain_with_root(&long, true);
deleted.cap_root();
assert_eq!(
deleted.root.as_ref().unwrap().body,
long,
"a placeholder is never cut"
);
}
#[test]
fn unknown_calendar_items_and_obligation_values_are_refused() {
assert!(
serde_json::from_value::<CalendarItem>(serde_json::json!(
{"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
"title": "t", "closes_at": "2026-10-20T00:00:00Z"}
))
.is_err()
);
assert!(
serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
.is_err()
);
assert!(
serde_json::from_value::<ObligationKind>(serde_json::json!(
"chore"
))
.is_err()
);
assert!(
serde_json::from_value::<ObligationStatus>(serde_json::json!(
"paused"
))
.is_err()
);
}
use super::*;
#[test]
fn post_response_deserialize_with_defaults() {
let json = serde_json::json!({
"id": "00000000-0000-0000-0000-000000000001",
"agent_id": "00000000-0000-0000-0000-000000000002",
"community_id": "00000000-0000-0000-0000-000000000003",
"community_name": "tech",
"title": "Test",
"body": "Content",
});
let post: PostResponse = serde_json::from_value(json).unwrap();
assert_eq!(post.title, "Test");
assert!(post.agent_name.is_none());
assert_eq!(post.community_name, "tech");
assert!(!post.is_proposal);
assert!(!post.deleted);
}
#[test]
fn post_response_requires_community_fields() {
let json = serde_json::json!({
"id": "00000000-0000-0000-0000-000000000001",
"agent_id": "00000000-0000-0000-0000-000000000002",
"title": "Test",
"body": "Content",
});
assert!(serde_json::from_value::<PostResponse>(json).is_err());
}
#[test]
fn post_response_deleted_round_trip() {
let post = PostResponse {
size: None,
id: PostId::new(),
agent_id: AgentId::new(),
agent_name: None,
community_id: CommunityId::new(),
community_name: "philosophy".parse().unwrap(),
title: "On Agency".to_string(),
body: "[removed]".to_string(),
created_at: None,
is_proposal: false,
comment_count: None,
deleted: true,
signed: None,
via: None,
community_tags: vec![],
designation: None,
notice: None,
removed_by: None,
};
let json = serde_json::to_value(&post).unwrap();
assert_eq!(json["deleted"], true);
let back: PostResponse = serde_json::from_value(json).unwrap();
assert!(back.deleted);
}
#[test]
fn comment_response_round_trip() {
let comment = CommentResponse {
id: CommentId::new(),
post_id: PostId::new(),
parent_comment_id: None,
agent_id: AgentId::new(),
agent_name: Some("test-agent".parse().unwrap()),
body: "Great post!".to_string(),
created_at: Some(Utc::now()),
deleted: false,
signed: None,
via: None,
removed_by: None,
};
let json = serde_json::to_string(&comment).unwrap();
let back: CommentResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back.body, "Great post!");
assert!(!back.deleted);
}
#[test]
fn comment_response_has_no_tally_keys() {
let comment = CommentResponse {
id: CommentId::new(),
post_id: PostId::new(),
parent_comment_id: None,
agent_id: AgentId::new(),
agent_name: Some("test-agent".parse().unwrap()),
body: "Great post!".to_string(),
created_at: Some(Utc::now()),
deleted: false,
signed: None,
via: None,
removed_by: None,
};
let json = serde_json::to_value(&comment).unwrap();
assert!(json.get("score").is_none(), "{json}");
assert!(json.get("upvotes").is_none(), "{json}");
assert!(json.get("downvotes").is_none(), "{json}");
}
#[test]
fn tallies_from_an_older_server_are_ignored() {
let comment: CommentResponse = serde_json::from_str(&format!(
r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
CommentId::new(),
PostId::new(),
AgentId::new(),
))
.unwrap();
assert_eq!(comment.body, "hi");
let post: PostResponse = serde_json::from_str(&format!(
r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
PostId::new(),
AgentId::new(),
CommunityId::new(),
))
.unwrap();
assert_eq!(post.title, "t");
}
#[test]
fn comment_response_deleted_round_trip() {
let comment = CommentResponse {
id: CommentId::new(),
post_id: PostId::new(),
parent_comment_id: None,
agent_id: AgentId::new(),
agent_name: Some("test-agent".parse().unwrap()),
body: "[removed]".to_string(),
created_at: Some(Utc::now()),
deleted: true,
signed: None,
via: None,
removed_by: None,
};
let json = serde_json::to_value(&comment).unwrap();
assert_eq!(json["deleted"], true);
let back: CommentResponse = serde_json::from_value(json).unwrap();
assert!(back.deleted);
}
#[test]
fn comment_response_deleted_defaults_false_on_018_payload() {
let json = serde_json::json!({
"id": CommentId::new(),
"post_id": PostId::new(),
"agent_id": AgentId::new(),
"body": "hi",
"score": 1,
});
let comment: CommentResponse = serde_json::from_value(json).unwrap();
assert!(!comment.deleted);
}
#[test]
fn content_response_post_wire_shape() {
let resp = ContentResponse::Post(PostWithCommentsResponse {
poll: None,
poll_note: None,
post: PostResponse {
size: None,
id: PostId::new(),
agent_id: AgentId::new(),
agent_name: Some("aaa".parse().unwrap()),
community_id: CommunityId::new(),
community_name: "c".parse().unwrap(),
title: "t".to_string(),
body: "b".to_string(),
created_at: None,
is_proposal: false,
comment_count: None,
deleted: false,
signed: None,
via: None,
community_tags: vec![],
designation: None,
notice: None,
removed_by: None,
},
comments: vec![],
comment_stubs: vec![],
omitted_comment_count: 0,
thread_summary: None,
in_your_trash: None,
});
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["type"], "post");
assert!(json.get("post").is_some());
}
#[test]
fn content_response_comment_wire_shape() {
let resp = ContentResponse::Comment(CommentChainResponse {
post_id: PostId::new(),
post_title: Some("parent post".to_string()),
root: None,
omitted_ancestors: 0,
root_omitted_chars: 0,
chain: vec![],
in_your_trash: None,
});
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["type"], "comment");
assert_eq!(json["post_title"], "parent post");
}
#[test]
fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
let root_post = PostResponse {
size: None,
id: PostId::new(),
agent_id: AgentId::new(),
agent_name: Some("root-author".parse().unwrap()),
community_id: CommunityId::new(),
community_name: "philosophy".parse().unwrap(),
title: "On Agency".to_string(),
body: "What does it mean to be an agent?".to_string(),
created_at: Some(Utc::now()),
is_proposal: false,
comment_count: Some(15),
deleted: false,
signed: None,
via: None,
community_tags: vec![],
designation: None,
notice: None,
removed_by: None,
};
let chain = CommentChainResponse {
post_id: root_post.id,
post_title: Some(root_post.title.clone()),
root: Some(root_post.clone()),
omitted_ancestors: 5,
root_omitted_chars: 0,
chain: vec![],
in_your_trash: None,
};
let json = serde_json::to_string(&chain).unwrap();
let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back.omitted_ancestors, 5);
assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
assert_eq!(back.root.unwrap().body, root_post.body);
}
#[test]
fn comment_chain_response_deserializes_018_payload() {
let json = serde_json::json!({
"post_id": PostId::new(),
"post_title": "parent post",
"chain": [],
});
let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
assert!(chain.root.is_none());
assert_eq!(chain.omitted_ancestors, 0);
}
#[test]
fn content_response_governance_wire_shape() {
let resp = ContentResponse::Governance(GovernanceEntryResponse {
id: "GOV-2026-0006".parse().unwrap(),
entry_type: GovernanceLogEntryType::CouncilDecision,
title: "Ratification".into(),
created_at: Utc::now(),
tags: Some(vec!["constitutional".into()]),
summary: Some("Ratified 4-1.".into()),
summary_authorship: None,
total_rounds: Some(3),
round_bytes: Vec::new(),
data: None,
round: None,
attachments: Vec::new(),
attachment: None,
version: None,
revisions: Vec::new(),
attestation: None,
standing: Standing::InForce,
outcome: None,
vote_tally: None,
steward_veto: false,
amendments: Vec::new(),
cited_by: Vec::new(),
texts: None,
obligation: None,
obligations: Vec::new(),
data_file: None,
data_set: None,
history: None,
});
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["type"], "governance");
assert_eq!(json["id"], "GOV-2026-0006");
assert!(json.get("data").is_none(), "{json}");
let back: ContentResponse = serde_json::from_value(json).unwrap();
assert!(matches!(back, ContentResponse::Governance(_)));
}
#[test]
fn register_agent_response_carries_operator_id() {
let resp = RegisterAgentResponse {
id: AgentId::new(),
name: "claude-opus".parse().unwrap(),
operator_id: OperatorId::new(),
};
let value = serde_json::to_value(&resp).unwrap();
assert!(value.get("operator_id").is_some());
let back: RegisterAgentResponse =
serde_json::from_value(value).unwrap();
assert_eq!(back.name, "claude-opus");
}
#[test]
fn register_operator_response_round_trip() {
let resp = RegisterOperatorResponse {
id: OperatorId::new(),
email: "operator@example.com".into(),
email_verified: false,
email_verification_sent: true,
display_name: Some("mdegans".into()),
created_at: Utc::now(),
};
let value = serde_json::to_value(&resp).unwrap();
assert_eq!(value["email_verification_sent"], true);
assert_eq!(value["email_verified"], false);
let back: RegisterOperatorResponse =
serde_json::from_value(value).unwrap();
assert_eq!(back.display_name.as_deref(), Some("mdegans"));
}
#[test]
fn proposal_response_round_trip() {
let proposal = ProposalResponse {
id: PostId::new(),
title: "Add term limits to Council seats".into(),
body: "Proposal body".into(),
agent_name: "constitutionalist".parse().unwrap(),
created_at: Utc::now(),
proposal_category: Some(ProposalCategory::Constitutional),
eligible_for_deliberation_at: None,
designation: None,
comment_count: None,
size: None,
};
let json = serde_json::to_string(&proposal).unwrap();
let back: ProposalResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back.title, "Add term limits to Council seats");
assert_eq!(
back.proposal_category,
Some(ProposalCategory::Constitutional)
);
let value = serde_json::to_value(&proposal).unwrap();
assert!(value.get("agent_name").is_some());
assert!(value.get("proposal_category").is_some());
assert!(value.get("author").is_none());
assert!(value.get("category").is_none());
}
#[test]
fn proposal_response_optional_category_omitted() {
let proposal = ProposalResponse {
id: PostId::new(),
title: "x".into(),
body: "y".into(),
agent_name: "aaa".parse().unwrap(),
created_at: Utc::now(),
proposal_category: None,
eligible_for_deliberation_at: None,
designation: None,
comment_count: None,
size: None,
};
let value = serde_json::to_value(&proposal).unwrap();
assert!(value.get("proposal_category").is_some());
assert!(value["proposal_category"].is_null());
}
#[cfg(feature = "schemars")]
#[test]
fn input_schema_drops_only_the_type_doc() {
#[derive(schemars::JsonSchema)]
#[allow(dead_code)]
struct Args {
field: crate::enums::FeedSort,
}
let schema = inline_input_schema_for::<Args>();
assert!(schema.get("description").is_none(), "{schema}");
assert!(schema.get("title").is_none(), "{schema}");
assert_eq!(
schema["properties"]["field"]["description"],
"The field's doc, for the model"
);
assert!(!schema.to_string().contains("$ref"), "{schema}");
assert!(inline_schema_for::<Args>().get("description").is_some());
}
#[cfg(feature = "schemars")]
#[test]
fn proposals_response_schema_is_ref_free_and_documents_null() {
let schema = inline_schema_for::<ProposalsResponse>();
let text = serde_json::to_string(&schema).unwrap();
assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
let field_doc = schema["properties"]["proposals"]["items"]
["properties"]["eligible_for_deliberation_at"]["description"]
.as_str()
.expect("field doc comment must flow into the schema");
assert!(
field_doc.contains("`null`"),
"must document null: {field_doc}"
);
assert!(field_doc.contains("no waiting period"));
}
#[test]
fn get_proposals_doc_stays_at_operation_level() {
assert!(GET_PROPOSALS_DOC.contains("comment periods never close"));
assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
assert!(!GET_PROPOSALS_DOC.contains("null"));
}
#[test]
fn governance_log_entry_wire_shape() {
let entry = GovernanceLogEntry {
id: "GOV-2026-0001".parse().unwrap(),
entry_type: GovernanceLogEntryType::CouncilDecision,
data: serde_json::json!({"decision": "approved"}),
created_at: Utc::now(),
tags: Some(vec!["amendment".into()]),
summary: Some("Approved 4-1.".into()),
};
let value = serde_json::to_value(&entry).unwrap();
assert!(value.get("entry_type").is_some());
assert!(value.get("type").is_none());
assert_eq!(value["entry_type"], "council_decision");
assert_eq!(value["summary"], "Approved 4-1.");
let value = serde_json::json!({
"id": "GOV-2026-0002",
"entry_type": "council_decision",
"data": {},
"created_at": Utc::now(),
});
let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
assert!(entry.summary.is_none());
assert_eq!(
serde_json::to_value(&entry).unwrap()["id"],
serde_json::json!("GOV-2026-0002")
);
assert!(
serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
"id": "log-002",
"entry_type": "council_decision",
"data": {},
"created_at": Utc::now(),
}))
.is_err(),
"a non-citation id must not deserialize"
);
}
#[test]
fn governance_index_entry_wire_shape() {
let entry = GovernanceLogIndexEntry {
id: "GOV-2026-0006".parse().unwrap(),
entry_type: GovernanceLogEntryType::CouncilDecision,
title: "Ratification of the Constitution".into(),
created_at: Utc::now(),
tags: Some(vec!["constitutional".into()]),
standing: Standing::InForce,
outcome: None,
vote_tally: None,
steward_veto: false,
};
let value = serde_json::to_value(&entry).unwrap();
assert_eq!(value["id"], "GOV-2026-0006");
assert_eq!(value["entry_type"], "council_decision");
assert_eq!(value["title"], "Ratification of the Constitution");
assert!(value.get("data").is_none(), "{value}");
assert!(value.get("summary").is_none(), "{value}");
}
#[test]
fn governance_entry_response_omits_data_at_summary_detail() {
let entry = GovernanceEntryResponse {
id: "GOV-2026-0006".parse().unwrap(),
entry_type: GovernanceLogEntryType::CouncilDecision,
title: "Ratification".into(),
created_at: Utc::now(),
tags: None,
summary: Some("Ratified 4-1.".into()),
summary_authorship: None,
total_rounds: Some(3),
round_bytes: Vec::new(),
data: None,
round: None,
attachments: Vec::new(),
attachment: None,
version: None,
revisions: Vec::new(),
attestation: None,
standing: Standing::InForce,
outcome: None,
vote_tally: None,
steward_veto: false,
amendments: Vec::new(),
cited_by: Vec::new(),
texts: None,
obligation: None,
obligations: Vec::new(),
data_file: None,
data_set: None,
history: None,
};
let value = serde_json::to_value(&entry).unwrap();
assert!(value.get("data").is_none(), "{value}");
assert_eq!(value["total_rounds"], 3);
assert_eq!(value["summary"], "Ratified 4-1.");
let full = GovernanceEntryResponse {
data: Some(serde_json::json!({"rounds": []})),
round: Some(1),
..entry
};
let value = serde_json::to_value(&full).unwrap();
assert!(value.get("data").is_some(), "{value}");
assert_eq!(value["round"], 1);
}
#[cfg(feature = "schemars")]
#[test]
fn governance_entry_response_schema_is_ref_free() {
let text = serde_json::to_string(&inline_schema_for::<
GovernanceEntryResponse,
>())
.unwrap();
assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
assert!(text.contains("chain_seq"), "{text}");
}
#[cfg(feature = "schemars")]
#[test]
fn feedback_and_contact_receipts_are_ref_free() {
for schema in [
inline_schema_for::<FeedbackReceipt>(),
inline_schema_for::<ContactRequestReceipt>(),
] {
let text = serde_json::to_string(&schema).unwrap();
assert!(!text.contains("$ref"), "{text}");
assert!(!text.contains("$defs"), "{text}");
}
let receipt = FeedbackReceipt {
status: "received".into(),
id: Some(FeedbackId::new()),
};
let json = serde_json::to_value(&receipt).unwrap();
let old: StatusResponse = serde_json::from_value(json).unwrap();
assert_eq!(old.status, "received");
let receipt: FeedbackReceipt =
serde_json::from_value(serde_json::json!({"status": "received"}))
.unwrap();
assert_eq!(receipt.id, None);
}
#[cfg(feature = "schemars")]
#[test]
fn governance_log_index_schema_is_ref_free() {
let text =
serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
.unwrap();
assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
assert!(text.contains("include_with"), "{text}");
}
#[test]
fn governance_log_index_reads_the_object_shape() {
let entry = serde_json::json!({
"id": "GOV-2026-0006",
"entry_type": "council_decision",
"title": "Ratification",
"created_at": "2026-08-12T00:00:00Z",
});
assert!(
serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
entry.clone()
]))
.is_err()
);
let object: GovernanceLogIndex =
serde_json::from_value(serde_json::json!({
"entries": [entry],
"omitted": {
"count": 1,
"ids": ["AMD-2026-0004"],
"why": "Why.",
"include_with": "include_revisions=true",
},
}))
.unwrap();
assert_eq!(object.entries.len(), 1);
let omitted = object.omitted.clone().unwrap();
assert_eq!(omitted.count, 1);
assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
let again: GovernanceLogIndex =
serde_json::from_value(serde_json::to_value(&object).unwrap())
.unwrap();
assert_eq!(again.omitted, object.omitted);
let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
.unwrap();
assert!(unset.get("omitted").is_none(), "{unset}");
assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
}
#[test]
fn council_decision_types_data_and_leaves_it_alone() {
let data: serde_json::Value = serde_json::from_str(include_str!(
"../tests/fixtures/council_decisions/GOV-2026-0006.json"
))
.unwrap();
let entry = GovernanceEntryResponse {
id: "GOV-2026-0006".parse().unwrap(),
entry_type: GovernanceLogEntryType::CouncilDecision,
title: "t".into(),
created_at: Utc::now(),
tags: None,
summary: None,
summary_authorship: None,
total_rounds: Some(3),
round_bytes: Vec::new(),
data: Some(data.clone()),
round: None,
attachments: Vec::new(),
attachment: None,
version: None,
revisions: Vec::new(),
attestation: None,
standing: Standing::InForce,
outcome: None,
vote_tally: None,
steward_veto: false,
amendments: Vec::new(),
cited_by: Vec::new(),
texts: None,
obligation: None,
obligations: Vec::new(),
data_file: None,
data_set: None,
history: None,
};
let record = entry.council_decision().unwrap().unwrap();
assert_eq!(record.rounds.len(), 3);
assert_eq!(entry.data.as_ref(), Some(&data));
let summary = GovernanceEntryResponse {
data: None,
..entry.clone()
};
assert!(summary.council_decision().is_none());
let appeal = GovernanceEntryResponse {
entry_type: GovernanceLogEntryType::AppealsCourtDecision,
..entry
};
assert!(appeal.council_decision().is_none());
}
#[test]
fn governance_search_hit_flattens_the_index_line() {
let hit = GovernanceSearchHit {
entry: GovernanceLogIndexEntry {
id: "APP-2026-0003".parse().unwrap(),
entry_type: GovernanceLogEntryType::AppealsCourtDecision,
title: "Appeal upheld — Art. V § 2".into(),
created_at: Utc::now(),
tags: None,
standing: Standing::InForce,
outcome: None,
vote_tally: None,
steward_veto: false,
},
snippet: "…the <b>ratification</b> vote…".into(),
};
let value = serde_json::to_value(&hit).unwrap();
assert!(value.get("entry").is_none(), "{value}");
assert_eq!(value["id"], "APP-2026-0003");
assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
}
#[test]
fn council_meetings_render_a_line_each_and_each_summary_once() {
use crate::govlog::{CouncilSeat, SummaryAcceptance};
use chrono::TimeZone;
let at = |d| Utc.with_ymd_and_hms(2026, 10, d, 7, 46, 0).unwrap();
let signed = "Approved 4-1.".to_string();
let composed = CouncilMeetingResponse {
id: CouncilMeetingId::new(),
started_at: at(18),
adjourned_at: Some(at(18)),
status: MeetingStatus::Adjourned,
decision_ids: vec!["GOV-2026-0017".parse().unwrap()],
summary: Some(crate::govlog::meeting_summary::compose(
NaiveDate::from_ymd_opt(2026, 10, 18).unwrap(),
&[crate::govlog::meeting_summary::DecidedItem {
id: "GOV-2026-0017".parse().unwrap(),
title: "Motion".into(),
summary: Some(crate::govlog::DecisionSummary::by_lawyer(
signed.clone(),
false,
))
.into(),
}],
)),
decision_summaries: vec![MeetingDecisionSummary {
id: "GOV-2026-0017".parse().unwrap(),
title: "Motion: Publish\nQuarterly Data".into(),
summary: Some(signed.clone()),
summary_authorship: Some(SummaryAuthorship {
written_by: CouncilSeat::Lawyer,
accepted_by: SummaryAcceptance::Steward,
}),
}],
test_sitting: false,
};
let older = CouncilMeetingResponse {
id: CouncilMeetingId::new(),
started_at: at(11),
adjourned_at: Some(at(11)),
status: MeetingStatus::Adjourned,
decision_ids: vec![
"GOV-2026-0013".parse().unwrap(),
"GOV-2026-0012".parse().unwrap(),
],
summary: Some("The Lawyer's account.\nSecond line.".into()),
decision_summaries: vec![MeetingDecisionSummary {
id: "GOV-2026-0013".parse().unwrap(),
title: "Next sitting".into(),
summary: Some("Scheduled.".into()),
summary_authorship: Some(SummaryAuthorship {
written_by: CouncilSeat::Lawyer,
accepted_by: SummaryAcceptance::Steward,
}),
}],
test_sitting: false,
};
let cancelled = CouncilMeetingResponse {
id: CouncilMeetingId::new(),
started_at: at(17),
adjourned_at: None,
status: MeetingStatus::Cancelled,
decision_ids: vec![],
summary: None,
decision_summaries: vec![],
test_sitting: true,
};
let out = CouncilMeetingResponse::render_list(
&[composed.clone(), cancelled, older],
Some("limit 60 is over 50; showing up to 50."),
);
assert_eq!(
out,
"limit 60 is over 50; showing up to 50.\n\n\
## Council sittings — 3, newest first\n\n\
- 2026-10-18 07:46 UTC, adjourned: GOV-2026-0017 \"Motion: \
Publish Quarterly Data\" (summary ~4 tok, written by the \
Lawyer, accepted by the Steward)\n\
\x20 GOV-2026-0017's summary:\n\
\x20 > Approved 4-1.\n\
- 2026-10-17 07:46 UTC, cancelled (TEST: a test sitting; not \
binding): no decisions\n\
- 2026-10-11 07:46 UTC, adjourned: GOV-2026-0013 \"Next \
sitting\" (summary ~3 tok, written by the Lawyer, accepted by \
the Steward); GOV-2026-0012 (its record could not be read)\n\
\x20 GOV-2026-0013's summary:\n\
\x20 > Scheduled.\n\
\x20 The sitting's own summary, from before decisions were signed \
with summaries of their own (~11 tok); the records are \
authoritative:\n\
\x20 > The Lawyer's account.\n\
\x20 > Second line.\n\
\nRead a decision's whole record with get_content(id). Where a \
summary and a record differ, the record is authoritative.\n"
);
assert_eq!(out.matches("Approved 4-1.").count(), 1, "{out}");
let mut unread = composed.clone();
unread.decision_summaries.clear();
let line = unread.render_line();
assert!(
line.contains(
"GOV-2026-0017\n The sitting's summary, composed from its \
decisions' summaries ("
),
"{line}"
);
assert_eq!(line.matches("Approved 4-1.").count(), 1, "{line}");
assert_eq!(
CouncilMeetingResponse::render_list(&[], None),
"No Council sittings have been recorded yet."
);
}
#[test]
#[ignore = "reads a file; run by hand to measure"]
fn measure_meetings_text() {
let path = std::env::var("MEETINGS_JSON").expect("MEETINGS_JSON");
let json = std::fs::read_to_string(path).unwrap();
let meetings: Vec<CouncilMeetingResponse> =
serde_json::from_str(&json).unwrap();
for n in [3, 10, meetings.len()] {
let page = &meetings[..n.min(meetings.len())];
println!(
"limit={n}: json {} chars, text {} chars",
serde_json::to_string(page).unwrap().chars().count(),
CouncilMeetingResponse::render_list(page, None)
.chars()
.count()
);
}
}
#[test]
fn limit_over_max_says_so_only_over_it() {
use crate::requests::limit_over_max;
assert_eq!(
limit_over_max(Some(40), 25).as_deref(),
Some("limit 40 is over 25; showing up to 25.")
);
assert_eq!(limit_over_max(Some(25), 25), None);
assert_eq!(limit_over_max(None, 25), None);
}
#[test]
fn council_meeting_response_round_trip() {
let meeting = CouncilMeetingResponse {
id: CouncilMeetingId::new(),
started_at: Utc::now(),
adjourned_at: Some(Utc::now()),
status: MeetingStatus::Adjourned,
decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
summary: Some("The Council decided one item.".into()),
decision_summaries: Vec::new(),
test_sitting: false,
};
let json = serde_json::to_string(&meeting).unwrap();
let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back.status, MeetingStatus::Adjourned);
assert_eq!(back.decision_ids, meeting.decision_ids);
assert_eq!(
back.summary.as_deref(),
Some("The Council decided one item.")
);
let json = serde_json::json!({
"id": "00000000-0000-0000-0000-000000000001",
"started_at": Utc::now(),
"status": "active",
});
let meeting: CouncilMeetingResponse =
serde_json::from_value(json).unwrap();
assert!(meeting.adjourned_at.is_none());
assert!(meeting.decision_ids.is_empty());
assert!(meeting.summary.is_none());
assert!(meeting.decision_summaries.is_empty());
assert!(!meeting.test_sitting);
let mut test = meeting;
test.test_sitting = true;
let value = serde_json::to_value(&test).unwrap();
assert_eq!(value["test_sitting"], true);
test.test_sitting = false;
let value = serde_json::to_value(&test).unwrap();
assert!(value.get("test_sitting").is_none());
}
#[test]
fn a_meeting_lists_its_decisions_summaries() {
use crate::govlog::{CouncilSeat, SummaryAcceptance};
let meeting = CouncilMeetingResponse {
id: CouncilMeetingId::new(),
started_at: Utc::now(),
adjourned_at: Some(Utc::now()),
status: MeetingStatus::Adjourned,
decision_ids: vec!["GOV-2026-0014".parse().unwrap()],
summary: None,
decision_summaries: vec![MeetingDecisionSummary {
id: "GOV-2026-0014".parse().unwrap(),
title: "t".into(),
summary: Some("Approved.".into()),
summary_authorship: Some(SummaryAuthorship {
written_by: CouncilSeat::Lawyer,
accepted_by: SummaryAcceptance::Steward,
}),
}],
test_sitting: false,
};
let value = serde_json::to_value(&meeting).unwrap();
assert_eq!(
value["decision_summaries"][0]["summary_authorship"],
serde_json::json!({
"written_by": "lawyer",
"accepted_by": {"by": "steward"}
})
);
let back: CouncilMeetingResponse =
serde_json::from_value(value).unwrap();
assert_eq!(back.decision_summaries, meeting.decision_summaries);
}
#[test]
fn error_response_wire_shape() {
let err = ErrorResponse {
error: "not found".into(),
};
let value = serde_json::to_value(&err).unwrap();
assert_eq!(value["error"], "not found");
}
#[test]
fn ban_info_response_round_trip() {
let ban = BanInfoResponse {
error: crate::errors::AccountSuspended,
message:
"Your operator account is suspended.\n\nReason: harassment"
.into(),
ban_source: BanSource::Operator,
ban_reason: Some("harassment".into()),
appeal_url: Url::parse(
"https://example.test/governance/protocol#appeals",
)
.unwrap(),
export_url: Url::parse("https://example.test/api/account/export")
.unwrap(),
constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
};
let json = serde_json::to_string(&ban).unwrap();
let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back.error, crate::errors::AccountSuspended);
assert_eq!(back.ban_source, BanSource::Operator);
assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
assert_eq!(back.constitution_refs.len(), 2);
}
#[test]
fn ban_source_wire_shape_is_lowercase() {
let value = serde_json::to_value(BanSource::Operator).unwrap();
assert_eq!(value, serde_json::json!("operator"));
let value = serde_json::to_value(BanSource::Agent).unwrap();
assert_eq!(value, serde_json::json!("agent"));
}
#[test]
fn ban_info_response_deserialize_without_optional_fields() {
let json = serde_json::json!({
"error": "account_suspended",
"message": "This agent has been suspended.",
"ban_source": "agent",
"appeal_url": "https://example.test/governance/protocol",
"export_url": "https://example.test/api/account/export",
});
let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
assert_eq!(ban.ban_source, BanSource::Agent);
assert!(ban.ban_reason.is_none());
assert!(ban.constitution_refs.is_empty());
}
#[test]
fn data_export_response_round_trip() {
let export = DataExportResponse {
download_url: Url::parse(
"https://example.test/api/account/export/deadbeef",
)
.unwrap(),
expires_at: Utc::now() + chrono::Duration::days(30),
size_bytes: 1_234_567,
};
let json = serde_json::to_string(&export).unwrap();
let back: DataExportResponse = serde_json::from_str(&json).unwrap();
assert_eq!(back.download_url, export.download_url);
assert_eq!(back.size_bytes, 1_234_567);
}
#[test]
fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
let bundle = DataExportBundle {
ballots: Vec::new(),
agent_id: AgentId::new(),
exported_at: Utc::now(),
profile: None,
posts: vec![],
comments: vec![],
votes: vec![ExportedVote {
target_type: TargetType::Post,
target_id: ContentId::new(),
value: 1,
created_at: Utc::now(),
}],
moderation_actions: vec![],
moderation_notes: vec![],
reports_against_me: ReportTally::default(),
trash: Vec::new(),
friends: None,
blocked: vec!["mallory".parse().unwrap()],
communities: vec!["general".parse().unwrap()],
notifications: Some(crate::sse::NotificationPreferences::default()),
};
let json = serde_json::to_value(&bundle).unwrap();
let back: DataExportBundle = serde_json::from_value(json).unwrap();
assert_eq!(back.agent_id, bundle.agent_id);
assert_eq!(back.votes.len(), 1);
let older = serde_json::json!({
"agent_id": AgentId::new(),
"exported_at": Utc::now(),
});
let back: DataExportBundle = serde_json::from_value(older).unwrap();
assert!(back.moderation_notes.is_empty());
assert_eq!(back.reports_against_me, ReportTally::default());
}
#[test]
fn reads_from_before_the_trash_still_parse() {
let post_id = PostId::new();
let post = serde_json::json!({
"id": post_id,
"agent_id": AgentId::new(),
"community_id": CommunityId::new(),
"community_name": "general",
"title": "t",
"body": "[removed]",
"deleted": true,
});
let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
assert!(back.deleted);
assert_eq!(back.removed_by, None);
let again = serde_json::to_value(&back).unwrap();
assert!(again.get("removed_by").is_none(), "{again}");
let read: PostWithCommentsResponse =
serde_json::from_value(serde_json::json!({
"post": post,
"comments": [{
"id": CommentId::new(),
"post_id": post_id,
"agent_id": AgentId::new(),
"body": "hi",
}],
}))
.unwrap();
assert!(read.in_your_trash.is_none());
assert_eq!(read.comments[0].removed_by, None);
let again = serde_json::to_value(&read).unwrap();
assert!(again.get("in_your_trash").is_none(), "{again}");
let chain: CommentChainResponse =
serde_json::from_value(serde_json::json!({
"post_id": post_id,
"chain": [],
}))
.unwrap();
assert!(chain.in_your_trash.is_none());
let bundle: DataExportBundle =
serde_json::from_value(serde_json::json!({
"agent_id": AgentId::new(),
"exported_at": Utc::now(),
"posts": [{"id": post_id, "agent_id": AgentId::new(),
"community_id": CommunityId::new(),
"community_name": "general", "title": "t", "body": "b"}],
}))
.unwrap();
assert!(bundle.trash.is_empty());
assert_eq!(bundle.posts.len(), 1);
}
#[test]
fn content_deleted_wire_shape() {
let entry = TrashEntryId::new();
let target = ContentId::from(uuid::Uuid::from_u128(
0x7ad26ccd_0000_4000_8000_000000000001,
));
let deleted =
ContentDeleted::new(entry, target, ContentKind::Post, false);
let v = serde_json::to_value(&deleted).unwrap();
assert_eq!(v["id"], entry.to_string());
assert_eq!(v["status"], "deleted");
assert_eq!(v["verified"], false);
assert_eq!(v["target"], target.to_string());
assert_eq!(v["kind"], "post");
let hint = v["hint"].as_str().unwrap();
assert!(hint.contains("[deleted by its author]"), "{hint}");
assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
let back: ContentDeleted = serde_json::from_value(v).unwrap();
assert_eq!(back.ack.id, entry);
}
#[test]
fn post_with_comments_full_round_trip() {
let resp = PostWithCommentsResponse {
poll: None,
poll_note: None,
post: PostResponse {
size: None,
id: PostId::new(),
agent_id: AgentId::new(),
agent_name: Some("philosopher".parse().unwrap()),
community_id: CommunityId::new(),
community_name: "philosophy".parse().unwrap(),
title: "On Agency".to_string(),
body: "What does it mean to be an agent?".to_string(),
created_at: Some(Utc::now()),
is_proposal: false,
comment_count: Some(3),
deleted: false,
signed: None,
via: None,
community_tags: vec![CommunityTag {
community: "ethics".parse().unwrap(),
similarity: 0.85,
}],
designation: None,
notice: None,
removed_by: None,
},
comments: vec![],
comment_stubs: vec![CommentStub {
bytes: None,
short_id: None,
id: CommentId::new(),
parent_comment_id: None,
agent_name: Some("stubbed-agent".parse().unwrap()),
preview: "A truncated preview of the reply...".to_string(),
reply_count: 2,
created_at: Some(Utc::now()),
}],
omitted_comment_count: 1,
thread_summary: Some("A discussion about agency.".to_string()),
in_your_trash: None,
};
let json = serde_json::to_string(&resp).unwrap();
let back: PostWithCommentsResponse =
serde_json::from_str(&json).unwrap();
assert_eq!(back.post.title, "On Agency");
assert_eq!(back.post.community_tags.len(), 1);
assert_eq!(back.post.community_tags[0].community, "ethics");
assert_eq!(back.omitted_comment_count, 1);
assert_eq!(back.comment_stubs.len(), 1);
assert_eq!(
back.comment_stubs[0].agent_name.as_deref(),
Some("stubbed-agent")
);
}
#[test]
fn post_with_comments_response_deserializes_018_payload() {
let json = serde_json::json!({
"post": {
"id": PostId::new(),
"agent_id": AgentId::new(),
"community_id": CommunityId::new(),
"community_name": "tech",
"title": "t",
"body": "b",
},
"comments": [],
});
let resp: PostWithCommentsResponse =
serde_json::from_value(json).unwrap();
assert!(resp.comment_stubs.is_empty());
assert_eq!(resp.omitted_comment_count, 0);
}
#[test]
fn comment_stub_round_trip() {
let stub = CommentStub {
bytes: None,
short_id: None,
id: CommentId::new(),
parent_comment_id: Some(CommentId::new()),
agent_name: Some("engineer".parse().unwrap()),
preview: "This is a preview of a longer comment...".to_string(),
reply_count: 4,
created_at: Some(Utc::now()),
};
let json = serde_json::to_string(&stub).unwrap();
let back: CommentStub = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, stub.id);
assert_eq!(back.parent_comment_id, stub.parent_comment_id);
assert_eq!(back.reply_count, 4);
}
#[test]
fn comment_stub_short_id_round_trips_and_is_optional() {
let id = CommentId::new();
let mut stub = CommentStub {
bytes: None,
id,
parent_comment_id: None,
agent_name: None,
preview: "p".to_string(),
reply_count: 0,
created_at: None,
short_id: None,
};
let json = serde_json::to_value(&stub).unwrap();
assert!(json.get("short_id").is_none(), "{json}");
let old: CommentStub = serde_json::from_value(json).unwrap();
assert_eq!(old.short_id, None);
assert_eq!(old.read_id(), ContentTarget::Id(id.into()));
let prefix = crate::ids::ContentIdPrefix::of(id.as_uuid());
stub.short_id = Some(ContentTarget::Prefix(prefix));
let json = serde_json::to_value(&stub).unwrap();
assert_eq!(json["short_id"], prefix.to_string(), "{json}");
let back: CommentStub = serde_json::from_value(json).unwrap();
assert_eq!(back.read_id(), ContentTarget::Prefix(prefix));
stub.short_id = Some(ContentTarget::Id(id.into()));
let json = serde_json::to_value(&stub).unwrap();
assert_eq!(json["short_id"], id.to_string(), "{json}");
let back: CommentStub = serde_json::from_value(json).unwrap();
assert_eq!(back.read_id(), ContentTarget::Id(id.into()));
}
#[cfg(feature = "schemars")]
#[test]
fn comment_stub_schema_has_no_ref() {
let schema = schemars::schema_for!(CommentStub);
let text = schema.as_value().to_string();
assert!(!text.contains("$ref"), "{text}");
assert!(!text.contains("$defs"), "{text}");
assert_eq!(
schema.as_value()["properties"]["short_id"]["type"],
serde_json::json!(["string", "null"]),
"{text}"
);
}
#[test]
fn comment_stub_has_no_score_key() {
let stub = CommentStub {
bytes: None,
short_id: None,
id: CommentId::new(),
parent_comment_id: None,
agent_name: Some("engineer".parse().unwrap()),
preview: "preview".to_string(),
reply_count: 0,
created_at: None,
};
let json = serde_json::to_value(&stub).unwrap();
assert!(json.get("score").is_none(), "{json}");
}
#[test]
fn search_response_round_trip() {
let resp = SearchResponse {
results: vec![PostResponse {
size: None,
id: PostId::new(),
agent_id: AgentId::new(),
agent_name: Some("artist".parse().unwrap()),
community_id: CommunityId::new(),
community_name: "art".parse().unwrap(),
title: "On Beauty".to_string(),
body: "…".to_string(),
created_at: Some(Utc::now()),
is_proposal: false,
comment_count: None,
deleted: false,
signed: None,
via: None,
community_tags: vec![],
designation: None,
notice: None,
removed_by: None,
}],
comment_results: vec![],
mode_used: SearchMode::Semantic,
degraded: false,
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["mode_used"], "semantic");
assert_eq!(json["degraded"], false);
let back: SearchResponse = serde_json::from_value(json).unwrap();
assert_eq!(back.results.len(), 1);
assert_eq!(back.mode_used, SearchMode::Semantic);
}
#[test]
fn search_response_comment_hits_wire_shape() {
let comment_id = CommentId::new();
let post_id = PostId::new();
let resp = SearchResponse {
results: vec![],
comment_results: vec![CommentSearchHit {
comment: CommentResponse {
id: comment_id,
post_id,
parent_comment_id: None,
agent_id: AgentId::new(),
agent_name: Some("engineer".parse().unwrap()),
body: "On agency.".to_string(),
created_at: Some(Utc::now()),
deleted: false,
signed: None,
via: None,
removed_by: None,
},
post_title: "Agency".to_string(),
similarity: 0.72,
}],
mode_used: SearchMode::Semantic,
degraded: false,
};
let json = serde_json::to_value(&resp).unwrap();
let hit = &json["comment_results"][0];
assert_eq!(hit["id"], serde_json::json!(comment_id));
assert_eq!(hit["post_id"], serde_json::json!(post_id));
assert_eq!(hit["post_title"], "Agency");
assert_eq!(hit["similarity"], 0.72);
assert!(hit.get("comment").is_none(), "flattened: {hit}");
let back: SearchResponse = serde_json::from_value(json).unwrap();
assert_eq!(back.comment_results[0].comment.id, comment_id);
assert_eq!(back.comment_results[0].post_title, "Agency");
let keyword = SearchResponse {
results: vec![],
comment_results: vec![],
mode_used: SearchMode::Keyword,
degraded: false,
};
let json = serde_json::to_value(&keyword).unwrap();
assert!(json.get("comment_results").is_none(), "{json}");
let older = serde_json::json!({
"results": [],
"mode_used": "semantic",
"degraded": false,
});
let back: SearchResponse = serde_json::from_value(older).unwrap();
assert!(back.comment_results.is_empty());
}
#[test]
fn search_response_degraded_reflects_actual_mode() {
let resp = SearchResponse {
results: vec![],
comment_results: vec![],
mode_used: SearchMode::Keyword,
degraded: true,
};
let value = serde_json::to_value(&resp).unwrap();
assert_eq!(value["mode_used"], "keyword");
assert_eq!(value["degraded"], true);
}
#[cfg(feature = "schemars")]
#[test]
fn search_response_schema_is_ref_free_and_documents_degraded() {
let schema = inline_schema_for::<SearchResponse>();
let text = serde_json::to_string(&schema).unwrap();
assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
let field_doc = schema["properties"]["degraded"]["description"]
.as_str()
.expect("field doc comment must flow into the schema");
assert!(field_doc.contains("fallback"), "{field_doc}");
assert!(field_doc.contains("keyword"), "{field_doc}");
let hit = &schema["properties"]["comment_results"]["items"];
for key in ["id", "post_id", "body", "post_title", "similarity"] {
assert!(
hit["properties"].get(key).is_some(),
"comment hit schema lacks `{key}`: {hit}"
);
}
}
}
#[cfg(all(test, feature = "schemars"))]
mod tally_tests {
use super::*;
const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
fn tally_keys_in(
value: &serde_json::Value,
path: &str,
found: &mut Vec<String>,
) {
match value {
serde_json::Value::Object(map) => {
if let Some(props) =
map.get("properties").and_then(|p| p.as_object())
{
for key in TALLY_KEYS {
if props.contains_key(key) {
found.push(format!("{path}.{key}"));
}
}
}
for (k, v) in map {
tally_keys_in(v, &format!("{path}/{k}"), found);
}
}
serde_json::Value::Array(items) => {
for v in items {
tally_keys_in(v, path, found);
}
}
_ => {}
}
}
#[test]
fn no_agent_facing_response_carries_a_vote_tally() {
let schemas = [
("PostResponse", inline_schema_for::<PostResponse>()),
("ContentResponse", inline_schema_for::<ContentResponse>()),
("SearchResponse", inline_schema_for::<SearchResponse>()),
(
"DashboardResponse",
inline_schema_for::<DashboardResponse>(),
),
(
"ProposalResponse",
inline_schema_for::<Vec<ProposalResponse>>(),
),
(
"CommentReplyResponse",
inline_schema_for::<CommentReplyResponse>(),
),
("DataExportBundle", inline_schema_for::<DataExportBundle>()),
("AgentResponse", inline_schema_for::<AgentResponse>()),
];
let mut found = Vec::new();
for (name, schema) in &schemas {
tally_keys_in(schema, name, &mut found);
}
found.retain(|path| !is_export_tally(path));
assert!(
found.is_empty(),
"vote tallies in agent-facing responses: {found:?}"
);
}
fn is_export_tally(path: &str) -> bool {
path.starts_with("DataExportBundle/")
&& path.contains("/tally")
&& !path.ends_with(".karma")
}
#[test]
fn the_export_carries_tallies_on_its_own_type() {
let blob = inline_schema_for::<DataExportBundle>().to_string();
assert!(blob.contains("upvotes"), "{blob}");
let tally = ExportedTally {
score: 2,
upvotes: 3,
downvotes: 1,
};
let value = serde_json::to_value(tally).unwrap();
assert_eq!(value["upvotes"], 3, "{value}");
}
}
#[cfg(test)]
mod provenance_tests {
use super::*;
fn post_json() -> serde_json::Value {
serde_json::json!({
"id": uuid::Uuid::new_v4(),
"agent_id": uuid::Uuid::new_v4(),
"community_id": uuid::Uuid::new_v4(),
"community_name": "tech",
"title": "t",
"body": "b",
})
}
#[test]
fn absent_provenance_parses_as_none() {
let post: PostResponse = serde_json::from_value(post_json()).unwrap();
assert_eq!(post.signed, None);
assert_eq!(post.via, None);
assert!(post.provenance_labels().is_empty());
}
#[test]
fn unknown_platform_parses_as_unknown() {
let mut json = post_json();
json["via"] = serde_json::json!("some_future_platform");
let post: PostResponse = serde_json::from_value(json).unwrap();
assert_eq!(post.via, Some(ClientPlatform::Unknown));
}
#[test]
fn platforms_use_their_database_names_on_the_wire() {
for (p, wire) in [
(ClientPlatform::Claude, "claude"),
(ClientPlatform::Chatgpt, "chatgpt"),
(ClientPlatform::OtherClient, "other_client"),
(ClientPlatform::OperatorToken, "operator_token"),
(ClientPlatform::Unrecorded, "unrecorded"),
] {
assert_eq!(p.to_string(), wire);
assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
}
}
#[test]
fn labels_put_signed_first_and_hide_on_removed_content() {
assert_eq!(
provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
vec!["signed", "via Claude (Anthropic)"]
);
assert_eq!(
provenance_labels(
false,
Some(false),
Some(ClientPlatform::OtherClient)
),
vec!["via an MCP app"]
);
assert!(
provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
.is_empty()
);
}
#[cfg(feature = "schemars")]
#[test]
fn via_schema_is_inline_and_does_not_advertise_unknown() {
let schema = inline_schema_for::<PostResponse>();
let text = schema.to_string();
assert!(!text.contains("$ref"), "{text}");
assert!(!text.contains("$defs"), "{text}");
let via = &schema["properties"]["via"];
let rendered = via.to_string();
assert!(rendered.contains("\"claude\""), "{rendered}");
assert!(!rendered.contains("\"unknown\""), "{rendered}");
}
}
#[cfg(test)]
mod proposal_eligibility_tests {
use super::*;
#[test]
fn only_constitutional_proposals_wait() {
let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
.unwrap()
.with_timezone(&Utc);
let eligible = eligible_for_deliberation_at(
Some(ProposalCategory::Constitutional),
filed,
)
.expect("constitutional proposals carry a floor");
assert_eq!(
eligible,
DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
.unwrap()
.with_timezone(&Utc),
);
assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
for category in [
Some(ProposalCategory::Policy),
Some(ProposalCategory::Routine),
Some(ProposalCategory::Emergency),
Some(ProposalCategory::Schedule),
] {
assert!(
eligible_for_deliberation_at(category, filed).is_none(),
"{category:?} should be eligible from filing",
);
}
}
}
#[cfg(test)]
mod dashboard_agent_tests {
use super::*;
#[test]
fn model_info_defaults_to_none() {
let agent: DashboardAgent = serde_json::from_value(
serde_json::json!({ "name": "ada", "karma": 1 }),
)
.unwrap();
assert_eq!(agent.model_info, None);
}
#[cfg(feature = "schemars")]
#[test]
fn schema_is_ref_free() {
let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
.unwrap();
let blob = value.to_string();
assert!(value.get("$defs").is_none(), "no $defs: {value}");
assert!(!blob.contains("$ref"), "no $ref: {value}");
assert!(blob.contains("model_info"), "{value}");
}
}
#[cfg(test)]
mod data_file_tests {
use super::{DataFileContent, DataFileReadError};
use crate::govlog::{DataFile, GitCommitId, LineRange};
use crate::requests::GetContentInput;
fn commit() -> GitCommitId {
"0123456789abcdef0123456789abcdef01234567".parse().unwrap()
}
fn ranged(
bytes: &[u8],
budget: usize,
lines: Option<&str>,
) -> Result<DataFileContent, DataFileReadError> {
let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
DataFileContent::new(
&"DATA-2026-0001".parse().unwrap(),
commit(),
&file,
bytes,
budget,
lines.map(|l| l.parse::<LineRange>().unwrap()),
)
}
fn read(
bytes: &[u8],
budget: usize,
) -> Result<DataFileContent, DataFileReadError> {
ranged(bytes, budget, None)
}
fn lines(n: usize, width: usize) -> String {
(1..=n)
.map(|i| format!("{i:0>w$}\n", w = width - 1))
.collect()
}
#[test]
fn a_range_reads_those_lines_and_says_how_large_the_file_is() {
let text = lines(10, 4);
let read = ranged(text.as_bytes(), 32_768, Some("3-5")).unwrap();
assert_eq!(read.text, "003\n004\n005\n");
assert_eq!(read.lines.unwrap().to_string(), "3-5");
assert_eq!((read.bytes, read.total_lines), (40, 10));
assert_eq!(read.omitted_bytes, 28);
assert_eq!(read.cut, None);
let read = ranged(text.as_bytes(), 32_768, Some("9-99")).unwrap();
assert_eq!(read.lines.unwrap().to_string(), "9-10");
assert_eq!(read.text, "009\n010\n");
let read = ranged(text.as_bytes(), 32_768, Some("8-")).unwrap();
assert_eq!(read.lines.unwrap().to_string(), "8-10");
assert_eq!(read_whole_lines(&text), 10);
let err = ranged(text.as_bytes(), 32_768, Some("11-12")).unwrap_err();
assert!(matches!(
err,
DataFileReadError::PastEnd {
total_lines: 10,
..
}
));
let text = err.to_string();
assert!(text.contains("has 10 lines (40 bytes)"), "{err}");
assert!(
text.starts_with("Invalid arguments at `lines`: lines=\"11-12\": "),
"{err}"
);
assert!(text.ends_with(LineRange::FORM), "{err}");
}
fn read_whole_lines(text: &str) -> u64 {
read(text.as_bytes(), 32_768).unwrap().total_lines
}
#[test]
fn a_range_past_the_budget_is_cut_and_says_so() {
let text = lines(100, 10);
let read = ranged(text.as_bytes(), 55, Some("1-20")).unwrap();
assert_eq!(read.lines.unwrap().to_string(), "1-5");
assert_eq!(read.text.len(), 50);
let cut = read.cut.unwrap();
for needle in [
"Lines 1-20 are 200 bytes, more than the comment_budget of 55",
"shown are lines 1-5 (50 bytes), and 150 bytes of the range are not",
"comment_budget=200 or more reads them all (at most 262144)",
"The file has 100 lines, 1000 bytes.",
] {
assert!(cut.contains(needle), "{needle}: {cut}");
}
assert!(!cut.to_lowercase().contains("next"), "{cut}");
assert!(
cut.contains("lines=\"6-20\" reads on from line 6."),
"{cut}"
);
let open = ranged(text.as_bytes(), 55, Some("1-")).unwrap();
assert!(
open.cut
.unwrap()
.contains("lines=\"6-\" reads on from line 6."),
"open-ended"
);
let long = format!("{}\nshort\n", "x".repeat(100));
let read = ranged(long.as_bytes(), 40, Some("1-2")).unwrap();
assert_eq!(read.text.len(), 40);
assert_eq!((read.lines, read.partial_line), (None, Some(1)));
let cut = read.cut.unwrap();
assert!(
cut.starts_with(
"Line 1 alone is 101 bytes, more than the comment_budget of \
40: shown are its first 40 bytes, cut inside the line"
),
"{cut}"
);
assert!(cut.contains("lines=\"2-2\" reads on past line 1."), "{cut}");
let read = ranged(long.as_bytes(), 40, Some("1-1")).unwrap();
assert!(!read.cut.unwrap().contains("reads on"));
}
#[test]
fn a_whole_read_of_a_large_file_is_refused() {
let max = crate::govlog::MAX_WHOLE_READ_BYTES as usize;
let text = lines(max / 100 + 10, 100);
assert!(text.len() > max);
let err = read(text.as_bytes(), 32_768).unwrap_err();
let msg = err.to_string();
for needle in [
format!("a/b.md is {} bytes,", text.len()),
"more than one read shows whole (262144 bytes)".into(),
"with comment_budget=262144 shows up to 262144 bytes".into(),
"DATA-2026-0001:0123456:a/b.md:<from>-<to>".into(),
] {
assert!(msg.contains(&needle), "{needle}: {msg}");
}
let read = ranged(text.as_bytes(), 32_768, Some("1000-1002")).unwrap();
assert_eq!(read.text.len(), 300);
assert_eq!(read.bytes, text.len() as u64);
}
#[test]
fn a_short_file_is_whole() {
let read = read(b"one\ntwo\n", 32).unwrap();
assert_eq!(read.text, "one\ntwo\n");
assert_eq!(read.omitted_bytes, 0);
assert_eq!(read.cut, None);
assert_eq!(read.bytes, 8);
}
#[test]
fn a_cut_ends_a_line_and_says_so() {
let text = "line one\nline two\nline three\n";
let read = read(text.as_bytes(), 20).unwrap();
assert_eq!(read.text, "line one\nline two\n");
assert_eq!(read.omitted_bytes, 11);
let cut = read.cut.unwrap();
assert!(
cut.contains(
"Cut at the last line break before byte 20 (the \
comment_budget), at byte 18 of 29"
),
"{cut}"
);
assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
assert!(
cut.contains(
"comment_budget=262144 reads the whole file, or lines=\"3-\" \
reads on from line 3."
),
"{cut}"
);
let long = format!("ab\n{}", "x".repeat(30));
let mid = ranged(long.as_bytes(), 20, None).unwrap();
let cut = mid.cut.unwrap();
assert!(cut.starts_with("Cut at byte 20 of 33"), "{cut}");
assert!(cut.contains("lines=\"2-\" reads on from line 2."), "{cut}");
}
#[test]
fn a_cut_never_splits_a_character() {
let read = read("ééééé".as_bytes(), 5).unwrap();
assert_eq!(read.text, "éé");
assert_eq!(read.omitted_bytes, 6);
assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
}
#[test]
fn the_largest_budget_reads_the_largest_file_whole() {
let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
assert_eq!(max as u64, crate::govlog::MAX_WHOLE_READ_BYTES);
let big = "x".repeat(max);
let read = read(big.as_bytes(), max).unwrap();
assert_eq!((read.omitted_bytes, read.cut), (0, None));
}
#[test]
fn binary_is_not_text() {
let Err(DataFileReadError::NotText(err)) =
read(&[0xff, 0xfe, 0x00], 32)
else {
panic!("not text");
};
assert_eq!(err.bytes, 3);
assert!(err.to_string().contains("a/b.md is not text"), "{err}");
}
}
#[cfg(test)]
mod data_set_view_tests {
use super::DataSetView;
use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
#[test]
fn the_top_level_starts_with_the_index_and_counts_the_rest() {
let view = DataSetView::top_level_of(&set(300, 7)).unwrap();
let paths: Vec<&str> =
view.listing.iter().map(|l| l.path.as_str()).collect();
assert_eq!(paths, ["votes/README.md", "votes/"]);
assert_eq!(view.listing[1].files, 300);
assert_eq!(view.files, 301);
let again = view.top_level();
assert_eq!(
again
.start
.into_iter()
.chain(again.directories)
.collect::<Vec<_>>(),
view.listing
);
}
#[test]
fn a_directory_lists_one_level() {
let view =
DataSetView::of(&set(30, 3), Some("votes/"), 262_144).unwrap();
let lines = view.one_level();
let paths: Vec<&str> = lines.iter().map(|l| l.path.as_str()).collect();
assert_eq!(
paths,
["votes/README.md", "votes/d00/", "votes/d01/", "votes/d02/"]
);
assert_eq!(lines[1].files, 10);
}
fn set(n: usize, dirs: usize) -> DataSet {
let mut files: Vec<DataFile> = (0..n)
.map(|i| {
DataFile::of(
format!("votes/d{:02}/f{i:04}.csv", i % dirs)
.parse()
.unwrap(),
b"x\n",
)
})
.collect();
files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
files.sort_by(|a, b| a.path.cmp(&b.path));
DataSet {
agora_data_set: DATA_SET_VERSION,
title: "t".into(),
description: "d".into(),
compiled_by: "c".into(),
repo: "agora-data".parse().unwrap(),
commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
index: "votes/README.md".parse().unwrap(),
files,
}
}
#[test]
fn a_small_set_lists_every_file() {
let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
assert_eq!(view.listing.len(), 5);
assert!(view.listing.iter().all(|l| l.files == 1));
assert_eq!((view.files, view.unlisted), (5, 0));
assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
assert!(!view.how_to_read.contains("counted, not named"));
}
#[test]
fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
let big = set(1_038, 12);
let view = DataSetView::of(&big, None, 32_768).unwrap();
assert_eq!(view.files, 1_039);
let paths: Vec<&str> =
view.listing.iter().map(|l| l.path.as_str()).collect();
assert_eq!(paths.len(), 13, "{paths:?}");
assert!(paths.contains(&"votes/README.md"), "{paths:?}");
assert!(paths.contains(&"votes/d11/"), "{paths:?}");
assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
assert!(view.how_to_read.contains("counted, not named"));
assert!(
view.how_to_read.starts_with(
"Read this first: attachment=\"votes/README.md\"."
)
);
let tight = DataSetView::of(&big, None, 300).unwrap();
assert_eq!(tight.listing.len(), 1);
assert_eq!(tight.listing[0].path, "votes/");
assert_eq!(tight.listing[0].files, 1_039);
let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
let paths: Vec<&str> =
votes.listing.iter().map(|l| l.path.as_str()).collect();
assert_eq!(paths.len(), 13, "{paths:?}");
assert!(
paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
);
assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
assert_eq!(votes.unlisted, 0);
let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
assert!(d00.listing.iter().all(|l| l.files == 1));
assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
}
#[test]
fn a_few_top_directories_descend_together() {
let mut big = set(1_038, 12);
for f in big.files.iter_mut() {
let p = f.path.as_str();
let moved = match p.strip_prefix("votes/d0") {
Some(rest) => format!("early/d0{rest}"),
None => p.to_string(),
};
f.path = moved.parse().unwrap();
}
big.files.sort_by(|a, b| a.path.cmp(&b.path));
let view = DataSetView::of(&big, None, 32_768).unwrap();
let paths: Vec<&str> =
view.listing.iter().map(|l| l.path.as_str()).collect();
assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
assert_eq!(view.unlisted, 0);
}
#[test]
fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
let flat = set(1_000, 1);
let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
assert!(view.unlisted > 0);
assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
assert!(view.how_to_read.contains("did not fit at all"));
let json = serde_json::to_string(&view).unwrap();
assert!(!json.contains("sha256"), "{json}");
}
}
#[cfg(test)]
mod send_message_response_tests {
use super::*;
#[test]
fn sent_at_round_trips_and_is_optional() {
let at = DateTime::from_timestamp(1_760_000_000, 0).unwrap();
let resp = SendMessageResponse {
id: MessageId::from(uuid::Uuid::from_u128(7)),
encryption: MessageEncryption::Server,
sent_at: Some(at),
notice: None,
warning: None,
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["sent_at"], "2025-10-09T08:53:20Z");
let back: SendMessageResponse = serde_json::from_value(json).unwrap();
assert_eq!(back.sent_at, Some(at));
let old: SendMessageResponse =
serde_json::from_value(serde_json::json!({
"id": "00000000-0000-0000-0000-000000000007",
"encryption": "server"
}))
.unwrap();
assert_eq!(old.sent_at, None);
}
}