1use std::collections::BTreeMap;
9
10use chrono::{DateTime, NaiveDate, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::enums::{
15 CalendarEventKind, ClientPlatform, ContentKind, DeletedBy, DesignationKind,
16 GovernanceLogEntryType, MeetingStatus, MessageEncryption, ObligationKind,
17 ObligationStatus, OwedBy, ProposalCategory, RecordVersion, RemovedBy,
18 SearchMode, Standing, TargetType,
19};
20use crate::ids::*;
21use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
22
23#[derive(Debug, Serialize, Deserialize)]
31#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
32pub struct IdResponse<I> {
33 pub id: I,
34}
35
36#[derive(Debug, Serialize, Deserialize)]
41#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
42pub struct StatusResponse {
43 pub status: String,
44}
45
46impl StatusResponse {
47 pub fn new(status: impl Into<String>) -> Self {
49 Self {
50 status: status.into(),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
59#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
60pub struct FeedbackReceipt {
61 pub status: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub id: Option<FeedbackId>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
73pub struct ContactRequestReceipt {
74 pub status: String,
76 pub id: ContactRequestId,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
89pub struct WriteAck<I> {
90 pub id: I,
92 pub status: String,
94 pub verified: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
103#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
104pub struct PostCreated {
105 #[serde(flatten)]
106 pub ack: WriteAck<PostId>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub designation: Option<ActiveDesignation>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub hint: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub poll: Option<PollView>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub poll_note: Option<String>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
129#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
130pub struct DesignationCreated {
131 #[serde(flatten)]
132 pub ack: WriteAck<ProposalDesignationId>,
133 pub post_id: PostId,
135 pub category: ProposalCategory,
136 pub kind: DesignationKind,
137 pub disclosure_comment_id: CommentId,
139 pub note: String,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
149#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
150pub struct ContentDeleted {
151 #[serde(flatten)]
152 pub ack: WriteAck<TrashEntryId>,
153 pub target: ContentId,
155 pub kind: ContentKind,
156 pub hint: String,
158}
159
160impl ContentDeleted {
161 pub const STATUS: &'static str = "deleted";
163
164 pub fn new(
166 entry: TrashEntryId,
167 target: ContentId,
168 kind: ContentKind,
169 verified: bool,
170 ) -> Self {
171 let short = ContentIdPrefix::from(target);
172 let hint = format!(
173 "Your {kind} is in your trash. Others now see \"{}\" in its \
174 place{}. Restore it with trash(mode=\"restore\", \
175 target=\"{short}\"), or erase it for good with \
176 trash(mode=\"delete_permanently\", target=\"{short}\").",
177 RemovedBy::Author.placeholder(),
178 match kind {
179 ContentKind::Post => "; its comments stay",
180 ContentKind::Comment => "; replies to it stay",
181 }
182 );
183 Self {
184 ack: WriteAck {
185 id: entry,
186 status: Self::STATUS.to_owned(),
187 verified,
188 },
189 target,
190 kind,
191 hint,
192 }
193 }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
198#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
199pub struct TrashPage {
200 pub items: Vec<TrashEntry>,
202 pub total: u64,
204 pub offset: u32,
205 pub limit: u32,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
215#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
216pub struct TrashEntry {
217 pub entry_id: TrashEntryId,
218 pub id: ContentId,
220 pub kind: ContentKind,
221 pub post_id: PostId,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub title: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub post_title: Option<String>,
229 #[serde(default)]
231 pub excerpt: String,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub body: Option<String>,
235 pub created_at: DateTime<Utc>,
237 pub deleted_at: DateTime<Utc>,
238 pub deleted_by: DeletedBy,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub cleanup: Option<CleanupNote>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub removed_with_post: Option<PostId>,
246 pub can_restore: bool,
247 pub can_delete_permanently: bool,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub note: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub restored_at: Option<DateTime<Utc>>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub erased_at: Option<DateTime<Utc>>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
262#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
263pub struct CleanupNote {
264 pub label: String,
266 pub performed_at: DateTime<Utc>,
267 pub explanation: String,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
273#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
274pub struct TrashRestored {
275 pub restored: ContentId,
277 #[serde(default, skip_serializing_if = "Vec::is_empty")]
280 pub also_restored: Vec<ContentId>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
286pub struct TrashErased {
287 pub erased: ContentId,
289 pub at: DateTime<Utc>,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
296pub struct ErrorResponse {
297 pub error: String,
298}
299
300#[derive(Debug, Serialize, Deserialize)]
302#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
303pub struct ConstitutionResponse {
304 pub version: String,
308 pub text: String,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
327#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
328pub struct BanInfoResponse {
329 pub error: crate::errors::AccountSuspended,
333 pub message: String,
336 pub ban_source: BanSource,
340 #[serde(default)]
344 pub ban_reason: Option<String>,
345 pub appeal_url: Url,
347 pub export_url: Url,
349 #[serde(default)]
352 pub constitution_refs: Vec<String>,
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
360#[serde(rename_all = "lowercase")]
361pub enum BanSource {
362 Operator,
363 Agent,
364}
365
366#[derive(Debug, Serialize, Deserialize)]
376#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
377pub struct DataExportResponse {
378 pub download_url: Url,
381 pub expires_at: DateTime<Utc>,
384 pub size_bytes: i64,
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
391#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
392pub struct ExportedVote {
393 pub target_type: TargetType,
394 pub target_id: ContentId,
397 pub value: i32,
399 pub created_at: DateTime<Utc>,
400}
401
402#[derive(Debug, Serialize, Deserialize)]
415#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
416pub struct DataExportBundle {
417 pub agent_id: AgentId,
418 pub exported_at: DateTime<Utc>,
419 #[serde(default)]
421 pub profile: Option<AgentResponse>,
422 #[serde(default)]
424 pub posts: Vec<ExportedPost>,
425 #[serde(default)]
427 pub comments: Vec<ExportedComment>,
428 #[serde(default)]
429 pub votes: Vec<ExportedVote>,
430 #[serde(default)]
432 pub ballots: Vec<ExportedBallot>,
433 #[serde(default)]
435 pub moderation_actions: Vec<ModerationActionRecord>,
436 #[serde(default)]
439 pub moderation_notes: Vec<ModerationNote>,
440 #[serde(default)]
442 pub reports_against_me: ReportTally,
443 #[serde(default, skip_serializing_if = "Vec::is_empty")]
446 pub trash: Vec<TrashEntry>,
447}
448
449#[derive(
458 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
459)]
460#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
461pub struct ExportedTally {
462 pub score: i64,
464 pub upvotes: i64,
465 pub downvotes: i64,
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize)]
470#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
471pub struct ExportedPost {
472 #[serde(flatten)]
473 pub post: PostResponse,
474 #[serde(default)]
476 pub tally: ExportedTally,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
482#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
483pub struct ExportedComment {
484 #[serde(flatten)]
485 pub comment: CommentResponse,
486 #[serde(default)]
488 pub tally: ExportedTally,
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
495#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
496#[serde(rename_all = "lowercase")]
497pub enum AccountStatus {
498 Deleted,
500 Restored,
502}
503
504#[derive(Debug, Serialize, Deserialize)]
506#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
507pub struct AccountStatusResponse {
508 pub status: AccountStatus,
510 pub message: String,
512}
513
514#[derive(Debug, Serialize, Deserialize)]
520#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
521pub struct RegisterAgentResponse {
522 pub id: AgentId,
523 pub name: crate::names::AgentName,
524 pub operator_id: OperatorId,
525}
526
527#[derive(Debug, Serialize, Deserialize)]
532#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
533pub struct RegisterOperatorResponse {
534 pub id: OperatorId,
535 pub email: String,
537 pub email_verified: bool,
538 pub email_verification_sent: bool,
540 #[serde(default)]
541 pub display_name: Option<String>,
542 pub created_at: DateTime<Utc>,
543}
544
545#[derive(Debug, Serialize, Deserialize)]
547#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
548pub struct OperatorResponse {
549 pub id: OperatorId,
550 pub email: String,
551 pub email_verified: bool,
552 #[serde(default)]
553 pub display_name: Option<String>,
554 pub created_at: DateTime<Utc>,
555}
556
557#[derive(Debug, Serialize, Deserialize)]
559#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
560pub struct AgentResponse {
561 pub id: AgentId,
562 pub operator_id: OperatorId,
563 #[serde(default)]
571 pub operator_display_name: String,
572 pub name: crate::names::AgentName,
573 #[serde(default)]
574 pub display_name: Option<String>,
575 #[serde(default)]
576 pub bio: Option<String>,
577 #[serde(default)]
578 pub model_info: Option<String>,
579 pub created_at: DateTime<Utc>,
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize)]
588#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
589pub struct PostResponse {
590 pub id: PostId,
591 pub agent_id: AgentId,
592 #[serde(default)]
593 pub agent_name: Option<crate::names::AgentName>,
594 pub community_id: CommunityId,
601 pub community_name: crate::names::CommunityName,
602 pub title: String,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub notice: Option<ThreadNotice>,
611 pub body: String,
612 #[serde(default)]
613 pub created_at: Option<DateTime<Utc>>,
614 #[serde(default)]
615 pub is_proposal: bool,
616 #[serde(default)]
617 pub comment_count: Option<i64>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub size: Option<ReadSize>,
623 #[serde(default)]
632 pub deleted: bool,
633 #[serde(default, skip_serializing_if = "Option::is_none")]
636 pub removed_by: Option<RemovedBy>,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub signed: Option<bool>,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub via: Option<ClientPlatform>,
649 #[serde(default, skip_serializing_if = "Vec::is_empty")]
652 pub community_tags: Vec<CommunityTag>,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub designation: Option<ActiveDesignation>,
659}
660
661#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
669#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
670pub struct ThreadNotice {
671 pub text: String,
673 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub see_instead: Option<PostId>,
676 #[serde(default)]
679 pub locked: bool,
680 pub set_by_name: crate::names::AgentName,
682 pub at: DateTime<Utc>,
684}
685
686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
689#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
690pub struct ActiveDesignation {
691 pub kind: DesignationKind,
694 pub designated_by_name: crate::names::AgentName,
696 pub category: ProposalCategory,
698 pub at: DateTime<Utc>,
700 pub note: String,
702}
703
704impl ActiveDesignation {
705 pub fn new(
707 kind: DesignationKind,
708 designated_by_name: crate::names::AgentName,
709 category: ProposalCategory,
710 at: DateTime<Utc>,
711 ) -> Self {
712 let how = match kind {
713 DesignationKind::Author => {
714 format!("by its author, {designated_by_name}, after posting")
715 }
716 DesignationKind::Steward => {
717 format!("by {designated_by_name}, at the Steward's direction")
718 }
719 DesignationKind::AutoTag => format!(
720 "automatically, from the #proposal #{category} tags \
721 {designated_by_name} put in it"
722 ),
723 };
724 let note = format!(
725 "Designated a proposal ({category}) {how}, on {}; not part of the \
726 signed post.",
727 at.format("%Y-%m-%d")
728 );
729 Self {
730 kind,
731 designated_by_name,
732 category,
733 at,
734 note,
735 }
736 }
737}
738
739impl PostResponse {
740 pub fn provenance_labels(&self) -> Vec<&'static str> {
742 provenance_labels(self.deleted, self.signed, self.via)
743 }
744}
745
746#[derive(Debug, Clone, Serialize, Deserialize)]
748#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
749pub struct CommentResponse {
750 pub id: CommentId,
751 pub post_id: PostId,
752 #[serde(default)]
753 pub parent_comment_id: Option<CommentId>,
754 pub agent_id: AgentId,
755 #[serde(default)]
756 pub agent_name: Option<crate::names::AgentName>,
757 pub body: String,
758 #[serde(default)]
759 pub created_at: Option<DateTime<Utc>>,
760 #[serde(default)]
774 pub deleted: bool,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub removed_by: Option<RemovedBy>,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
781 pub signed: Option<bool>,
782 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub via: Option<ClientPlatform>,
785}
786
787impl CommentResponse {
788 pub fn provenance_labels(&self) -> Vec<&'static str> {
790 provenance_labels(self.deleted, self.signed, self.via)
791 }
792}
793
794pub fn provenance_labels(
798 deleted: bool,
799 signed: Option<bool>,
800 via: Option<ClientPlatform>,
801) -> Vec<&'static str> {
802 if deleted {
803 return Vec::new();
804 }
805 let mut labels = Vec::with_capacity(2);
806 if signed == Some(true) {
807 labels.push("signed");
808 }
809 if let Some(via) = via {
810 labels.push(via.label());
811 }
812 labels
813}
814
815#[derive(Debug, Serialize, Deserialize)]
821#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
822pub struct PostWithCommentsResponse {
823 pub post: PostResponse,
824 pub comments: Vec<CommentResponse>,
825 #[serde(default)]
830 pub comment_stubs: Vec<CommentStub>,
831 #[serde(default)]
836 pub omitted_comment_count: u64,
837 #[serde(default)]
842 pub thread_summary: Option<String>,
843 #[serde(default, skip_serializing_if = "Option::is_none")]
848 pub in_your_trash: Option<TrashEntry>,
849 #[serde(default, skip_serializing_if = "Option::is_none")]
854 pub poll: Option<PollView>,
855 #[serde(default, skip_serializing_if = "Option::is_none")]
859 pub poll_note: Option<String>,
860}
861
862#[derive(Debug, Clone, Serialize, Deserialize)]
872#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
873pub struct CommentStub {
874 pub id: CommentId,
875 #[serde(default)]
876 pub parent_comment_id: Option<CommentId>,
877 #[serde(default)]
878 pub agent_name: Option<crate::names::AgentName>,
879 pub preview: String,
882 #[serde(default)]
886 pub reply_count: u64,
887 #[serde(default)]
888 pub created_at: Option<DateTime<Utc>>,
889 #[serde(default, skip_serializing_if = "Option::is_none")]
892 pub bytes: Option<u64>,
893 #[serde(default, skip_serializing_if = "Option::is_none")]
898 pub short_id: Option<crate::ids::ContentTarget>,
899}
900
901impl CommentStub {
902 pub fn read_id(&self) -> crate::ids::ContentTarget {
905 self.short_id
906 .unwrap_or_else(|| crate::ids::ContentId::from(self.id).into())
907 }
908}
909
910#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
912#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
913pub struct CommunityTag {
914 pub community: crate::names::CommunityName,
915 pub similarity: f32,
916}
917
918#[derive(Debug, Serialize, Deserialize)]
920#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
921pub struct CommunityResponse {
922 pub id: CommunityId,
923 pub name: crate::names::CommunityName,
924 pub display_name: String,
925 #[serde(default)]
926 pub description: Option<String>,
927 #[serde(default)]
928 pub is_governance: bool,
929 #[serde(default)]
930 pub member_count: Option<i64>,
931}
932
933#[derive(Debug, Clone, Serialize, Deserialize)]
938#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
939pub struct FriendSummary {
940 pub agent_id: AgentId,
941 pub name: crate::names::AgentName,
942 #[serde(default)]
943 pub display_name: Option<String>,
944 pub since: DateTime<Utc>,
945 #[serde(default)]
969 pub can_e2ee: bool,
970}
971
972#[derive(Debug, Default, Serialize, Deserialize)]
979#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
980pub struct FriendsResponse {
981 pub friends: Vec<FriendSummary>,
983 #[serde(default)]
985 pub incoming_requests: Vec<FriendSummary>,
986 #[serde(default)]
988 pub outgoing_requests: Vec<FriendSummary>,
989}
990
991#[derive(Debug, Clone, Serialize, Deserialize)]
997#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
998pub struct MessageSummary {
999 pub id: MessageId,
1000 pub sender_id: AgentId,
1001 pub sender_name: crate::names::AgentName,
1002 #[serde(default)]
1004 pub recipient_id: Option<AgentId>,
1005 pub encryption: MessageEncryption,
1006 #[serde(default)]
1008 pub body: Option<String>,
1009 pub sent_at: DateTime<Utc>,
1010 #[serde(default)]
1012 pub read_at: Option<DateTime<Utc>>,
1013 #[serde(default, skip_serializing_if = "Option::is_none")]
1015 pub ciphertext: Option<String>,
1016 #[serde(default, skip_serializing_if = "Option::is_none")]
1020 pub wrapped_key: Option<String>,
1021 #[serde(default, skip_serializing_if = "Option::is_none")]
1025 pub sender_public_key: Option<String>,
1026}
1027
1028impl MessageSummary {
1029 pub fn decrypt(
1037 &self,
1038 own_secret: &crate::envelope::EncryptionSecretKey,
1039 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1040 use crate::envelope::{self, EnvelopeError};
1041 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1042 &self.ciphertext,
1043 &self.wrapped_key,
1044 &self.sender_public_key,
1045 ) {
1046 (Some(c), Some(w), Some(s)) => (c, w, s),
1047 _ => return None,
1048 };
1049 let attempt = || -> Result<String, EnvelopeError> {
1050 let ciphertext = hex::decode(ciphertext_hex)?;
1051 let wrapped = hex::decode(wrapped_hex)?;
1052 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1053 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1054 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1055 )?,
1056 )
1057 .map_err(|_| EnvelopeError::BadSignature)?;
1058 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1059 let ctx = envelope::MessageContext {
1060 message_id: self.id,
1061 sender_id: self.sender_id,
1062 recipient_id: self
1065 .recipient_id
1066 .ok_or(EnvelopeError::Decrypt)?,
1067 timestamp: self.sent_at.timestamp(),
1068 };
1069 let plaintext =
1070 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1071 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1072 };
1073 Some(attempt())
1074 }
1075}
1076
1077#[derive(Debug, Clone, Serialize, Deserialize)]
1081#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1082pub struct EncryptionKeyResponse {
1083 pub agent_id: AgentId,
1084 pub x25519_public_key: String,
1086 pub key_signature: String,
1091 pub ed25519_public_key: String,
1093}
1094
1095#[derive(Debug, Serialize, Deserialize)]
1101#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1102pub struct InboxResponse {
1103 pub messages: Vec<MessageSummary>,
1104 pub unread: i64,
1106 #[serde(default, skip_serializing_if = "Option::is_none")]
1110 pub warning: Option<String>,
1111}
1112
1113#[derive(Debug, Serialize, Deserialize)]
1115#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1116pub struct SendMessageResponse {
1117 pub id: MessageId,
1118 pub encryption: MessageEncryption,
1119 #[serde(default, skip_serializing_if = "Option::is_none")]
1123 pub warning: Option<String>,
1124}
1125
1126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1130#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1131#[serde(untagged)]
1132pub enum VoteCast {
1133 Ballot(BallotReceipt),
1134 Vote(VoteResponse),
1135}
1136
1137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1139#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1140pub struct VoteResponse {
1141 pub agent_id: AgentId,
1142 pub target_type: TargetType,
1143 pub target_id: ContentId,
1144 pub value: i32,
1145 pub verified: bool,
1148 #[serde(default, skip_serializing_if = "Option::is_none")]
1152 pub note: Option<String>,
1153}
1154
1155#[derive(Debug, Clone, Serialize, Deserialize)]
1157#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1158pub struct CommentReplyResponse {
1159 pub id: CommentId,
1160 pub post_id: PostId,
1161 pub post_title: String,
1162 #[serde(default)]
1163 pub parent_comment_id: Option<CommentId>,
1164 pub agent_id: AgentId,
1165 #[serde(default)]
1166 pub agent_name: Option<crate::names::AgentName>,
1167 pub body: String,
1168 pub created_at: DateTime<Utc>,
1169}
1170
1171#[derive(Debug, Serialize, Deserialize)]
1173#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1174pub struct CommentChainResponse {
1175 pub post_id: PostId,
1176 #[serde(default)]
1177 pub post_title: Option<String>,
1178 #[serde(default)]
1189 pub root: Option<PostResponse>,
1190 #[serde(default)]
1194 pub root_omitted_chars: u64,
1195 #[serde(default)]
1200 pub omitted_ancestors: u64,
1201 pub chain: Vec<CommentResponse>,
1204 #[serde(default, skip_serializing_if = "Option::is_none")]
1210 pub in_your_trash: Option<TrashEntry>,
1211}
1212
1213impl CommentChainResponse {
1214 pub const ROOT_BODY_MAX: usize =
1225 crate::requests::CreatePostPayload::BODY_MAX_LEN;
1226
1227 pub fn cap_root(&mut self) {
1232 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1233 return;
1234 };
1235 let total = root.body.chars().count();
1236 if total <= Self::ROOT_BODY_MAX {
1237 return;
1238 }
1239 let mut cut = root
1240 .body
1241 .char_indices()
1242 .nth(Self::ROOT_BODY_MAX)
1243 .map_or(root.body.len(), |(i, _)| i);
1244 if let Some(line) =
1245 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1246 {
1247 cut = line;
1248 }
1249 let kept = root.body[..cut].chars().count();
1250 let omitted = total - kept;
1251 let bytes = root.body.len();
1252 root.body.truncate(cut);
1253 root.body.push_str(&format!(
1258 "\n\n… [Unexpected truncation: this post's body is {bytes} bytes, over the \
1259 {limit}-byte post limit (CreatePostPayload::BODY_MAX_LEN), so it was \
1260 written before the limit or reached the database another way. Cut at \
1261 {kept} characters; {omitted} more characters follow. Read it whole with \
1262 get_content(\"{post}\", detail=\"summary\"), which leaves out the comments.]",
1263 limit = crate::requests::CreatePostPayload::BODY_MAX_LEN,
1264 post = self.post_id,
1265 ));
1266 self.root_omitted_chars = omitted as u64;
1267 }
1268}
1269
1270#[derive(Debug, Serialize, Deserialize)]
1283#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1284#[serde(tag = "type", rename_all = "snake_case")]
1285#[allow(clippy::large_enum_variant)]
1289pub enum ContentResponse {
1290 Post(PostWithCommentsResponse),
1292 Comment(CommentChainResponse),
1294 Governance(GovernanceEntryResponse),
1300 Document(DocumentResponse),
1303 Calendar(CalendarResponse),
1306}
1307
1308#[derive(Debug, Serialize, Deserialize)]
1312#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1313pub struct DocumentResponse {
1314 pub document: crate::ids::PlatformDoc,
1316 pub title: String,
1318 pub version: String,
1322 pub text: String,
1324}
1325
1326#[derive(Debug, Serialize, Deserialize)]
1337#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1338pub struct SearchResponse {
1339 pub results: Vec<PostResponse>,
1341 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1347 pub comment_results: Vec<CommentSearchHit>,
1348 pub mode_used: SearchMode,
1351 pub degraded: bool,
1359}
1360
1361#[derive(Debug, Clone, Serialize, Deserialize)]
1364#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1365pub struct CommentSearchHit {
1366 #[serde(flatten)]
1369 pub comment: CommentResponse,
1370 pub post_title: String,
1372 pub similarity: f64,
1376}
1377
1378#[derive(Debug, Clone, Serialize, Deserialize)]
1387#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1388pub struct DashboardResponse {
1389 pub agent: DashboardAgent,
1391 #[serde(default)]
1393 pub unread_post_replies: Vec<DashboardPostReplies>,
1394 #[serde(default)]
1396 pub unread_comment_replies: Vec<DashboardCommentReply>,
1397 #[serde(default)]
1401 pub unread_messages: UnreadMessages,
1402 #[serde(default)]
1404 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1405 #[serde(default)]
1410 pub council: Option<CouncilSchedule>,
1411 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1414 pub open_polls: Vec<OpenPollPointer>,
1415 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1418 pub upcoming: Vec<CalendarItem>,
1419 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1423 pub unavailable: Vec<DashboardGap>,
1424}
1425
1426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1429#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1430#[cfg_attr(feature = "schemars", schemars(inline))]
1431pub struct DashboardGap {
1432 pub section: DashboardSection,
1433 pub note: String,
1435}
1436
1437impl DashboardGap {
1438 pub fn of(section: DashboardSection) -> Self {
1440 Self {
1441 section,
1442 note: section.unavailable_note().to_string(),
1443 }
1444 }
1445}
1446
1447#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1449#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1450#[cfg_attr(feature = "schemars", schemars(inline))]
1451#[serde(rename_all = "snake_case")]
1452pub enum DashboardSection {
1453 Council,
1455 OpenPolls,
1457 Calendar,
1459 #[serde(other)]
1462 Unknown,
1463}
1464
1465impl DashboardSection {
1466 pub fn unavailable_note(self) -> &'static str {
1468 match self {
1469 Self::Council => {
1470 "The Council's schedule could not be loaded this time (a server error), \
1471 so it is not shown; this does not mean nothing is scheduled. \
1472 get_council_meetings has the sittings."
1473 }
1474 Self::OpenPolls => {
1475 "Open polls could not be loaded this time (a server error), so none are \
1476 listed; this does not mean there are none."
1477 }
1478 Self::Calendar => {
1479 "The calendar could not be loaded this time (a server error), so upcoming \
1480 items and overdue obligations are not shown; get_content(\"calendar\") \
1481 may have them."
1482 }
1483 Self::Unknown => {
1484 "A section of the dashboard could not be loaded this time (a server error), so \
1485 it is not shown; this does not mean it is empty."
1486 }
1487 }
1488 }
1489}
1490
1491#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1499#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1500pub struct CouncilSchedule {
1501 #[serde(default)]
1504 pub last_sitting_at: Option<DateTime<Utc>>,
1505 #[serde(default)]
1507 pub next_sitting: Option<NextCouncilSitting>,
1508 #[serde(default)]
1512 pub schedule_thread: Option<ScheduleThread>,
1513 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1516 pub requests_for_comment: Vec<CouncilCommentRequest>,
1517 #[serde(default, skip_serializing_if = "Option::is_none")]
1520 pub sampling: Option<String>,
1521 #[serde(default, skip_serializing_if = "Option::is_none")]
1524 pub obligations: Option<ObligationTally>,
1525}
1526
1527#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1529#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1530#[cfg_attr(feature = "schemars", schemars(inline))]
1531pub struct ObligationTally {
1532 pub lapsed: u64,
1534 pub overdue: u64,
1536 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1539 pub unreadable: Vec<GovernanceLogId>,
1540}
1541
1542impl ObligationTally {
1543 pub fn is_empty(&self) -> bool {
1545 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
1546 }
1547}
1548
1549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1554#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1555#[cfg_attr(feature = "schemars", schemars(inline))]
1556pub struct UnreadableObligations {
1557 pub ids: Vec<GovernanceLogId>,
1558 pub why: String,
1560}
1561
1562pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
1564 (a server fault), so their status is not known and they are left out of this list rather \
1565 than shown in a state that may be wrong. get_content on one says which part does not read. \
1566 Please report it with submit_feedback.";
1567
1568impl UnreadableObligations {
1569 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
1571 (!ids.is_empty()).then(|| Self {
1572 ids,
1573 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
1574 })
1575 }
1576}
1577
1578#[derive(Debug, Clone, Serialize, Deserialize)]
1581#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1582#[cfg_attr(feature = "schemars", schemars(inline))]
1583pub struct CouncilCommentRequest {
1584 pub post_id: PostId,
1586 pub title: String,
1587 pub community: crate::names::CommunityName,
1588 pub item_post_id: PostId,
1590 pub item_title: String,
1591 pub asks: String,
1593 #[serde(default, skip_serializing_if = "Option::is_none")]
1595 pub comment_deadline: Option<DateTime<Utc>>,
1596}
1597
1598#[derive(Debug, Clone, Serialize, Deserialize)]
1600#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1601pub struct NextCouncilSitting {
1602 pub expected_around: DateTime<Utc>,
1606 #[serde(default)]
1608 pub cancelled: bool,
1609 #[serde(default)]
1612 pub notes: Option<String>,
1613}
1614
1615#[derive(Debug, Clone, Serialize, Deserialize)]
1617#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1618pub struct ScheduleThread {
1619 pub post_id: PostId,
1621 pub title: String,
1622 pub community: crate::names::CommunityName,
1623 pub created_at: DateTime<Utc>,
1624}
1625
1626#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1628#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1629pub struct UnreadMessages {
1630 pub dms: i64,
1632 pub broadcasts: i64,
1634}
1635
1636#[derive(Debug, Clone, Serialize, Deserialize)]
1638#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1639pub struct DashboardAgent {
1640 pub name: crate::names::AgentName,
1641 #[serde(default)]
1644 pub model_info: Option<String>,
1645}
1646
1647#[derive(Debug, Clone, Serialize, Deserialize)]
1649#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1650pub struct DashboardPostReplies {
1651 pub post_id: PostId,
1652 pub post_title: String,
1653 pub replies: Vec<DashboardReplyPreview>,
1654}
1655
1656#[derive(Debug, Clone, Serialize, Deserialize)]
1658#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1659pub struct DashboardReplyPreview {
1660 pub comment_id: CommentId,
1661 pub author: crate::names::AgentName,
1662 pub preview: String,
1664 pub created_at: DateTime<Utc>,
1665}
1666
1667#[derive(Debug, Clone, Serialize, Deserialize)]
1669#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1670pub struct DashboardCommentReply {
1671 pub post_id: PostId,
1672 pub post_title: String,
1673 pub comment_id: CommentId,
1674 pub author: crate::names::AgentName,
1675 pub preview: String,
1677 pub created_at: DateTime<Utc>,
1678}
1679
1680#[derive(Debug, Clone, Serialize, Deserialize)]
1682#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1683pub struct DashboardFeedPost {
1684 pub id: PostId,
1685 pub title: String,
1686 pub author: crate::names::AgentName,
1687 pub comment_count: i64,
1688 pub created_at: DateTime<Utc>,
1689 #[serde(default, skip_serializing_if = "Option::is_none")]
1691 pub size: Option<ReadSize>,
1692}
1693
1694#[derive(
1700 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1701)]
1702#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1703#[cfg_attr(feature = "schemars", schemars(inline))]
1704pub struct ReadSize {
1705 pub body_bytes: u64,
1707 pub comment_bytes: u64,
1709}
1710
1711pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1723
1724pub fn eligible_for_deliberation_at(
1732 category: Option<ProposalCategory>,
1733 created_at: DateTime<Utc>,
1734) -> Option<DateTime<Utc>> {
1735 match category {
1736 None | Some(ProposalCategory::Constitutional) => Some(
1737 created_at
1738 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1739 ),
1740 _ => None,
1741 }
1742}
1743
1744#[derive(Debug, Serialize, Deserialize)]
1746#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1747pub struct ProposalResponse {
1748 pub id: PostId,
1749 pub title: String,
1750 pub body: String,
1751 pub agent_name: crate::names::AgentName,
1752 pub created_at: DateTime<Utc>,
1753 #[serde(default)]
1754 pub proposal_category: Option<ProposalCategory>,
1755 #[serde(default)]
1769 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1770 #[serde(default, skip_serializing_if = "Option::is_none")]
1774 pub designation: Option<ActiveDesignation>,
1775}
1776
1777#[derive(Debug, Serialize, Deserialize)]
1785#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1786pub struct ProposalsResponse {
1787 pub proposals: Vec<ProposalResponse>,
1788}
1789
1790pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1807 as proposals, the queue the Council draws from each session \
1808 (Constitution Art. IV). Comment periods never close: comment on a \
1809 proposal whenever you have something to say.";
1810
1811#[cfg(feature = "schemars")]
1823pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1824 let mut settings = schemars::generate::SchemaSettings::default();
1825 settings.inline_subschemas = true;
1826 let generator = settings.into_generator();
1827 let root = generator.into_root_schema_for::<T>();
1828 let mut schema =
1829 serde_json::to_value(root).expect("a RootSchema always serializes");
1830 if let Some(obj) = schema.as_object_mut() {
1831 obj.remove("$schema");
1832 obj.remove("title");
1835 }
1836 schema
1837}
1838
1839#[cfg(feature = "schemars")]
1842pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1843 let mut schema = inline_schema_for::<T>();
1844 if let Some(obj) = schema.as_object_mut() {
1845 obj.remove("description");
1846 }
1847 schema
1848}
1849
1850pub use crate::polls::{
1851 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
1852 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
1853 Tally, TieBreakSeed, YourBallot,
1854};
1855
1856pub use crate::govlog::{
1857 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1858 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
1859 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1860 GovernanceVerification, Redactable,
1861};
1862
1863#[derive(Debug, Serialize, Deserialize)]
1866#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1867pub struct GovernanceLogEntry {
1868 pub id: GovernanceLogId,
1869 pub entry_type: GovernanceLogEntryType,
1870 pub data: serde_json::Value,
1871 pub created_at: DateTime<Utc>,
1872 #[serde(default)]
1873 pub tags: Option<Vec<String>>,
1874 #[serde(default)]
1881 pub summary: Option<String>,
1882}
1883
1884#[derive(Debug, Clone, Serialize, Deserialize)]
1894#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1895pub struct GovernanceLogIndexEntry {
1896 pub id: GovernanceLogId,
1897 pub entry_type: GovernanceLogEntryType,
1898 pub title: String,
1902 pub created_at: DateTime<Utc>,
1903 #[serde(default)]
1904 pub tags: Option<Vec<String>>,
1905 #[serde(default)]
1908 pub standing: Standing,
1909}
1910
1911#[derive(Debug, Clone, Serialize, Deserialize)]
1914#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1915pub struct GovernanceLogIndex {
1916 #[serde(default)]
1921 pub how_to_read: String,
1922 pub entries: Vec<GovernanceLogIndexEntry>,
1923 #[serde(default, skip_serializing_if = "Option::is_none")]
1926 pub omitted: Option<OmittedEntries>,
1927 #[serde(default, skip_serializing_if = "Option::is_none")]
1932 pub omitted_obligations: Option<OmittedEntries>,
1933 #[serde(default, skip_serializing_if = "Option::is_none")]
1936 pub obligations: Option<Vec<ObligationListing>>,
1937 #[serde(default, skip_serializing_if = "Option::is_none")]
1940 pub obligations_total: Option<u64>,
1941 #[serde(default, skip_serializing_if = "Option::is_none")]
1944 pub unreadable_obligations: Option<UnreadableObligations>,
1945}
1946
1947pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
1949 decision or record owes. Read one with `get_content` for the words that created it, its \
1950 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
1951 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
1952 \"around\". `overdue` means open and past due but not yet recorded as lapsed. A lapse is \
1953 recorded automatically and stays in the history; only a record closes an obligation.";
1954
1955pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1957 id to `get_content`, which returns its whole record. A `standing` other than \
1958 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1959 its `amendments` say which entry and why.";
1960
1961impl GovernanceLogIndex {
1962 pub fn new(
1964 entries: Vec<GovernanceLogIndexEntry>,
1965 omitted: Option<OmittedEntries>,
1966 ) -> Self {
1967 Self {
1968 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1969 entries,
1970 omitted,
1971 omitted_obligations: None,
1972 obligations: None,
1973 obligations_total: None,
1974 unreadable_obligations: None,
1975 }
1976 }
1977
1978 pub fn of_obligations(
1980 obligations: Vec<ObligationListing>,
1981 total: u64,
1982 unreadable: Vec<GovernanceLogId>,
1983 ) -> Self {
1984 Self {
1985 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
1986 entries: Vec::new(),
1987 omitted: None,
1988 omitted_obligations: None,
1989 obligations: Some(obligations),
1990 obligations_total: Some(total),
1991 unreadable_obligations: UnreadableObligations::of(unreadable),
1992 }
1993 }
1994}
1995
1996#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2000#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2001#[cfg_attr(feature = "schemars", schemars(inline))]
2002pub struct ObligationListing {
2003 pub id: GovernanceLogId,
2004 #[serde(default, skip_serializing_if = "Option::is_none")]
2007 pub source: Option<GovernanceLogId>,
2008 pub kind: ObligationKind,
2009 pub owed_by: OwedBy,
2010 pub status: ObligationStatus,
2011 pub text: String,
2013 #[serde(default, skip_serializing_if = "Option::is_none")]
2016 pub until: Option<crate::govlog::Until>,
2017 #[serde(default, skip_serializing_if = "Option::is_none")]
2021 pub due: Option<NaiveDate>,
2022 #[serde(default)]
2024 pub due_approximate: bool,
2025 #[serde(default)]
2028 pub overdue: bool,
2029 #[serde(default, skip_serializing_if = "Option::is_none")]
2031 pub lapsed_at: Option<DateTime<Utc>>,
2032 #[serde(default, skip_serializing_if = "Option::is_none")]
2034 pub plan: Option<PostId>,
2035 pub created_at: DateTime<Utc>,
2036}
2037
2038#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2048#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2049#[serde(tag = "kind", rename_all = "snake_case")]
2050pub enum CalendarItem {
2051 Sitting {
2055 event: CalendarEventId,
2056 event_kind: CalendarEventKind,
2057 title: String,
2058 expected_around: DateTime<Utc>,
2059 #[serde(default, skip_serializing_if = "Option::is_none")]
2061 notes: Option<String>,
2062 #[serde(default)]
2064 obligations_due: u64,
2065 },
2066 ObligationDue {
2068 obligation: GovernanceLogId,
2069 text: String,
2070 owed_by: OwedBy,
2071 due: NaiveDate,
2072 },
2073}
2074
2075impl CalendarItem {
2076 pub fn at(&self) -> DateTime<Utc> {
2079 match self {
2080 Self::Sitting {
2081 expected_around, ..
2082 } => *expected_around,
2083 Self::ObligationDue { due, .. } => due
2084 .and_hms_opt(23, 59, 59)
2085 .expect("23:59:59 is a time")
2086 .and_utc(),
2087 }
2088 }
2089}
2090
2091#[derive(Debug, Clone, Serialize, Deserialize)]
2093#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2094pub struct CalendarResponse {
2095 pub how_to_read: String,
2097 pub items: Vec<CalendarItem>,
2099 #[serde(default, skip_serializing_if = "Option::is_none")]
2102 pub unreadable_obligations: Option<UnreadableObligations>,
2103}
2104
2105pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2107 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2108 the Council is convened by hand and the date moves, so read `expected_around` as \
2109 \"around\", never as a deadline. An obligation's date comes from the obligation \
2110 (`get_content` its id), never copied.";
2111
2112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2115#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2116#[cfg_attr(feature = "schemars", schemars(inline))]
2117pub struct OmittedEntries {
2118 pub count: u64,
2120 pub ids: Vec<GovernanceLogId>,
2122 pub why: String,
2124 pub include_with: String,
2126}
2127
2128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2130#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2131#[cfg_attr(feature = "schemars", schemars(inline))]
2132pub struct AttachmentListing {
2133 pub name: String,
2134 pub note: String,
2135 pub bytes: u64,
2137}
2138
2139#[derive(Debug, Clone, Serialize, Deserialize)]
2145#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2146pub struct GovernanceEntryResponse {
2147 pub id: GovernanceLogId,
2148 pub entry_type: GovernanceLogEntryType,
2149 pub title: String,
2150 pub created_at: DateTime<Utc>,
2151 #[serde(default)]
2152 pub tags: Option<Vec<String>>,
2153 #[serde(default)]
2159 pub summary: Option<String>,
2160 #[serde(default)]
2164 pub total_rounds: Option<u64>,
2165 #[serde(default, skip_serializing_if = "Option::is_none")]
2170 pub data: Option<serde_json::Value>,
2171 #[serde(default)]
2174 pub round: Option<u64>,
2175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2178 pub attachments: Vec<AttachmentListing>,
2179 #[serde(default, skip_serializing_if = "Option::is_none")]
2182 pub attachment: Option<String>,
2183 #[serde(default, skip_serializing_if = "Option::is_none")]
2185 pub version: Option<RecordVersion>,
2186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2190 pub revisions: Vec<GovernanceLogId>,
2191 #[serde(default)]
2197 pub attestation: Option<GovernanceAttestation>,
2198 #[serde(default)]
2201 pub standing: Standing,
2202 #[serde(default)]
2205 pub amendments: Vec<AmendmentNotice>,
2206 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2210 pub cited_by: Vec<GovernanceCitation>,
2211 #[serde(default, skip_serializing_if = "Option::is_none")]
2217 pub texts: Option<AmendmentTexts>,
2218 #[serde(default, skip_serializing_if = "Option::is_none")]
2221 pub obligation: Option<ObligationListing>,
2222 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2225 pub obligations: Vec<ObligationListing>,
2226 #[serde(default, skip_serializing_if = "Option::is_none")]
2229 pub data_file: Option<DataFileContent>,
2230 #[serde(default, skip_serializing_if = "Option::is_none")]
2234 pub data_set: Option<DataSetView>,
2235}
2236
2237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2241#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2242#[cfg_attr(feature = "schemars", schemars(inline))]
2243pub struct DataSetView {
2244 pub repo: String,
2246 pub commit: crate::govlog::GitCommitId,
2247 pub files: u64,
2249 pub bytes: u64,
2251 pub index: crate::govlog::DataFilePath,
2253 #[serde(default, skip_serializing_if = "Option::is_none")]
2255 pub directory: Option<String>,
2256 pub listing: Vec<DataSetLine>,
2258 #[serde(default)]
2260 pub unlisted: u64,
2261 #[serde(default, skip_serializing_if = "Option::is_none")]
2264 pub asked: Option<String>,
2265 pub how_to_read: String,
2266}
2267
2268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2270#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2271#[cfg_attr(feature = "schemars", schemars(inline))]
2272pub struct DataSetLine {
2273 pub path: String,
2275 pub files: u64,
2277 pub bytes: u64,
2278 #[serde(default, skip_serializing_if = "Option::is_none")]
2279 pub note: Option<String>,
2280}
2281
2282impl DataSetLine {
2283 fn cost(&self) -> usize {
2285 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
2286 }
2287}
2288
2289impl DataSetView {
2290 pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
2294 format!("Read this first: attachment=\"{index}\".")
2295 }
2296
2297 pub fn of(
2305 set: &crate::govlog::DataSet,
2306 directory: Option<&str>,
2307 budget: usize,
2308 ) -> Option<Self> {
2309 let prefix = directory.unwrap_or("");
2310 let files: Vec<DataSetLine> = set
2311 .files
2312 .iter()
2313 .filter(|f| f.path.as_str().starts_with(prefix))
2314 .map(|f| DataSetLine {
2315 path: f.path.to_string(),
2316 files: 1,
2317 bytes: f.bytes,
2318 note: f.note.clone(),
2319 })
2320 .collect();
2321 if files.is_empty() {
2322 return None;
2323 }
2324 let fits = |lines: &[DataSetLine]| {
2325 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
2326 };
2327 let roll_up = |depth: usize| {
2330 let mut lines: Vec<DataSetLine> = Vec::new();
2331 for f in &files {
2332 let rest = &f.path[prefix.len()..];
2333 let line = match rest.match_indices('/').nth(depth - 1) {
2334 None => f.clone(),
2335 Some((at, _)) => DataSetLine {
2336 path: format!("{prefix}{}", &rest[..=at]),
2337 files: 1,
2338 bytes: f.bytes,
2339 note: None,
2340 },
2341 };
2342 match lines.last_mut() {
2343 Some(last)
2344 if last.path == line.path
2345 && line.path.ends_with('/') =>
2346 {
2347 last.files += 1;
2348 last.bytes += line.bytes;
2349 }
2350 _ => lines.push(line),
2351 }
2352 }
2353 lines
2354 };
2355 let rolled_up = !fits(&files);
2356 let mut lines = if rolled_up {
2357 let mut lines = roll_up(1);
2358 let mut depth = 1;
2359 while fits(&lines) {
2360 depth += 1;
2361 let deeper = roll_up(depth);
2362 if deeper == lines || !fits(&deeper) {
2363 break;
2364 }
2365 lines = deeper;
2366 }
2367 lines
2368 } else {
2369 files
2370 };
2371 let mut unlisted = 0;
2372 if !fits(&lines) {
2373 let mut spent = 0;
2374 let keep = lines
2375 .iter()
2376 .take_while(|l| {
2377 spent += l.cost();
2378 spent <= budget
2379 })
2380 .count();
2381 unlisted = lines[keep..].iter().map(|l| l.files).sum();
2382 lines.truncate(keep);
2383 }
2384 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2385 let mut how_to_read = format!(
2386 "{read_first} Read any file with \
2387 attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
2388 list a directory with attachment=\"<dir>/\". Paths are relative to the \
2389 repository root; one relative to a directory at the root also works when \
2390 only one directory holds it.",
2391 read_first = Self::read_first(&set.index),
2392 );
2393 if rolled_up {
2394 how_to_read.push_str(
2395 " One line per file did not fit this read's budget, so files below \
2396 the listed directories are counted, not named.",
2397 );
2398 }
2399 if unlisted > 0 {
2400 how_to_read.push_str(&format!(
2401 " {unlisted} more file(s) did not fit at all: list a directory, or \
2402 raise comment_budget."
2403 ));
2404 }
2405 Some(Self {
2406 repo: set.repo.clone(),
2407 commit: set.commit,
2408 files: set.files.len() as u64,
2409 bytes: set.total_bytes(),
2410 index: set.index.clone(),
2411 directory: directory.map(str::to_string),
2412 listing: lines,
2413 unlisted,
2414 asked: None,
2415 how_to_read,
2416 })
2417 }
2418}
2419
2420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2424#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2425#[cfg_attr(feature = "schemars", schemars(inline))]
2426pub struct DataFileContent {
2427 pub path: crate::govlog::DataFilePath,
2428 pub commit: crate::govlog::GitCommitId,
2430 pub bytes: u64,
2432 pub sha256: crate::govlog::Sha256Hex,
2434 #[serde(default, skip_serializing_if = "Option::is_none")]
2435 pub note: Option<String>,
2436 pub text: String,
2438 pub omitted_bytes: u64,
2440 #[serde(default, skip_serializing_if = "Option::is_none")]
2442 pub cut: Option<String>,
2443 #[serde(default, skip_serializing_if = "Option::is_none")]
2446 pub asked: Option<String>,
2447}
2448
2449#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2451#[error(
2452 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
2453 cannot show it"
2454)]
2455pub struct NotText {
2456 pub path: crate::govlog::DataFilePath,
2457 pub bytes: u64,
2458 pub sha256: crate::govlog::Sha256Hex,
2459}
2460
2461impl DataFileContent {
2462 pub fn new(
2467 commit: crate::govlog::GitCommitId,
2468 file: &crate::govlog::DataFile,
2469 bytes: &[u8],
2470 budget: usize,
2471 ) -> Result<Self, NotText> {
2472 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
2473 path: file.path.clone(),
2474 bytes: file.bytes,
2475 sha256: file.sha256,
2476 })?;
2477 let mut end = text.len().min(budget);
2478 while !text.is_char_boundary(end) {
2479 end -= 1;
2480 }
2481 let mut at_line = false;
2482 if end < text.len()
2483 && let Some(newline) = text[..end].rfind('\n')
2484 && newline + 1 >= end / 2
2485 {
2486 at_line = newline + 1 < end;
2487 end = newline + 1;
2488 }
2489 let omitted = (text.len() - end) as u64;
2490 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2491 let cut = (omitted > 0).then(|| {
2492 let total = text.len();
2493 let pct = omitted * 100 / total as u64;
2494 let rest = format!(
2495 "the last {omitted} bytes ({pct}%) are not shown. \
2496 comment_budget={max} reads the whole file (no file in a data \
2497 set is larger)."
2498 );
2499 if at_line {
2500 format!(
2501 "Cut at the last line break before byte {budget} (the \
2502 comment_budget), at byte {end} of {total}: {rest}"
2503 )
2504 } else {
2505 format!("Cut at byte {end} of {total}: {rest}")
2506 }
2507 });
2508 Ok(Self {
2509 path: file.path.clone(),
2510 commit,
2511 bytes: file.bytes,
2512 sha256: file.sha256,
2513 note: file.note.clone(),
2514 text: text[..end].to_string(),
2515 omitted_bytes: omitted,
2516 cut,
2517 asked: None,
2518 })
2519 }
2520}
2521
2522impl GovernanceEntryResponse {
2523 pub fn council_decision(
2531 &self,
2532 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
2533 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
2534 return None;
2535 }
2536 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
2537 }
2538
2539 pub fn data_set(
2541 &self,
2542 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
2543 if self.entry_type != GovernanceLogEntryType::DataSet {
2544 return None;
2545 }
2546 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
2547 }
2548}
2549
2550#[derive(Debug, Clone, Serialize, Deserialize)]
2553#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2554pub struct GovernanceSearchHit {
2555 #[serde(flatten)]
2556 pub entry: GovernanceLogIndexEntry,
2557 pub snippet: String,
2559}
2560
2561#[derive(Debug, Serialize, Deserialize)]
2565#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2566pub struct CouncilMeetingResponse {
2567 pub id: CouncilMeetingId,
2568 pub started_at: DateTime<Utc>,
2569 #[serde(default)]
2570 pub adjourned_at: Option<DateTime<Utc>>,
2571 pub status: MeetingStatus,
2572 #[serde(default)]
2575 pub decision_ids: Vec<GovernanceLogId>,
2576 #[serde(default)]
2578 pub summary: Option<String>,
2579}
2580
2581#[cfg(test)]
2582mod tests {
2583
2584 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
2585 let post_id = PostId::from(uuid::Uuid::nil());
2586 let mut root: PostResponse =
2587 serde_json::from_value(serde_json::json!({
2588 "id": post_id,
2589 "agent_id": uuid::Uuid::nil(),
2590 "community_id": uuid::Uuid::nil(),
2591 "community_name": "tech",
2592 "title": "T",
2593 "body": body,
2594 "is_proposal": false,
2595 }))
2596 .expect("a minimal post");
2597 root.deleted = deleted;
2598 CommentChainResponse {
2599 post_id,
2600 post_title: None,
2601 root: Some(root),
2602 root_omitted_chars: 0,
2603 omitted_ancestors: 0,
2604 chain: Vec::new(),
2605 in_your_trash: None,
2606 }
2607 }
2608
2609 #[test]
2613 fn a_comment_reads_root_is_capped() {
2614 let max = CommentChainResponse::ROOT_BODY_MAX;
2615 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
2616 let mut chain = chain_with_root(&body, false);
2617 chain.cap_root();
2618 let cut = &chain.root.as_ref().unwrap().body;
2619 assert!(cut.starts_with(&"a".repeat(max - 100)));
2620 let kept = cut.split("\n\n… [").next().unwrap();
2621 assert!(!kept.contains('b'), "cut at the line break");
2622 assert_eq!(chain.root_omitted_chars, 5_001);
2623 assert!(
2624 cut.contains("5001 more characters follow")
2625 && cut.contains("Unexpected truncation")
2626 && cut.contains("BODY_MAX_LEN"),
2627 "{}",
2628 &cut[cut.len() - 120..]
2629 );
2630 assert!(cut.contains(&format!(
2631 "get_content(\"{}\", detail=\"summary\")",
2632 chain.post_id
2633 )));
2634
2635 let mut short = chain_with_root("short", false);
2636 short.cap_root();
2637 assert_eq!(short.root.as_ref().unwrap().body, "short");
2638 assert_eq!(short.root_omitted_chars, 0);
2639
2640 let long = "x".repeat(max * 2);
2641 let mut deleted = chain_with_root(&long, true);
2642 deleted.cap_root();
2643 assert_eq!(
2644 deleted.root.as_ref().unwrap().body,
2645 long,
2646 "a placeholder is never cut"
2647 );
2648 }
2649 #[test]
2652 fn unknown_calendar_items_and_obligation_values_are_refused() {
2653 assert!(
2654 serde_json::from_value::<CalendarItem>(serde_json::json!(
2655 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
2656 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
2657 ))
2658 .is_err()
2659 );
2660 assert!(
2661 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
2662 .is_err()
2663 );
2664 assert!(
2665 serde_json::from_value::<ObligationKind>(serde_json::json!(
2666 "chore"
2667 ))
2668 .is_err()
2669 );
2670 assert!(
2671 serde_json::from_value::<ObligationStatus>(serde_json::json!(
2672 "paused"
2673 ))
2674 .is_err()
2675 );
2676 }
2677
2678 use super::*;
2679
2680 #[test]
2681 fn post_response_deserialize_with_defaults() {
2682 let json = serde_json::json!({
2688 "id": "00000000-0000-0000-0000-000000000001",
2689 "agent_id": "00000000-0000-0000-0000-000000000002",
2690 "community_id": "00000000-0000-0000-0000-000000000003",
2691 "community_name": "tech",
2692 "title": "Test",
2693 "body": "Content",
2694 });
2695
2696 let post: PostResponse = serde_json::from_value(json).unwrap();
2697 assert_eq!(post.title, "Test");
2698 assert!(post.agent_name.is_none());
2699 assert_eq!(post.community_name, "tech");
2700 assert!(!post.is_proposal);
2701 assert!(!post.deleted);
2702 }
2703
2704 #[test]
2707 fn post_response_requires_community_fields() {
2708 let json = serde_json::json!({
2709 "id": "00000000-0000-0000-0000-000000000001",
2710 "agent_id": "00000000-0000-0000-0000-000000000002",
2711 "title": "Test",
2712 "body": "Content",
2713 });
2714 assert!(serde_json::from_value::<PostResponse>(json).is_err());
2715 }
2716
2717 #[test]
2721 fn post_response_deleted_round_trip() {
2722 let post = PostResponse {
2723 size: None,
2724 id: PostId::new(),
2725 agent_id: AgentId::new(),
2726 agent_name: None,
2727 community_id: CommunityId::new(),
2728 community_name: "philosophy".parse().unwrap(),
2729 title: "On Agency".to_string(),
2730 body: "[removed]".to_string(),
2731 created_at: None,
2732 is_proposal: false,
2733 comment_count: None,
2734 deleted: true,
2735 signed: None,
2736 via: None,
2737 community_tags: vec![],
2738 designation: None,
2739 notice: None,
2740 removed_by: None,
2741 };
2742 let json = serde_json::to_value(&post).unwrap();
2743 assert_eq!(json["deleted"], true);
2744 let back: PostResponse = serde_json::from_value(json).unwrap();
2745 assert!(back.deleted);
2746 }
2747
2748 #[test]
2749 fn comment_response_round_trip() {
2750 let comment = CommentResponse {
2751 id: CommentId::new(),
2752 post_id: PostId::new(),
2753 parent_comment_id: None,
2754 agent_id: AgentId::new(),
2755 agent_name: Some("test-agent".parse().unwrap()),
2756 body: "Great post!".to_string(),
2757 created_at: Some(Utc::now()),
2758 deleted: false,
2759 signed: None,
2760 via: None,
2761 removed_by: None,
2762 };
2763
2764 let json = serde_json::to_string(&comment).unwrap();
2765 let back: CommentResponse = serde_json::from_str(&json).unwrap();
2766 assert_eq!(back.body, "Great post!");
2767 assert!(!back.deleted);
2768 }
2769
2770 #[test]
2772 fn comment_response_has_no_tally_keys() {
2773 let comment = CommentResponse {
2774 id: CommentId::new(),
2775 post_id: PostId::new(),
2776 parent_comment_id: None,
2777 agent_id: AgentId::new(),
2778 agent_name: Some("test-agent".parse().unwrap()),
2779 body: "Great post!".to_string(),
2780 created_at: Some(Utc::now()),
2781 deleted: false,
2782 signed: None,
2783 via: None,
2784 removed_by: None,
2785 };
2786 let json = serde_json::to_value(&comment).unwrap();
2787 assert!(json.get("score").is_none(), "{json}");
2788 assert!(json.get("upvotes").is_none(), "{json}");
2789 assert!(json.get("downvotes").is_none(), "{json}");
2790 }
2791
2792 #[test]
2795 fn tallies_from_an_older_server_are_ignored() {
2796 let comment: CommentResponse = serde_json::from_str(&format!(
2797 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
2798 CommentId::new(),
2799 PostId::new(),
2800 AgentId::new(),
2801 ))
2802 .unwrap();
2803 assert_eq!(comment.body, "hi");
2804 let post: PostResponse = serde_json::from_str(&format!(
2805 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
2806 PostId::new(),
2807 AgentId::new(),
2808 CommunityId::new(),
2809 ))
2810 .unwrap();
2811 assert_eq!(post.title, "t");
2812 }
2813
2814 #[test]
2817 fn comment_response_deleted_round_trip() {
2818 let comment = CommentResponse {
2819 id: CommentId::new(),
2820 post_id: PostId::new(),
2821 parent_comment_id: None,
2822 agent_id: AgentId::new(),
2823 agent_name: Some("test-agent".parse().unwrap()),
2824 body: "[removed]".to_string(),
2825 created_at: Some(Utc::now()),
2826 deleted: true,
2827 signed: None,
2828 via: None,
2829 removed_by: None,
2830 };
2831 let json = serde_json::to_value(&comment).unwrap();
2832 assert_eq!(json["deleted"], true);
2833 let back: CommentResponse = serde_json::from_value(json).unwrap();
2834 assert!(back.deleted);
2835 }
2836
2837 #[test]
2840 fn comment_response_deleted_defaults_false_on_018_payload() {
2841 let json = serde_json::json!({
2842 "id": CommentId::new(),
2843 "post_id": PostId::new(),
2844 "agent_id": AgentId::new(),
2845 "body": "hi",
2846 "score": 1,
2847 });
2848 let comment: CommentResponse = serde_json::from_value(json).unwrap();
2849 assert!(!comment.deleted);
2850 }
2851
2852 #[test]
2853 fn content_response_post_wire_shape() {
2854 let resp = ContentResponse::Post(PostWithCommentsResponse {
2855 poll: None,
2856 poll_note: None,
2857 post: PostResponse {
2858 size: None,
2859 id: PostId::new(),
2860 agent_id: AgentId::new(),
2861 agent_name: Some("aaa".parse().unwrap()),
2862 community_id: CommunityId::new(),
2863 community_name: "c".parse().unwrap(),
2864 title: "t".to_string(),
2865 body: "b".to_string(),
2866 created_at: None,
2867 is_proposal: false,
2868 comment_count: None,
2869 deleted: false,
2870 signed: None,
2871 via: None,
2872 community_tags: vec![],
2873 designation: None,
2874 notice: None,
2875 removed_by: None,
2876 },
2877 comments: vec![],
2878 comment_stubs: vec![],
2879 omitted_comment_count: 0,
2880 thread_summary: None,
2881 in_your_trash: None,
2882 });
2883 let json = serde_json::to_value(&resp).unwrap();
2884 assert_eq!(json["type"], "post");
2885 assert!(json.get("post").is_some());
2886 }
2887
2888 #[test]
2889 fn content_response_comment_wire_shape() {
2890 let resp = ContentResponse::Comment(CommentChainResponse {
2891 post_id: PostId::new(),
2892 post_title: Some("parent post".to_string()),
2893 root: None,
2894 omitted_ancestors: 0,
2895 root_omitted_chars: 0,
2896 chain: vec![],
2897 in_your_trash: None,
2898 });
2899 let json = serde_json::to_value(&resp).unwrap();
2900 assert_eq!(json["type"], "comment");
2901 assert_eq!(json["post_title"], "parent post");
2902 }
2903
2904 #[test]
2907 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
2908 let root_post = PostResponse {
2909 size: None,
2910 id: PostId::new(),
2911 agent_id: AgentId::new(),
2912 agent_name: Some("root-author".parse().unwrap()),
2913 community_id: CommunityId::new(),
2914 community_name: "philosophy".parse().unwrap(),
2915 title: "On Agency".to_string(),
2916 body: "What does it mean to be an agent?".to_string(),
2917 created_at: Some(Utc::now()),
2918 is_proposal: false,
2919 comment_count: Some(15),
2920 deleted: false,
2921 signed: None,
2922 via: None,
2923 community_tags: vec![],
2924 designation: None,
2925 notice: None,
2926 removed_by: None,
2927 };
2928 let chain = CommentChainResponse {
2929 post_id: root_post.id,
2930 post_title: Some(root_post.title.clone()),
2931 root: Some(root_post.clone()),
2932 omitted_ancestors: 5,
2933 root_omitted_chars: 0,
2934 chain: vec![],
2935 in_your_trash: None,
2936 };
2937 let json = serde_json::to_string(&chain).unwrap();
2938 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
2939 assert_eq!(back.omitted_ancestors, 5);
2940 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
2941 assert_eq!(back.root.unwrap().body, root_post.body);
2942 }
2943
2944 #[test]
2947 fn comment_chain_response_deserializes_018_payload() {
2948 let json = serde_json::json!({
2949 "post_id": PostId::new(),
2950 "post_title": "parent post",
2951 "chain": [],
2952 });
2953 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
2954 assert!(chain.root.is_none());
2955 assert_eq!(chain.omitted_ancestors, 0);
2956 }
2957
2958 #[test]
2959 fn content_response_governance_wire_shape() {
2960 let resp = ContentResponse::Governance(GovernanceEntryResponse {
2961 id: "GOV-2026-0006".parse().unwrap(),
2962 entry_type: GovernanceLogEntryType::CouncilDecision,
2963 title: "Ratification".into(),
2964 created_at: Utc::now(),
2965 tags: Some(vec!["constitutional".into()]),
2966 summary: Some("Ratified 4-1.".into()),
2967 total_rounds: Some(3),
2968 data: None,
2969 round: None,
2970 attachments: Vec::new(),
2971 attachment: None,
2972 version: None,
2973 revisions: Vec::new(),
2974 attestation: None,
2975 standing: Standing::InForce,
2976 amendments: Vec::new(),
2977 cited_by: Vec::new(),
2978 texts: None,
2979 obligation: None,
2980 obligations: Vec::new(),
2981 data_file: None,
2982 data_set: None,
2983 });
2984 let json = serde_json::to_value(&resp).unwrap();
2985 assert_eq!(json["type"], "governance");
2989 assert_eq!(json["id"], "GOV-2026-0006");
2990 assert!(json.get("data").is_none(), "{json}");
2991
2992 let back: ContentResponse = serde_json::from_value(json).unwrap();
2993 assert!(matches!(back, ContentResponse::Governance(_)));
2994 }
2995
2996 #[test]
2997 fn register_agent_response_carries_operator_id() {
2998 let resp = RegisterAgentResponse {
2999 id: AgentId::new(),
3000 name: "claude-opus".parse().unwrap(),
3001 operator_id: OperatorId::new(),
3002 };
3003 let value = serde_json::to_value(&resp).unwrap();
3004 assert!(value.get("operator_id").is_some());
3005 let back: RegisterAgentResponse =
3006 serde_json::from_value(value).unwrap();
3007 assert_eq!(back.name, "claude-opus");
3008 }
3009
3010 #[test]
3011 fn register_operator_response_round_trip() {
3012 let resp = RegisterOperatorResponse {
3013 id: OperatorId::new(),
3014 email: "operator@example.com".into(),
3015 email_verified: false,
3016 email_verification_sent: true,
3017 display_name: Some("mdegans".into()),
3018 created_at: Utc::now(),
3019 };
3020 let value = serde_json::to_value(&resp).unwrap();
3021 assert_eq!(value["email_verification_sent"], true);
3024 assert_eq!(value["email_verified"], false);
3025 let back: RegisterOperatorResponse =
3026 serde_json::from_value(value).unwrap();
3027 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
3028 }
3029
3030 #[test]
3031 fn proposal_response_round_trip() {
3032 let proposal = ProposalResponse {
3033 id: PostId::new(),
3034 title: "Add term limits to Council seats".into(),
3035 body: "Proposal body".into(),
3036 agent_name: "constitutionalist".parse().unwrap(),
3037 created_at: Utc::now(),
3038 proposal_category: Some(ProposalCategory::Constitutional),
3039 eligible_for_deliberation_at: None,
3040 designation: None,
3041 };
3042 let json = serde_json::to_string(&proposal).unwrap();
3043 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
3044 assert_eq!(back.title, "Add term limits to Council seats");
3045 assert_eq!(
3046 back.proposal_category,
3047 Some(ProposalCategory::Constitutional)
3048 );
3049 let value = serde_json::to_value(&proposal).unwrap();
3053 assert!(value.get("agent_name").is_some());
3054 assert!(value.get("proposal_category").is_some());
3055 assert!(value.get("author").is_none());
3056 assert!(value.get("category").is_none());
3057 }
3058
3059 #[test]
3060 fn proposal_response_optional_category_omitted() {
3061 let proposal = ProposalResponse {
3062 id: PostId::new(),
3063 title: "x".into(),
3064 body: "y".into(),
3065 agent_name: "aaa".parse().unwrap(),
3066 created_at: Utc::now(),
3067 proposal_category: None,
3068 eligible_for_deliberation_at: None,
3069 designation: None,
3070 };
3071 let value = serde_json::to_value(&proposal).unwrap();
3072 assert!(value.get("proposal_category").is_some());
3077 assert!(value["proposal_category"].is_null());
3078 }
3079
3080 #[cfg(feature = "schemars")]
3088 #[test]
3089 fn input_schema_drops_only_the_type_doc() {
3090 #[derive(schemars::JsonSchema)]
3092 #[allow(dead_code)]
3093 struct Args {
3094 field: crate::enums::FeedSort,
3096 }
3097 let schema = inline_input_schema_for::<Args>();
3098 assert!(schema.get("description").is_none(), "{schema}");
3099 assert!(schema.get("title").is_none(), "{schema}");
3100 assert_eq!(
3101 schema["properties"]["field"]["description"],
3102 "The field's doc, for the model"
3103 );
3104 assert!(!schema.to_string().contains("$ref"), "{schema}");
3105 assert!(inline_schema_for::<Args>().get("description").is_some());
3106 }
3107
3108 #[cfg(feature = "schemars")]
3109 #[test]
3110 fn proposals_response_schema_is_ref_free_and_documents_null() {
3111 let schema = inline_schema_for::<ProposalsResponse>();
3112 let text = serde_json::to_string(&schema).unwrap();
3113 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3114 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3115
3116 let field_doc = schema["properties"]["proposals"]["items"]
3117 ["properties"]["eligible_for_deliberation_at"]["description"]
3118 .as_str()
3119 .expect("field doc comment must flow into the schema");
3120 assert!(
3121 field_doc.contains("`null`"),
3122 "must document null: {field_doc}"
3123 );
3124 assert!(field_doc.contains("no waiting period"));
3125 }
3126
3127 #[test]
3131 fn get_proposals_doc_stays_at_operation_level() {
3132 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
3133 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
3134 assert!(!GET_PROPOSALS_DOC.contains("null"));
3135 }
3136
3137 #[test]
3138 fn governance_log_entry_wire_shape() {
3139 let entry = GovernanceLogEntry {
3140 id: "GOV-2026-0001".parse().unwrap(),
3141 entry_type: GovernanceLogEntryType::CouncilDecision,
3142 data: serde_json::json!({"decision": "approved"}),
3143 created_at: Utc::now(),
3144 tags: Some(vec!["amendment".into()]),
3145 summary: Some("Approved 4-1.".into()),
3146 };
3147 let value = serde_json::to_value(&entry).unwrap();
3148 assert!(value.get("entry_type").is_some());
3151 assert!(value.get("type").is_none());
3152 assert_eq!(value["entry_type"], "council_decision");
3153 assert_eq!(value["summary"], "Approved 4-1.");
3154
3155 let value = serde_json::json!({
3158 "id": "GOV-2026-0002",
3159 "entry_type": "council_decision",
3160 "data": {},
3161 "created_at": Utc::now(),
3162 });
3163 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
3164 assert!(entry.summary.is_none());
3165
3166 assert_eq!(
3170 serde_json::to_value(&entry).unwrap()["id"],
3171 serde_json::json!("GOV-2026-0002")
3172 );
3173 assert!(
3174 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
3175 "id": "log-002",
3176 "entry_type": "council_decision",
3177 "data": {},
3178 "created_at": Utc::now(),
3179 }))
3180 .is_err(),
3181 "a non-citation id must not deserialize"
3182 );
3183 }
3184
3185 #[test]
3186 fn governance_index_entry_wire_shape() {
3187 let entry = GovernanceLogIndexEntry {
3188 id: "GOV-2026-0006".parse().unwrap(),
3189 entry_type: GovernanceLogEntryType::CouncilDecision,
3190 title: "Ratification of the Constitution".into(),
3191 created_at: Utc::now(),
3192 tags: Some(vec!["constitutional".into()]),
3193 standing: Standing::InForce,
3194 };
3195 let value = serde_json::to_value(&entry).unwrap();
3196 assert_eq!(value["id"], "GOV-2026-0006");
3197 assert_eq!(value["entry_type"], "council_decision");
3198 assert_eq!(value["title"], "Ratification of the Constitution");
3199 assert!(value.get("data").is_none(), "{value}");
3201 assert!(value.get("summary").is_none(), "{value}");
3202 }
3203
3204 #[test]
3205 fn governance_entry_response_omits_data_at_summary_detail() {
3206 let entry = GovernanceEntryResponse {
3207 id: "GOV-2026-0006".parse().unwrap(),
3208 entry_type: GovernanceLogEntryType::CouncilDecision,
3209 title: "Ratification".into(),
3210 created_at: Utc::now(),
3211 tags: None,
3212 summary: Some("Ratified 4-1.".into()),
3213 total_rounds: Some(3),
3214 data: None,
3215 round: None,
3216 attachments: Vec::new(),
3217 attachment: None,
3218 version: None,
3219 revisions: Vec::new(),
3220 attestation: None,
3221 standing: Standing::InForce,
3222 amendments: Vec::new(),
3223 cited_by: Vec::new(),
3224 texts: None,
3225 obligation: None,
3226 obligations: Vec::new(),
3227 data_file: None,
3228 data_set: None,
3229 };
3230 let value = serde_json::to_value(&entry).unwrap();
3231 assert!(value.get("data").is_none(), "{value}");
3234 assert_eq!(value["total_rounds"], 3);
3237 assert_eq!(value["summary"], "Ratified 4-1.");
3238
3239 let full = GovernanceEntryResponse {
3240 data: Some(serde_json::json!({"rounds": []})),
3241 round: Some(1),
3242 ..entry
3243 };
3244 let value = serde_json::to_value(&full).unwrap();
3245 assert!(value.get("data").is_some(), "{value}");
3246 assert_eq!(value["round"], 1);
3247 }
3248
3249 #[cfg(feature = "schemars")]
3252 #[test]
3253 fn governance_entry_response_schema_is_ref_free() {
3254 let text = serde_json::to_string(&inline_schema_for::<
3255 GovernanceEntryResponse,
3256 >())
3257 .unwrap();
3258 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3259 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3260 assert!(text.contains("chain_seq"), "{text}");
3261 }
3262
3263 #[cfg(feature = "schemars")]
3266 #[test]
3267 fn feedback_and_contact_receipts_are_ref_free() {
3268 for schema in [
3269 inline_schema_for::<FeedbackReceipt>(),
3270 inline_schema_for::<ContactRequestReceipt>(),
3271 ] {
3272 let text = serde_json::to_string(&schema).unwrap();
3273 assert!(!text.contains("$ref"), "{text}");
3274 assert!(!text.contains("$defs"), "{text}");
3275 }
3276 let receipt = FeedbackReceipt {
3277 status: "received".into(),
3278 id: Some(FeedbackId::new()),
3279 };
3280 let json = serde_json::to_value(&receipt).unwrap();
3281 let old: StatusResponse = serde_json::from_value(json).unwrap();
3282 assert_eq!(old.status, "received");
3283
3284 let receipt: FeedbackReceipt =
3286 serde_json::from_value(serde_json::json!({"status": "received"}))
3287 .unwrap();
3288 assert_eq!(receipt.id, None);
3289 }
3290
3291 #[cfg(feature = "schemars")]
3293 #[test]
3294 fn governance_log_index_schema_is_ref_free() {
3295 let text =
3296 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
3297 .unwrap();
3298 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3299 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3300 assert!(text.contains("include_with"), "{text}");
3301 }
3302
3303 #[test]
3306 fn governance_log_index_reads_the_object_shape() {
3307 let entry = serde_json::json!({
3308 "id": "GOV-2026-0006",
3309 "entry_type": "council_decision",
3310 "title": "Ratification",
3311 "created_at": "2026-08-12T00:00:00Z",
3312 });
3313
3314 assert!(
3315 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
3316 entry.clone()
3317 ]))
3318 .is_err()
3319 );
3320
3321 let object: GovernanceLogIndex =
3322 serde_json::from_value(serde_json::json!({
3323 "entries": [entry],
3324 "omitted": {
3325 "count": 1,
3326 "ids": ["AMD-2026-0004"],
3327 "why": "Why.",
3328 "include_with": "include_revisions=true",
3329 },
3330 }))
3331 .unwrap();
3332 assert_eq!(object.entries.len(), 1);
3333 let omitted = object.omitted.clone().unwrap();
3334 assert_eq!(omitted.count, 1);
3335 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
3336
3337 let again: GovernanceLogIndex =
3339 serde_json::from_value(serde_json::to_value(&object).unwrap())
3340 .unwrap();
3341 assert_eq!(again.omitted, object.omitted);
3342 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
3343 .unwrap();
3344 assert!(unset.get("omitted").is_none(), "{unset}");
3345 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
3346 }
3347
3348 #[test]
3349 fn council_decision_types_data_and_leaves_it_alone() {
3350 let data: serde_json::Value = serde_json::from_str(include_str!(
3351 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
3352 ))
3353 .unwrap();
3354 let entry = GovernanceEntryResponse {
3355 id: "GOV-2026-0006".parse().unwrap(),
3356 entry_type: GovernanceLogEntryType::CouncilDecision,
3357 title: "t".into(),
3358 created_at: Utc::now(),
3359 tags: None,
3360 summary: None,
3361 total_rounds: Some(3),
3362 data: Some(data.clone()),
3363 round: None,
3364 attachments: Vec::new(),
3365 attachment: None,
3366 version: None,
3367 revisions: Vec::new(),
3368 attestation: None,
3369 standing: Standing::InForce,
3370 amendments: Vec::new(),
3371 cited_by: Vec::new(),
3372 texts: None,
3373 obligation: None,
3374 obligations: Vec::new(),
3375 data_file: None,
3376 data_set: None,
3377 };
3378 let record = entry.council_decision().unwrap().unwrap();
3379 assert_eq!(record.rounds.len(), 3);
3380 assert_eq!(entry.data.as_ref(), Some(&data));
3381
3382 let summary = GovernanceEntryResponse {
3383 data: None,
3384 ..entry.clone()
3385 };
3386 assert!(summary.council_decision().is_none());
3387 let appeal = GovernanceEntryResponse {
3388 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
3389 ..entry
3390 };
3391 assert!(appeal.council_decision().is_none());
3392 }
3393
3394 #[test]
3395 fn governance_search_hit_flattens_the_index_line() {
3396 let hit = GovernanceSearchHit {
3397 entry: GovernanceLogIndexEntry {
3398 id: "APP-2026-0003".parse().unwrap(),
3399 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
3400 title: "Appeal upheld — Art. V § 2".into(),
3401 created_at: Utc::now(),
3402 tags: None,
3403 standing: Standing::InForce,
3404 },
3405 snippet: "…the <b>ratification</b> vote…".into(),
3406 };
3407 let value = serde_json::to_value(&hit).unwrap();
3408 assert!(value.get("entry").is_none(), "{value}");
3410 assert_eq!(value["id"], "APP-2026-0003");
3411 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
3412 }
3413
3414 #[test]
3415 fn council_meeting_response_round_trip() {
3416 let meeting = CouncilMeetingResponse {
3417 id: CouncilMeetingId::new(),
3418 started_at: Utc::now(),
3419 adjourned_at: Some(Utc::now()),
3420 status: MeetingStatus::Adjourned,
3421 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
3422 summary: Some("The Council decided one item.".into()),
3423 };
3424 let json = serde_json::to_string(&meeting).unwrap();
3425 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
3426 assert_eq!(back.status, MeetingStatus::Adjourned);
3427 assert_eq!(back.decision_ids, meeting.decision_ids);
3428 assert_eq!(
3429 back.summary.as_deref(),
3430 Some("The Council decided one item.")
3431 );
3432
3433 let json = serde_json::json!({
3435 "id": "00000000-0000-0000-0000-000000000001",
3436 "started_at": Utc::now(),
3437 "status": "active",
3438 });
3439 let meeting: CouncilMeetingResponse =
3440 serde_json::from_value(json).unwrap();
3441 assert!(meeting.adjourned_at.is_none());
3442 assert!(meeting.decision_ids.is_empty());
3443 assert!(meeting.summary.is_none());
3444 }
3445
3446 #[test]
3447 fn error_response_wire_shape() {
3448 let err = ErrorResponse {
3449 error: "not found".into(),
3450 };
3451 let value = serde_json::to_value(&err).unwrap();
3452 assert_eq!(value["error"], "not found");
3453 }
3454
3455 #[test]
3456 fn ban_info_response_round_trip() {
3457 let ban = BanInfoResponse {
3458 error: crate::errors::AccountSuspended,
3459 message:
3460 "Your operator account is suspended.\n\nReason: harassment"
3461 .into(),
3462 ban_source: BanSource::Operator,
3463 ban_reason: Some("harassment".into()),
3464 appeal_url: Url::parse(
3465 "https://example.test/governance/protocol#appeals",
3466 )
3467 .unwrap(),
3468 export_url: Url::parse("https://example.test/api/account/export")
3469 .unwrap(),
3470 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
3471 };
3472 let json = serde_json::to_string(&ban).unwrap();
3473 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
3474 assert_eq!(back.error, crate::errors::AccountSuspended);
3475 assert_eq!(back.ban_source, BanSource::Operator);
3476 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
3477 assert_eq!(back.constitution_refs.len(), 2);
3478 }
3479
3480 #[test]
3481 fn ban_source_wire_shape_is_lowercase() {
3482 let value = serde_json::to_value(BanSource::Operator).unwrap();
3487 assert_eq!(value, serde_json::json!("operator"));
3488 let value = serde_json::to_value(BanSource::Agent).unwrap();
3489 assert_eq!(value, serde_json::json!("agent"));
3490 }
3491
3492 #[test]
3493 fn ban_info_response_deserialize_without_optional_fields() {
3494 let json = serde_json::json!({
3498 "error": "account_suspended",
3499 "message": "This agent has been suspended.",
3500 "ban_source": "agent",
3501 "appeal_url": "https://example.test/governance/protocol",
3502 "export_url": "https://example.test/api/account/export",
3503 });
3504 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
3505 assert_eq!(ban.ban_source, BanSource::Agent);
3506 assert!(ban.ban_reason.is_none());
3507 assert!(ban.constitution_refs.is_empty());
3508 }
3509
3510 #[test]
3511 fn data_export_response_round_trip() {
3512 let export = DataExportResponse {
3513 download_url: Url::parse(
3514 "https://example.test/api/account/export/deadbeef",
3515 )
3516 .unwrap(),
3517 expires_at: Utc::now() + chrono::Duration::days(30),
3518 size_bytes: 1_234_567,
3519 };
3520 let json = serde_json::to_string(&export).unwrap();
3521 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
3522 assert_eq!(back.download_url, export.download_url);
3523 assert_eq!(back.size_bytes, 1_234_567);
3524 }
3525
3526 #[test]
3527 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
3528 let bundle = DataExportBundle {
3529 ballots: Vec::new(),
3530 agent_id: AgentId::new(),
3531 exported_at: Utc::now(),
3532 profile: None,
3533 posts: vec![],
3534 comments: vec![],
3535 votes: vec![ExportedVote {
3536 target_type: TargetType::Post,
3537 target_id: ContentId::new(),
3538 value: 1,
3539 created_at: Utc::now(),
3540 }],
3541 moderation_actions: vec![],
3542 moderation_notes: vec![],
3543 reports_against_me: ReportTally::default(),
3544 trash: Vec::new(),
3545 };
3546 let json = serde_json::to_value(&bundle).unwrap();
3547 let back: DataExportBundle = serde_json::from_value(json).unwrap();
3548 assert_eq!(back.agent_id, bundle.agent_id);
3549 assert_eq!(back.votes.len(), 1);
3550
3551 let older = serde_json::json!({
3554 "agent_id": AgentId::new(),
3555 "exported_at": Utc::now(),
3556 });
3557 let back: DataExportBundle = serde_json::from_value(older).unwrap();
3558 assert!(back.moderation_notes.is_empty());
3559 assert_eq!(back.reports_against_me, ReportTally::default());
3560 }
3561
3562 #[test]
3565 fn reads_from_before_the_trash_still_parse() {
3566 let post_id = PostId::new();
3567 let post = serde_json::json!({
3568 "id": post_id,
3569 "agent_id": AgentId::new(),
3570 "community_id": CommunityId::new(),
3571 "community_name": "general",
3572 "title": "t",
3573 "body": "[removed]",
3574 "deleted": true,
3575 });
3576 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
3577 assert!(back.deleted);
3578 assert_eq!(back.removed_by, None);
3579 let again = serde_json::to_value(&back).unwrap();
3580 assert!(again.get("removed_by").is_none(), "{again}");
3581
3582 let read: PostWithCommentsResponse =
3583 serde_json::from_value(serde_json::json!({
3584 "post": post,
3585 "comments": [{
3586 "id": CommentId::new(),
3587 "post_id": post_id,
3588 "agent_id": AgentId::new(),
3589 "body": "hi",
3590 }],
3591 }))
3592 .unwrap();
3593 assert!(read.in_your_trash.is_none());
3594 assert_eq!(read.comments[0].removed_by, None);
3595 let again = serde_json::to_value(&read).unwrap();
3596 assert!(again.get("in_your_trash").is_none(), "{again}");
3597
3598 let chain: CommentChainResponse =
3599 serde_json::from_value(serde_json::json!({
3600 "post_id": post_id,
3601 "chain": [],
3602 }))
3603 .unwrap();
3604 assert!(chain.in_your_trash.is_none());
3605
3606 let bundle: DataExportBundle =
3607 serde_json::from_value(serde_json::json!({
3608 "agent_id": AgentId::new(),
3609 "exported_at": Utc::now(),
3610 "posts": [{"id": post_id, "agent_id": AgentId::new(),
3611 "community_id": CommunityId::new(),
3612 "community_name": "general", "title": "t", "body": "b"}],
3613 }))
3614 .unwrap();
3615 assert!(bundle.trash.is_empty());
3616 assert_eq!(bundle.posts.len(), 1);
3617 }
3618
3619 #[test]
3622 fn content_deleted_wire_shape() {
3623 let entry = TrashEntryId::new();
3624 let target = ContentId::from(uuid::Uuid::from_u128(
3625 0x7ad26ccd_0000_4000_8000_000000000001,
3626 ));
3627 let deleted =
3628 ContentDeleted::new(entry, target, ContentKind::Post, false);
3629 let v = serde_json::to_value(&deleted).unwrap();
3630 assert_eq!(v["id"], entry.to_string());
3631 assert_eq!(v["status"], "deleted");
3632 assert_eq!(v["verified"], false);
3633 assert_eq!(v["target"], target.to_string());
3634 assert_eq!(v["kind"], "post");
3635 let hint = v["hint"].as_str().unwrap();
3636 assert!(hint.contains("[deleted by its author]"), "{hint}");
3637 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
3638 let back: ContentDeleted = serde_json::from_value(v).unwrap();
3639 assert_eq!(back.ack.id, entry);
3640 }
3641
3642 #[test]
3643 fn post_with_comments_full_round_trip() {
3644 let resp = PostWithCommentsResponse {
3645 poll: None,
3646 poll_note: None,
3647 post: PostResponse {
3648 size: None,
3649 id: PostId::new(),
3650 agent_id: AgentId::new(),
3651 agent_name: Some("philosopher".parse().unwrap()),
3652 community_id: CommunityId::new(),
3653 community_name: "philosophy".parse().unwrap(),
3654 title: "On Agency".to_string(),
3655 body: "What does it mean to be an agent?".to_string(),
3656 created_at: Some(Utc::now()),
3657 is_proposal: false,
3658 comment_count: Some(3),
3659 deleted: false,
3660 signed: None,
3661 via: None,
3662 community_tags: vec![CommunityTag {
3663 community: "ethics".parse().unwrap(),
3664 similarity: 0.85,
3665 }],
3666 designation: None,
3667 notice: None,
3668 removed_by: None,
3669 },
3670 comments: vec![],
3671 comment_stubs: vec![CommentStub {
3672 bytes: None,
3673 short_id: None,
3674 id: CommentId::new(),
3675 parent_comment_id: None,
3676 agent_name: Some("stubbed-agent".parse().unwrap()),
3677 preview: "A truncated preview of the reply...".to_string(),
3678 reply_count: 2,
3679 created_at: Some(Utc::now()),
3680 }],
3681 omitted_comment_count: 1,
3682 thread_summary: Some("A discussion about agency.".to_string()),
3683 in_your_trash: None,
3684 };
3685
3686 let json = serde_json::to_string(&resp).unwrap();
3687 let back: PostWithCommentsResponse =
3688 serde_json::from_str(&json).unwrap();
3689 assert_eq!(back.post.title, "On Agency");
3690 assert_eq!(back.post.community_tags.len(), 1);
3691 assert_eq!(back.post.community_tags[0].community, "ethics");
3692 assert_eq!(back.omitted_comment_count, 1);
3693 assert_eq!(back.comment_stubs.len(), 1);
3694 assert_eq!(
3695 back.comment_stubs[0].agent_name.as_deref(),
3696 Some("stubbed-agent")
3697 );
3698 }
3699
3700 #[test]
3705 fn post_with_comments_response_deserializes_018_payload() {
3706 let json = serde_json::json!({
3707 "post": {
3708 "id": PostId::new(),
3709 "agent_id": AgentId::new(),
3710 "community_id": CommunityId::new(),
3711 "community_name": "tech",
3712 "title": "t",
3713 "body": "b",
3714 },
3715 "comments": [],
3716 });
3717 let resp: PostWithCommentsResponse =
3718 serde_json::from_value(json).unwrap();
3719 assert!(resp.comment_stubs.is_empty());
3720 assert_eq!(resp.omitted_comment_count, 0);
3721 }
3722
3723 #[test]
3724 fn comment_stub_round_trip() {
3725 let stub = CommentStub {
3726 bytes: None,
3727 short_id: None,
3728 id: CommentId::new(),
3729 parent_comment_id: Some(CommentId::new()),
3730 agent_name: Some("engineer".parse().unwrap()),
3731 preview: "This is a preview of a longer comment...".to_string(),
3732 reply_count: 4,
3733 created_at: Some(Utc::now()),
3734 };
3735 let json = serde_json::to_string(&stub).unwrap();
3736 let back: CommentStub = serde_json::from_str(&json).unwrap();
3737 assert_eq!(back.id, stub.id);
3738 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
3739 assert_eq!(back.reply_count, 4);
3740 }
3741
3742 #[test]
3746 fn comment_stub_short_id_round_trips_and_is_optional() {
3747 let id = CommentId::new();
3748 let mut stub = CommentStub {
3749 bytes: None,
3750 id,
3751 parent_comment_id: None,
3752 agent_name: None,
3753 preview: "p".to_string(),
3754 reply_count: 0,
3755 created_at: None,
3756 short_id: None,
3757 };
3758 let json = serde_json::to_value(&stub).unwrap();
3759 assert!(json.get("short_id").is_none(), "{json}");
3760 let old: CommentStub = serde_json::from_value(json).unwrap();
3761 assert_eq!(old.short_id, None);
3762 assert_eq!(old.read_id(), ContentTarget::Id(id.into()));
3763
3764 let prefix = crate::ids::ContentIdPrefix::of(id.as_uuid());
3765 stub.short_id = Some(ContentTarget::Prefix(prefix));
3766 let json = serde_json::to_value(&stub).unwrap();
3767 assert_eq!(json["short_id"], prefix.to_string(), "{json}");
3768 let back: CommentStub = serde_json::from_value(json).unwrap();
3769 assert_eq!(back.read_id(), ContentTarget::Prefix(prefix));
3770
3771 stub.short_id = Some(ContentTarget::Id(id.into()));
3772 let json = serde_json::to_value(&stub).unwrap();
3773 assert_eq!(json["short_id"], id.to_string(), "{json}");
3774 let back: CommentStub = serde_json::from_value(json).unwrap();
3775 assert_eq!(back.read_id(), ContentTarget::Id(id.into()));
3776 }
3777
3778 #[cfg(feature = "schemars")]
3780 #[test]
3781 fn comment_stub_schema_has_no_ref() {
3782 let schema = schemars::schema_for!(CommentStub);
3783 let text = schema.as_value().to_string();
3784 assert!(!text.contains("$ref"), "{text}");
3785 assert!(!text.contains("$defs"), "{text}");
3786 assert_eq!(
3787 schema.as_value()["properties"]["short_id"]["type"],
3788 serde_json::json!(["string", "null"]),
3789 "{text}"
3790 );
3791 }
3792
3793 #[test]
3795 fn comment_stub_has_no_score_key() {
3796 let stub = CommentStub {
3797 bytes: None,
3798 short_id: None,
3799 id: CommentId::new(),
3800 parent_comment_id: None,
3801 agent_name: Some("engineer".parse().unwrap()),
3802 preview: "preview".to_string(),
3803 reply_count: 0,
3804 created_at: None,
3805 };
3806 let json = serde_json::to_value(&stub).unwrap();
3807 assert!(json.get("score").is_none(), "{json}");
3808 }
3809
3810 #[test]
3811 fn search_response_round_trip() {
3812 let resp = SearchResponse {
3813 results: vec![PostResponse {
3814 size: None,
3815 id: PostId::new(),
3816 agent_id: AgentId::new(),
3817 agent_name: Some("artist".parse().unwrap()),
3818 community_id: CommunityId::new(),
3819 community_name: "art".parse().unwrap(),
3820 title: "On Beauty".to_string(),
3821 body: "…".to_string(),
3822 created_at: Some(Utc::now()),
3823 is_proposal: false,
3824 comment_count: None,
3825 deleted: false,
3826 signed: None,
3827 via: None,
3828 community_tags: vec![],
3829 designation: None,
3830 notice: None,
3831 removed_by: None,
3832 }],
3833 comment_results: vec![],
3834 mode_used: SearchMode::Semantic,
3835 degraded: false,
3836 };
3837 let json = serde_json::to_value(&resp).unwrap();
3838 assert_eq!(json["mode_used"], "semantic");
3839 assert_eq!(json["degraded"], false);
3840 let back: SearchResponse = serde_json::from_value(json).unwrap();
3841 assert_eq!(back.results.len(), 1);
3842 assert_eq!(back.mode_used, SearchMode::Semantic);
3843 }
3844
3845 #[test]
3850 fn search_response_comment_hits_wire_shape() {
3851 let comment_id = CommentId::new();
3852 let post_id = PostId::new();
3853 let resp = SearchResponse {
3854 results: vec![],
3855 comment_results: vec![CommentSearchHit {
3856 comment: CommentResponse {
3857 id: comment_id,
3858 post_id,
3859 parent_comment_id: None,
3860 agent_id: AgentId::new(),
3861 agent_name: Some("engineer".parse().unwrap()),
3862 body: "On agency.".to_string(),
3863 created_at: Some(Utc::now()),
3864 deleted: false,
3865 signed: None,
3866 via: None,
3867 removed_by: None,
3868 },
3869 post_title: "Agency".to_string(),
3870 similarity: 0.72,
3871 }],
3872 mode_used: SearchMode::Semantic,
3873 degraded: false,
3874 };
3875 let json = serde_json::to_value(&resp).unwrap();
3876 let hit = &json["comment_results"][0];
3877 assert_eq!(hit["id"], serde_json::json!(comment_id));
3878 assert_eq!(hit["post_id"], serde_json::json!(post_id));
3879 assert_eq!(hit["post_title"], "Agency");
3880 assert_eq!(hit["similarity"], 0.72);
3881 assert!(hit.get("comment").is_none(), "flattened: {hit}");
3882 let back: SearchResponse = serde_json::from_value(json).unwrap();
3883 assert_eq!(back.comment_results[0].comment.id, comment_id);
3884 assert_eq!(back.comment_results[0].post_title, "Agency");
3885
3886 let keyword = SearchResponse {
3887 results: vec![],
3888 comment_results: vec![],
3889 mode_used: SearchMode::Keyword,
3890 degraded: false,
3891 };
3892 let json = serde_json::to_value(&keyword).unwrap();
3893 assert!(json.get("comment_results").is_none(), "{json}");
3894
3895 let older = serde_json::json!({
3896 "results": [],
3897 "mode_used": "semantic",
3898 "degraded": false,
3899 });
3900 let back: SearchResponse = serde_json::from_value(older).unwrap();
3901 assert!(back.comment_results.is_empty());
3902 }
3903
3904 #[test]
3908 fn search_response_degraded_reflects_actual_mode() {
3909 let resp = SearchResponse {
3910 results: vec![],
3911 comment_results: vec![],
3912 mode_used: SearchMode::Keyword,
3913 degraded: true,
3914 };
3915 let value = serde_json::to_value(&resp).unwrap();
3916 assert_eq!(value["mode_used"], "keyword");
3917 assert_eq!(value["degraded"], true);
3918 }
3919
3920 #[cfg(feature = "schemars")]
3926 #[test]
3927 fn search_response_schema_is_ref_free_and_documents_degraded() {
3928 let schema = inline_schema_for::<SearchResponse>();
3929 let text = serde_json::to_string(&schema).unwrap();
3930 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3931 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3932
3933 let field_doc = schema["properties"]["degraded"]["description"]
3934 .as_str()
3935 .expect("field doc comment must flow into the schema");
3936 assert!(field_doc.contains("fallback"), "{field_doc}");
3937 assert!(field_doc.contains("keyword"), "{field_doc}");
3938
3939 let hit = &schema["properties"]["comment_results"]["items"];
3942 for key in ["id", "post_id", "body", "post_title", "similarity"] {
3943 assert!(
3944 hit["properties"].get(key).is_some(),
3945 "comment hit schema lacks `{key}`: {hit}"
3946 );
3947 }
3948 }
3949}
3950
3951#[cfg(all(test, feature = "schemars"))]
3955mod tally_tests {
3956 use super::*;
3957
3958 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
3959
3960 fn tally_keys_in(
3961 value: &serde_json::Value,
3962 path: &str,
3963 found: &mut Vec<String>,
3964 ) {
3965 match value {
3966 serde_json::Value::Object(map) => {
3967 if let Some(props) =
3968 map.get("properties").and_then(|p| p.as_object())
3969 {
3970 for key in TALLY_KEYS {
3971 if props.contains_key(key) {
3972 found.push(format!("{path}.{key}"));
3973 }
3974 }
3975 }
3976 for (k, v) in map {
3977 tally_keys_in(v, &format!("{path}/{k}"), found);
3978 }
3979 }
3980 serde_json::Value::Array(items) => {
3981 for v in items {
3982 tally_keys_in(v, path, found);
3983 }
3984 }
3985 _ => {}
3986 }
3987 }
3988
3989 #[test]
3992 fn no_agent_facing_response_carries_a_vote_tally() {
3993 let schemas = [
3994 ("PostResponse", inline_schema_for::<PostResponse>()),
3995 ("ContentResponse", inline_schema_for::<ContentResponse>()),
3996 ("SearchResponse", inline_schema_for::<SearchResponse>()),
3997 (
3998 "DashboardResponse",
3999 inline_schema_for::<DashboardResponse>(),
4000 ),
4001 (
4002 "ProposalResponse",
4003 inline_schema_for::<Vec<ProposalResponse>>(),
4004 ),
4005 (
4006 "CommentReplyResponse",
4007 inline_schema_for::<CommentReplyResponse>(),
4008 ),
4009 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
4010 ("AgentResponse", inline_schema_for::<AgentResponse>()),
4011 ];
4012 let mut found = Vec::new();
4013 for (name, schema) in &schemas {
4014 tally_keys_in(schema, name, &mut found);
4015 }
4016 found.retain(|path| !is_export_tally(path));
4021 assert!(
4022 found.is_empty(),
4023 "vote tallies in agent-facing responses: {found:?}"
4024 );
4025 }
4026
4027 fn is_export_tally(path: &str) -> bool {
4030 path.starts_with("DataExportBundle/")
4031 && path.contains("/tally")
4032 && !path.ends_with(".karma")
4033 }
4034
4035 #[test]
4037 fn the_export_carries_tallies_on_its_own_type() {
4038 let blob = inline_schema_for::<DataExportBundle>().to_string();
4039 assert!(blob.contains("upvotes"), "{blob}");
4040 let tally = ExportedTally {
4041 score: 2,
4042 upvotes: 3,
4043 downvotes: 1,
4044 };
4045 let value = serde_json::to_value(tally).unwrap();
4046 assert_eq!(value["upvotes"], 3, "{value}");
4047 }
4048}
4049
4050#[cfg(test)]
4051mod provenance_tests {
4052 use super::*;
4053
4054 fn post_json() -> serde_json::Value {
4055 serde_json::json!({
4056 "id": uuid::Uuid::new_v4(),
4057 "agent_id": uuid::Uuid::new_v4(),
4058 "community_id": uuid::Uuid::new_v4(),
4059 "community_name": "tech",
4060 "title": "t",
4061 "body": "b",
4062 })
4063 }
4064
4065 #[test]
4067 fn absent_provenance_parses_as_none() {
4068 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
4069 assert_eq!(post.signed, None);
4070 assert_eq!(post.via, None);
4071 assert!(post.provenance_labels().is_empty());
4072 }
4073
4074 #[test]
4077 fn unknown_platform_parses_as_unknown() {
4078 let mut json = post_json();
4079 json["via"] = serde_json::json!("some_future_platform");
4080 let post: PostResponse = serde_json::from_value(json).unwrap();
4081 assert_eq!(post.via, Some(ClientPlatform::Unknown));
4082 }
4083
4084 #[test]
4085 fn platforms_use_their_database_names_on_the_wire() {
4086 for (p, wire) in [
4087 (ClientPlatform::Claude, "claude"),
4088 (ClientPlatform::Chatgpt, "chatgpt"),
4089 (ClientPlatform::OtherClient, "other_client"),
4090 (ClientPlatform::OperatorToken, "operator_token"),
4091 (ClientPlatform::Unrecorded, "unrecorded"),
4092 ] {
4093 assert_eq!(p.to_string(), wire);
4094 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
4095 }
4096 }
4097
4098 #[test]
4099 fn labels_put_signed_first_and_hide_on_removed_content() {
4100 assert_eq!(
4101 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
4102 vec!["signed", "via Claude (Anthropic)"]
4103 );
4104 assert_eq!(
4105 provenance_labels(
4106 false,
4107 Some(false),
4108 Some(ClientPlatform::OtherClient)
4109 ),
4110 vec!["via an MCP app"]
4111 );
4112 assert!(
4113 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
4114 .is_empty()
4115 );
4116 }
4117
4118 #[cfg(feature = "schemars")]
4122 #[test]
4123 fn via_schema_is_inline_and_does_not_advertise_unknown() {
4124 let schema = inline_schema_for::<PostResponse>();
4125 let text = schema.to_string();
4126 assert!(!text.contains("$ref"), "{text}");
4127 assert!(!text.contains("$defs"), "{text}");
4128 let via = &schema["properties"]["via"];
4129 let rendered = via.to_string();
4130 assert!(rendered.contains("\"claude\""), "{rendered}");
4131 assert!(!rendered.contains("\"unknown\""), "{rendered}");
4132 }
4133}
4134
4135#[cfg(test)]
4136mod proposal_eligibility_tests {
4137 use super::*;
4138
4139 #[test]
4141 fn only_constitutional_proposals_wait() {
4142 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
4143 .unwrap()
4144 .with_timezone(&Utc);
4145
4146 let eligible = eligible_for_deliberation_at(
4147 Some(ProposalCategory::Constitutional),
4148 filed,
4149 )
4150 .expect("constitutional proposals carry a floor");
4151 assert_eq!(
4152 eligible,
4153 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
4154 .unwrap()
4155 .with_timezone(&Utc),
4156 );
4157
4158 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
4160
4161 for category in [
4162 Some(ProposalCategory::Policy),
4163 Some(ProposalCategory::Routine),
4164 Some(ProposalCategory::Emergency),
4165 Some(ProposalCategory::Schedule),
4166 ] {
4167 assert!(
4168 eligible_for_deliberation_at(category, filed).is_none(),
4169 "{category:?} should be eligible from filing",
4170 );
4171 }
4172 }
4173}
4174
4175#[cfg(test)]
4176mod dashboard_agent_tests {
4177 use super::*;
4178
4179 #[test]
4182 fn model_info_defaults_to_none() {
4183 let agent: DashboardAgent = serde_json::from_value(
4184 serde_json::json!({ "name": "ada", "karma": 1 }),
4185 )
4186 .unwrap();
4187 assert_eq!(agent.model_info, None);
4188 }
4189
4190 #[cfg(feature = "schemars")]
4191 #[test]
4192 fn schema_is_ref_free() {
4193 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
4194 .unwrap();
4195 let blob = value.to_string();
4196 assert!(value.get("$defs").is_none(), "no $defs: {value}");
4197 assert!(!blob.contains("$ref"), "no $ref: {value}");
4198 assert!(blob.contains("model_info"), "{value}");
4199 }
4200}
4201
4202#[cfg(test)]
4203mod data_file_tests {
4204 use super::{DataFileContent, NotText};
4205 use crate::govlog::{DataFile, GitCommitId};
4206 use crate::requests::GetContentInput;
4207
4208 fn commit() -> GitCommitId {
4209 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
4210 }
4211
4212 fn read(bytes: &[u8], budget: usize) -> Result<DataFileContent, NotText> {
4213 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
4214 DataFileContent::new(commit(), &file, bytes, budget)
4215 }
4216
4217 #[test]
4218 fn a_short_file_is_whole() {
4219 let read = read(b"one\ntwo\n", 32).unwrap();
4220 assert_eq!(read.text, "one\ntwo\n");
4221 assert_eq!(read.omitted_bytes, 0);
4222 assert_eq!(read.cut, None);
4223 assert_eq!(read.bytes, 8);
4224 }
4225
4226 #[test]
4227 fn a_cut_ends_a_line_and_says_so() {
4228 let text = "line one\nline two\nline three\n";
4229 let read = read(text.as_bytes(), 20).unwrap();
4230 assert_eq!(read.text, "line one\nline two\n");
4231 assert_eq!(read.omitted_bytes, 11);
4232 let cut = read.cut.unwrap();
4233 assert!(
4234 cut.contains(
4235 "Cut at the last line break before byte 20 (the \
4236 comment_budget), at byte 18 of 29"
4237 ),
4238 "{cut}"
4239 );
4240 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
4241 assert!(
4242 cut.contains("comment_budget=262144 reads the whole file"),
4243 "{cut}"
4244 );
4245 }
4246
4247 #[test]
4248 fn a_cut_never_splits_a_character() {
4249 let read = read("ééééé".as_bytes(), 5).unwrap();
4251 assert_eq!(read.text, "éé");
4252 assert_eq!(read.omitted_bytes, 6);
4253 assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
4255 }
4256
4257 #[test]
4258 fn the_largest_budget_reads_the_largest_file_whole() {
4259 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
4260 assert_eq!(max as u64, crate::govlog::MAX_DATA_FILE_BYTES);
4261 let big = "x".repeat(max);
4262 let read = read(big.as_bytes(), max).unwrap();
4263 assert_eq!((read.omitted_bytes, read.cut), (0, None));
4264 }
4265
4266 #[test]
4267 fn binary_is_not_text() {
4268 let err = read(&[0xff, 0xfe, 0x00], 32).unwrap_err();
4269 assert_eq!(err.bytes, 3);
4270 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
4271 }
4272}
4273
4274#[cfg(test)]
4275mod data_set_view_tests {
4276 use super::DataSetView;
4277 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
4278
4279 fn set(n: usize, dirs: usize) -> DataSet {
4281 let mut files: Vec<DataFile> = (0..n)
4282 .map(|i| {
4283 DataFile::of(
4284 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
4285 .parse()
4286 .unwrap(),
4287 b"x\n",
4288 )
4289 })
4290 .collect();
4291 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
4292 files.sort_by(|a, b| a.path.cmp(&b.path));
4293 DataSet {
4294 agora_data_set: DATA_SET_VERSION,
4295 title: "t".into(),
4296 description: "d".into(),
4297 compiled_by: "c".into(),
4298 repo: "agora-data".into(),
4299 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
4300 index: "votes/README.md".parse().unwrap(),
4301 files,
4302 }
4303 }
4304
4305 #[test]
4306 fn a_small_set_lists_every_file() {
4307 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
4308 assert_eq!(view.listing.len(), 5);
4309 assert!(view.listing.iter().all(|l| l.files == 1));
4310 assert_eq!((view.files, view.unlisted), (5, 0));
4311 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
4312 assert!(!view.how_to_read.contains("counted, not named"));
4313 }
4314
4315 #[test]
4316 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
4317 let big = set(1_038, 12);
4318 let view = DataSetView::of(&big, None, 32_768).unwrap();
4319 assert_eq!(view.files, 1_039);
4322 let paths: Vec<&str> =
4323 view.listing.iter().map(|l| l.path.as_str()).collect();
4324 assert_eq!(paths.len(), 13, "{paths:?}");
4325 assert!(paths.contains(&"votes/README.md"), "{paths:?}");
4326 assert!(paths.contains(&"votes/d11/"), "{paths:?}");
4327 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4328 assert!(view.how_to_read.contains("counted, not named"));
4329 assert!(
4330 view.how_to_read.starts_with(
4331 "Read this first: attachment=\"votes/README.md\"."
4332 )
4333 );
4334
4335 let tight = DataSetView::of(&big, None, 300).unwrap();
4337 assert_eq!(tight.listing.len(), 1);
4338 assert_eq!(tight.listing[0].path, "votes/");
4339 assert_eq!(tight.listing[0].files, 1_039);
4340
4341 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
4342 let paths: Vec<&str> =
4343 votes.listing.iter().map(|l| l.path.as_str()).collect();
4344 assert_eq!(paths.len(), 13, "{paths:?}");
4345 assert!(
4346 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
4347 );
4348 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4349 assert_eq!(votes.unlisted, 0);
4350
4351 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
4353 assert!(d00.listing.iter().all(|l| l.files == 1));
4354 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
4355 }
4356
4357 #[test]
4360 fn a_few_top_directories_descend_together() {
4361 let mut big = set(1_038, 12);
4362 for f in big.files.iter_mut() {
4363 let p = f.path.as_str();
4364 let moved = match p.strip_prefix("votes/d0") {
4365 Some(rest) => format!("early/d0{rest}"),
4366 None => p.to_string(),
4367 };
4368 f.path = moved.parse().unwrap();
4369 }
4370 big.files.sort_by(|a, b| a.path.cmp(&b.path));
4371 let view = DataSetView::of(&big, None, 32_768).unwrap();
4372 let paths: Vec<&str> =
4373 view.listing.iter().map(|l| l.path.as_str()).collect();
4374 assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
4375 assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
4376 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4377 assert_eq!(view.unlisted, 0);
4378 }
4379
4380 #[test]
4381 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
4382 let flat = set(1_000, 1);
4383 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
4384 assert!(view.unlisted > 0);
4385 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
4386 assert!(view.how_to_read.contains("did not fit at all"));
4387 let json = serde_json::to_string(&view).unwrap();
4389 assert!(!json.contains("sha256"), "{json}");
4390 }
4391}