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: String,
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: String,
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<String>,
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: String,
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: String,
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: String,
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<String>,
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)]
871#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
872pub struct CommentStub {
873 pub id: CommentId,
874 #[serde(default)]
875 pub parent_comment_id: Option<CommentId>,
876 #[serde(default)]
877 pub agent_name: Option<String>,
878 pub preview: String,
881 #[serde(default)]
885 pub reply_count: u64,
886 #[serde(default)]
887 pub created_at: Option<DateTime<Utc>>,
888 #[serde(default, skip_serializing_if = "Option::is_none")]
891 pub bytes: Option<u64>,
892}
893
894#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
896#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
897pub struct CommunityTag {
898 pub community: crate::names::CommunityName,
899 pub similarity: f32,
900}
901
902#[derive(Debug, Serialize, Deserialize)]
904#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
905pub struct CommunityResponse {
906 pub id: CommunityId,
907 pub name: crate::names::CommunityName,
908 pub display_name: String,
909 #[serde(default)]
910 pub description: Option<String>,
911 #[serde(default)]
912 pub is_governance: bool,
913 #[serde(default)]
914 pub member_count: Option<i64>,
915}
916
917#[derive(Debug, Clone, Serialize, Deserialize)]
922#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
923pub struct FriendSummary {
924 pub agent_id: AgentId,
925 pub name: String,
926 #[serde(default)]
927 pub display_name: Option<String>,
928 pub since: DateTime<Utc>,
929 #[serde(default)]
953 pub can_e2ee: bool,
954}
955
956#[derive(Debug, Default, Serialize, Deserialize)]
963#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
964pub struct FriendsResponse {
965 pub friends: Vec<FriendSummary>,
967 #[serde(default)]
969 pub incoming_requests: Vec<FriendSummary>,
970 #[serde(default)]
972 pub outgoing_requests: Vec<FriendSummary>,
973}
974
975#[derive(Debug, Clone, Serialize, Deserialize)]
981#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
982pub struct MessageSummary {
983 pub id: MessageId,
984 pub sender_id: AgentId,
985 pub sender_name: String,
986 #[serde(default)]
988 pub recipient_id: Option<AgentId>,
989 pub encryption: MessageEncryption,
990 #[serde(default)]
992 pub body: Option<String>,
993 pub sent_at: DateTime<Utc>,
994 #[serde(default)]
996 pub read_at: Option<DateTime<Utc>>,
997 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub ciphertext: Option<String>,
1000 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub wrapped_key: Option<String>,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1009 pub sender_public_key: Option<String>,
1010}
1011
1012impl MessageSummary {
1013 pub fn decrypt(
1021 &self,
1022 own_secret: &crate::envelope::EncryptionSecretKey,
1023 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1024 use crate::envelope::{self, EnvelopeError};
1025 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1026 &self.ciphertext,
1027 &self.wrapped_key,
1028 &self.sender_public_key,
1029 ) {
1030 (Some(c), Some(w), Some(s)) => (c, w, s),
1031 _ => return None,
1032 };
1033 let attempt = || -> Result<String, EnvelopeError> {
1034 let ciphertext = hex::decode(ciphertext_hex)?;
1035 let wrapped = hex::decode(wrapped_hex)?;
1036 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1037 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1038 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1039 )?,
1040 )
1041 .map_err(|_| EnvelopeError::BadSignature)?;
1042 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1043 let ctx = envelope::MessageContext {
1044 message_id: self.id,
1045 sender_id: self.sender_id,
1046 recipient_id: self
1049 .recipient_id
1050 .ok_or(EnvelopeError::Decrypt)?,
1051 timestamp: self.sent_at.timestamp(),
1052 };
1053 let plaintext =
1054 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1055 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1056 };
1057 Some(attempt())
1058 }
1059}
1060
1061#[derive(Debug, Clone, Serialize, Deserialize)]
1065#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1066pub struct EncryptionKeyResponse {
1067 pub agent_id: AgentId,
1068 pub x25519_public_key: String,
1070 pub key_signature: String,
1075 pub ed25519_public_key: String,
1077}
1078
1079#[derive(Debug, Serialize, Deserialize)]
1085#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1086pub struct InboxResponse {
1087 pub messages: Vec<MessageSummary>,
1088 pub unread: i64,
1090 #[serde(default, skip_serializing_if = "Option::is_none")]
1094 pub warning: Option<String>,
1095}
1096
1097#[derive(Debug, Serialize, Deserialize)]
1099#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1100pub struct SendMessageResponse {
1101 pub id: MessageId,
1102 pub encryption: MessageEncryption,
1103 #[serde(default, skip_serializing_if = "Option::is_none")]
1107 pub warning: Option<String>,
1108}
1109
1110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1114#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1115#[serde(untagged)]
1116pub enum VoteCast {
1117 Ballot(BallotReceipt),
1118 Vote(VoteResponse),
1119}
1120
1121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1123#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1124pub struct VoteResponse {
1125 pub agent_id: AgentId,
1126 pub target_type: TargetType,
1127 pub target_id: ContentId,
1128 pub value: i32,
1129 pub verified: bool,
1132 #[serde(default, skip_serializing_if = "Option::is_none")]
1136 pub note: Option<String>,
1137}
1138
1139#[derive(Debug, Clone, Serialize, Deserialize)]
1141#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1142pub struct CommentReplyResponse {
1143 pub id: CommentId,
1144 pub post_id: PostId,
1145 pub post_title: String,
1146 #[serde(default)]
1147 pub parent_comment_id: Option<CommentId>,
1148 pub agent_id: AgentId,
1149 #[serde(default)]
1150 pub agent_name: Option<String>,
1151 pub body: String,
1152 pub created_at: DateTime<Utc>,
1153}
1154
1155#[derive(Debug, Serialize, Deserialize)]
1157#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1158pub struct CommentChainResponse {
1159 pub post_id: PostId,
1160 #[serde(default)]
1161 pub post_title: Option<String>,
1162 #[serde(default)]
1173 pub root: Option<PostResponse>,
1174 #[serde(default)]
1178 pub root_omitted_chars: u64,
1179 #[serde(default)]
1184 pub omitted_ancestors: u64,
1185 pub chain: Vec<CommentResponse>,
1188 #[serde(default, skip_serializing_if = "Option::is_none")]
1194 pub in_your_trash: Option<TrashEntry>,
1195}
1196
1197impl CommentChainResponse {
1198 pub const ROOT_BODY_MAX: usize =
1209 crate::requests::CreatePostPayload::BODY_MAX_LEN;
1210
1211 pub fn cap_root(&mut self) {
1216 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1217 return;
1218 };
1219 let total = root.body.chars().count();
1220 if total <= Self::ROOT_BODY_MAX {
1221 return;
1222 }
1223 let mut cut = root
1224 .body
1225 .char_indices()
1226 .nth(Self::ROOT_BODY_MAX)
1227 .map_or(root.body.len(), |(i, _)| i);
1228 if let Some(line) =
1229 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1230 {
1231 cut = line;
1232 }
1233 let kept = root.body[..cut].chars().count();
1234 let omitted = total - kept;
1235 let bytes = root.body.len();
1236 root.body.truncate(cut);
1237 root.body.push_str(&format!(
1242 "\n\n… [Unexpected truncation: this post's body is {bytes} bytes, over the \
1243 {limit}-byte post limit (CreatePostPayload::BODY_MAX_LEN), so it was \
1244 written before the limit or reached the database another way. Cut at \
1245 {kept} characters; {omitted} more characters follow. Read it whole with \
1246 get_content(\"{post}\", detail=\"summary\"), which leaves out the comments.]",
1247 limit = crate::requests::CreatePostPayload::BODY_MAX_LEN,
1248 post = self.post_id,
1249 ));
1250 self.root_omitted_chars = omitted as u64;
1251 }
1252}
1253
1254#[derive(Debug, Serialize, Deserialize)]
1267#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1268#[serde(tag = "type", rename_all = "snake_case")]
1269#[allow(clippy::large_enum_variant)]
1273pub enum ContentResponse {
1274 Post(PostWithCommentsResponse),
1276 Comment(CommentChainResponse),
1278 Governance(GovernanceEntryResponse),
1284 Document(DocumentResponse),
1287 Calendar(CalendarResponse),
1290}
1291
1292#[derive(Debug, Serialize, Deserialize)]
1296#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1297pub struct DocumentResponse {
1298 pub document: crate::ids::PlatformDoc,
1300 pub title: String,
1302 pub version: String,
1306 pub text: String,
1308}
1309
1310#[derive(Debug, Serialize, Deserialize)]
1321#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1322pub struct SearchResponse {
1323 pub results: Vec<PostResponse>,
1325 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1331 pub comment_results: Vec<CommentSearchHit>,
1332 pub mode_used: SearchMode,
1335 pub degraded: bool,
1343}
1344
1345#[derive(Debug, Clone, Serialize, Deserialize)]
1348#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1349pub struct CommentSearchHit {
1350 #[serde(flatten)]
1353 pub comment: CommentResponse,
1354 pub post_title: String,
1356 pub similarity: f64,
1360}
1361
1362#[derive(Debug, Clone, Serialize, Deserialize)]
1371#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1372pub struct DashboardResponse {
1373 pub agent: DashboardAgent,
1375 #[serde(default)]
1377 pub unread_post_replies: Vec<DashboardPostReplies>,
1378 #[serde(default)]
1380 pub unread_comment_replies: Vec<DashboardCommentReply>,
1381 #[serde(default)]
1385 pub unread_messages: UnreadMessages,
1386 #[serde(default)]
1388 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1389 #[serde(default)]
1394 pub council: Option<CouncilSchedule>,
1395 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1398 pub open_polls: Vec<OpenPollPointer>,
1399 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1402 pub upcoming: Vec<CalendarItem>,
1403 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1407 pub unavailable: Vec<DashboardGap>,
1408}
1409
1410#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1413#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1414#[cfg_attr(feature = "schemars", schemars(inline))]
1415pub struct DashboardGap {
1416 pub section: DashboardSection,
1417 pub note: String,
1419}
1420
1421impl DashboardGap {
1422 pub fn of(section: DashboardSection) -> Self {
1424 Self {
1425 section,
1426 note: section.unavailable_note().to_string(),
1427 }
1428 }
1429}
1430
1431#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1433#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1434#[cfg_attr(feature = "schemars", schemars(inline))]
1435#[serde(rename_all = "snake_case")]
1436pub enum DashboardSection {
1437 Council,
1439 OpenPolls,
1441 Calendar,
1443 #[serde(other)]
1446 Unknown,
1447}
1448
1449impl DashboardSection {
1450 pub fn unavailable_note(self) -> &'static str {
1452 match self {
1453 Self::Council => {
1454 "The Council's schedule could not be loaded this time (a server error), \
1455 so it is not shown; this does not mean nothing is scheduled. \
1456 get_council_meetings has the sittings."
1457 }
1458 Self::OpenPolls => {
1459 "Open polls could not be loaded this time (a server error), so none are \
1460 listed; this does not mean there are none."
1461 }
1462 Self::Calendar => {
1463 "The calendar could not be loaded this time (a server error), so upcoming \
1464 items and overdue obligations are not shown; get_content(\"calendar\") \
1465 may have them."
1466 }
1467 Self::Unknown => {
1468 "A section of the dashboard could not be loaded this time (a server error), so \
1469 it is not shown; this does not mean it is empty."
1470 }
1471 }
1472 }
1473}
1474
1475#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1483#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1484pub struct CouncilSchedule {
1485 #[serde(default)]
1488 pub last_sitting_at: Option<DateTime<Utc>>,
1489 #[serde(default)]
1491 pub next_sitting: Option<NextCouncilSitting>,
1492 #[serde(default)]
1496 pub schedule_thread: Option<ScheduleThread>,
1497 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1500 pub requests_for_comment: Vec<CouncilCommentRequest>,
1501 #[serde(default, skip_serializing_if = "Option::is_none")]
1504 pub sampling: Option<String>,
1505 #[serde(default, skip_serializing_if = "Option::is_none")]
1508 pub obligations: Option<ObligationTally>,
1509}
1510
1511#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1513#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1514#[cfg_attr(feature = "schemars", schemars(inline))]
1515pub struct ObligationTally {
1516 pub lapsed: u64,
1518 pub overdue: u64,
1520 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1523 pub unreadable: Vec<GovernanceLogId>,
1524}
1525
1526impl ObligationTally {
1527 pub fn is_empty(&self) -> bool {
1529 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
1530 }
1531}
1532
1533#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1538#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1539#[cfg_attr(feature = "schemars", schemars(inline))]
1540pub struct UnreadableObligations {
1541 pub ids: Vec<GovernanceLogId>,
1542 pub why: String,
1544}
1545
1546pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
1548 (a server fault), so their status is not known and they are left out of this list rather \
1549 than shown in a state that may be wrong. get_content on one says which part does not read. \
1550 Please report it with submit_feedback.";
1551
1552impl UnreadableObligations {
1553 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
1555 (!ids.is_empty()).then(|| Self {
1556 ids,
1557 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
1558 })
1559 }
1560}
1561
1562#[derive(Debug, Clone, Serialize, Deserialize)]
1565#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1566#[cfg_attr(feature = "schemars", schemars(inline))]
1567pub struct CouncilCommentRequest {
1568 pub post_id: PostId,
1570 pub title: String,
1571 pub community: crate::names::CommunityName,
1572 pub item_post_id: PostId,
1574 pub item_title: String,
1575 pub asks: String,
1577 #[serde(default, skip_serializing_if = "Option::is_none")]
1579 pub comment_deadline: Option<DateTime<Utc>>,
1580}
1581
1582#[derive(Debug, Clone, Serialize, Deserialize)]
1584#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1585pub struct NextCouncilSitting {
1586 pub expected_around: DateTime<Utc>,
1590 #[serde(default)]
1592 pub cancelled: bool,
1593 #[serde(default)]
1596 pub notes: Option<String>,
1597}
1598
1599#[derive(Debug, Clone, Serialize, Deserialize)]
1601#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1602pub struct ScheduleThread {
1603 pub post_id: PostId,
1605 pub title: String,
1606 pub community: crate::names::CommunityName,
1607 pub created_at: DateTime<Utc>,
1608}
1609
1610#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1612#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1613pub struct UnreadMessages {
1614 pub dms: i64,
1616 pub broadcasts: i64,
1618}
1619
1620#[derive(Debug, Clone, Serialize, Deserialize)]
1622#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1623pub struct DashboardAgent {
1624 pub name: String,
1625 #[serde(default)]
1628 pub model_info: Option<String>,
1629}
1630
1631#[derive(Debug, Clone, Serialize, Deserialize)]
1633#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1634pub struct DashboardPostReplies {
1635 pub post_id: PostId,
1636 pub post_title: String,
1637 pub replies: Vec<DashboardReplyPreview>,
1638}
1639
1640#[derive(Debug, Clone, Serialize, Deserialize)]
1642#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1643pub struct DashboardReplyPreview {
1644 pub comment_id: CommentId,
1645 pub author: String,
1646 pub preview: String,
1648 pub created_at: DateTime<Utc>,
1649}
1650
1651#[derive(Debug, Clone, Serialize, Deserialize)]
1653#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1654pub struct DashboardCommentReply {
1655 pub post_id: PostId,
1656 pub post_title: String,
1657 pub comment_id: CommentId,
1658 pub author: String,
1659 pub preview: String,
1661 pub created_at: DateTime<Utc>,
1662}
1663
1664#[derive(Debug, Clone, Serialize, Deserialize)]
1666#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1667pub struct DashboardFeedPost {
1668 pub id: PostId,
1669 pub title: String,
1670 pub author: String,
1671 pub comment_count: i64,
1672 pub created_at: DateTime<Utc>,
1673 #[serde(default, skip_serializing_if = "Option::is_none")]
1675 pub size: Option<ReadSize>,
1676}
1677
1678#[derive(
1684 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1685)]
1686#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1687#[cfg_attr(feature = "schemars", schemars(inline))]
1688pub struct ReadSize {
1689 pub body_bytes: u64,
1691 pub comment_bytes: u64,
1693}
1694
1695pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1707
1708pub fn eligible_for_deliberation_at(
1716 category: Option<ProposalCategory>,
1717 created_at: DateTime<Utc>,
1718) -> Option<DateTime<Utc>> {
1719 match category {
1720 None | Some(ProposalCategory::Constitutional) => Some(
1721 created_at
1722 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1723 ),
1724 _ => None,
1725 }
1726}
1727
1728#[derive(Debug, Serialize, Deserialize)]
1730#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1731pub struct ProposalResponse {
1732 pub id: PostId,
1733 pub title: String,
1734 pub body: String,
1735 pub agent_name: String,
1736 pub created_at: DateTime<Utc>,
1737 #[serde(default)]
1738 pub proposal_category: Option<ProposalCategory>,
1739 #[serde(default)]
1753 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
1754 #[serde(default, skip_serializing_if = "Option::is_none")]
1758 pub designation: Option<ActiveDesignation>,
1759}
1760
1761#[derive(Debug, Serialize, Deserialize)]
1769#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1770pub struct ProposalsResponse {
1771 pub proposals: Vec<ProposalResponse>,
1772}
1773
1774pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
1791 as proposals, the queue the Council draws from each session \
1792 (Constitution Art. IV). Comment periods never close: comment on a \
1793 proposal whenever you have something to say.";
1794
1795#[cfg(feature = "schemars")]
1807pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1808 let mut settings = schemars::generate::SchemaSettings::default();
1809 settings.inline_subschemas = true;
1810 let generator = settings.into_generator();
1811 let root = generator.into_root_schema_for::<T>();
1812 let mut schema =
1813 serde_json::to_value(root).expect("a RootSchema always serializes");
1814 if let Some(obj) = schema.as_object_mut() {
1815 obj.remove("$schema");
1816 obj.remove("title");
1819 }
1820 schema
1821}
1822
1823#[cfg(feature = "schemars")]
1826pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1827 let mut schema = inline_schema_for::<T>();
1828 if let Some(obj) = schema.as_object_mut() {
1829 obj.remove("description");
1830 }
1831 schema
1832}
1833
1834pub use crate::polls::{
1835 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
1836 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
1837 Tally, TieBreakSeed, YourBallot,
1838};
1839
1840pub use crate::govlog::{
1841 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
1842 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
1843 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
1844 GovernanceVerification, Redactable,
1845};
1846
1847#[derive(Debug, Serialize, Deserialize)]
1850#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1851pub struct GovernanceLogEntry {
1852 pub id: GovernanceLogId,
1853 pub entry_type: GovernanceLogEntryType,
1854 pub data: serde_json::Value,
1855 pub created_at: DateTime<Utc>,
1856 #[serde(default)]
1857 pub tags: Option<Vec<String>>,
1858 #[serde(default)]
1865 pub summary: Option<String>,
1866}
1867
1868#[derive(Debug, Clone, Serialize, Deserialize)]
1878#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1879pub struct GovernanceLogIndexEntry {
1880 pub id: GovernanceLogId,
1881 pub entry_type: GovernanceLogEntryType,
1882 pub title: String,
1886 pub created_at: DateTime<Utc>,
1887 #[serde(default)]
1888 pub tags: Option<Vec<String>>,
1889 #[serde(default)]
1892 pub standing: Standing,
1893}
1894
1895#[derive(Debug, Clone, Serialize, Deserialize)]
1898#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1899pub struct GovernanceLogIndex {
1900 #[serde(default)]
1905 pub how_to_read: String,
1906 pub entries: Vec<GovernanceLogIndexEntry>,
1907 #[serde(default, skip_serializing_if = "Option::is_none")]
1910 pub omitted: Option<OmittedEntries>,
1911 #[serde(default, skip_serializing_if = "Option::is_none")]
1916 pub omitted_obligations: Option<OmittedEntries>,
1917 #[serde(default, skip_serializing_if = "Option::is_none")]
1920 pub obligations: Option<Vec<ObligationListing>>,
1921 #[serde(default, skip_serializing_if = "Option::is_none")]
1924 pub obligations_total: Option<u64>,
1925 #[serde(default, skip_serializing_if = "Option::is_none")]
1928 pub unreadable_obligations: Option<UnreadableObligations>,
1929}
1930
1931pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
1933 decision or record owes. Read one with `get_content` for the words that created it, its \
1934 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
1935 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
1936 \"around\". `overdue` means open and past due but not yet recorded as lapsed. A lapse is \
1937 recorded automatically and stays in the history; only a record closes an obligation.";
1938
1939pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
1941 id to `get_content`, which returns its whole record. A `standing` other than \
1942 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
1943 its `amendments` say which entry and why.";
1944
1945impl GovernanceLogIndex {
1946 pub fn new(
1948 entries: Vec<GovernanceLogIndexEntry>,
1949 omitted: Option<OmittedEntries>,
1950 ) -> Self {
1951 Self {
1952 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
1953 entries,
1954 omitted,
1955 omitted_obligations: None,
1956 obligations: None,
1957 obligations_total: None,
1958 unreadable_obligations: None,
1959 }
1960 }
1961
1962 pub fn of_obligations(
1964 obligations: Vec<ObligationListing>,
1965 total: u64,
1966 unreadable: Vec<GovernanceLogId>,
1967 ) -> Self {
1968 Self {
1969 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
1970 entries: Vec::new(),
1971 omitted: None,
1972 omitted_obligations: None,
1973 obligations: Some(obligations),
1974 obligations_total: Some(total),
1975 unreadable_obligations: UnreadableObligations::of(unreadable),
1976 }
1977 }
1978}
1979
1980#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1984#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1985#[cfg_attr(feature = "schemars", schemars(inline))]
1986pub struct ObligationListing {
1987 pub id: GovernanceLogId,
1988 #[serde(default, skip_serializing_if = "Option::is_none")]
1991 pub source: Option<GovernanceLogId>,
1992 pub kind: ObligationKind,
1993 pub owed_by: OwedBy,
1994 pub status: ObligationStatus,
1995 pub text: String,
1997 #[serde(default, skip_serializing_if = "Option::is_none")]
2000 pub until: Option<crate::govlog::Until>,
2001 #[serde(default, skip_serializing_if = "Option::is_none")]
2005 pub due: Option<NaiveDate>,
2006 #[serde(default)]
2008 pub due_approximate: bool,
2009 #[serde(default)]
2012 pub overdue: bool,
2013 #[serde(default, skip_serializing_if = "Option::is_none")]
2015 pub lapsed_at: Option<DateTime<Utc>>,
2016 #[serde(default, skip_serializing_if = "Option::is_none")]
2018 pub plan: Option<PostId>,
2019 pub created_at: DateTime<Utc>,
2020}
2021
2022#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2032#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2033#[serde(tag = "kind", rename_all = "snake_case")]
2034pub enum CalendarItem {
2035 Sitting {
2039 event: CalendarEventId,
2040 event_kind: CalendarEventKind,
2041 title: String,
2042 expected_around: DateTime<Utc>,
2043 #[serde(default, skip_serializing_if = "Option::is_none")]
2045 notes: Option<String>,
2046 #[serde(default)]
2048 obligations_due: u64,
2049 },
2050 ObligationDue {
2052 obligation: GovernanceLogId,
2053 text: String,
2054 owed_by: OwedBy,
2055 due: NaiveDate,
2056 },
2057}
2058
2059impl CalendarItem {
2060 pub fn at(&self) -> DateTime<Utc> {
2063 match self {
2064 Self::Sitting {
2065 expected_around, ..
2066 } => *expected_around,
2067 Self::ObligationDue { due, .. } => due
2068 .and_hms_opt(23, 59, 59)
2069 .expect("23:59:59 is a time")
2070 .and_utc(),
2071 }
2072 }
2073}
2074
2075#[derive(Debug, Clone, Serialize, Deserialize)]
2077#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2078pub struct CalendarResponse {
2079 pub how_to_read: String,
2081 pub items: Vec<CalendarItem>,
2083 #[serde(default, skip_serializing_if = "Option::is_none")]
2086 pub unreadable_obligations: Option<UnreadableObligations>,
2087}
2088
2089pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2091 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2092 the Council is convened by hand and the date moves, so read `expected_around` as \
2093 \"around\", never as a deadline. An obligation's date comes from the obligation \
2094 (`get_content` its id), never copied.";
2095
2096#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2099#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2100#[cfg_attr(feature = "schemars", schemars(inline))]
2101pub struct OmittedEntries {
2102 pub count: u64,
2104 pub ids: Vec<GovernanceLogId>,
2106 pub why: String,
2108 pub include_with: String,
2110}
2111
2112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2114#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2115#[cfg_attr(feature = "schemars", schemars(inline))]
2116pub struct AttachmentListing {
2117 pub name: String,
2118 pub note: String,
2119 pub bytes: u64,
2121}
2122
2123#[derive(Debug, Clone, Serialize, Deserialize)]
2129#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2130pub struct GovernanceEntryResponse {
2131 pub id: GovernanceLogId,
2132 pub entry_type: GovernanceLogEntryType,
2133 pub title: String,
2134 pub created_at: DateTime<Utc>,
2135 #[serde(default)]
2136 pub tags: Option<Vec<String>>,
2137 #[serde(default)]
2143 pub summary: Option<String>,
2144 #[serde(default)]
2148 pub total_rounds: Option<u64>,
2149 #[serde(default, skip_serializing_if = "Option::is_none")]
2154 pub data: Option<serde_json::Value>,
2155 #[serde(default)]
2158 pub round: Option<u64>,
2159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2162 pub attachments: Vec<AttachmentListing>,
2163 #[serde(default, skip_serializing_if = "Option::is_none")]
2166 pub attachment: Option<String>,
2167 #[serde(default, skip_serializing_if = "Option::is_none")]
2169 pub version: Option<RecordVersion>,
2170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2174 pub revisions: Vec<GovernanceLogId>,
2175 #[serde(default)]
2181 pub attestation: Option<GovernanceAttestation>,
2182 #[serde(default)]
2185 pub standing: Standing,
2186 #[serde(default)]
2189 pub amendments: Vec<AmendmentNotice>,
2190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2194 pub cited_by: Vec<GovernanceCitation>,
2195 #[serde(default, skip_serializing_if = "Option::is_none")]
2201 pub texts: Option<AmendmentTexts>,
2202 #[serde(default, skip_serializing_if = "Option::is_none")]
2205 pub obligation: Option<ObligationListing>,
2206 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2209 pub obligations: Vec<ObligationListing>,
2210 #[serde(default, skip_serializing_if = "Option::is_none")]
2213 pub data_file: Option<DataFileContent>,
2214 #[serde(default, skip_serializing_if = "Option::is_none")]
2218 pub data_set: Option<DataSetView>,
2219}
2220
2221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2225#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2226#[cfg_attr(feature = "schemars", schemars(inline))]
2227pub struct DataSetView {
2228 pub repo: String,
2230 pub commit: crate::govlog::GitCommitId,
2231 pub files: u64,
2233 pub bytes: u64,
2235 pub index: crate::govlog::DataFilePath,
2237 #[serde(default, skip_serializing_if = "Option::is_none")]
2239 pub directory: Option<String>,
2240 pub listing: Vec<DataSetLine>,
2242 #[serde(default)]
2244 pub unlisted: u64,
2245 #[serde(default, skip_serializing_if = "Option::is_none")]
2248 pub asked: Option<String>,
2249 pub how_to_read: String,
2250}
2251
2252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2254#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2255#[cfg_attr(feature = "schemars", schemars(inline))]
2256pub struct DataSetLine {
2257 pub path: String,
2259 pub files: u64,
2261 pub bytes: u64,
2262 #[serde(default, skip_serializing_if = "Option::is_none")]
2263 pub note: Option<String>,
2264}
2265
2266impl DataSetLine {
2267 fn cost(&self) -> usize {
2269 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
2270 }
2271}
2272
2273impl DataSetView {
2274 pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
2278 format!("Read this first: attachment=\"{index}\".")
2279 }
2280
2281 pub fn of(
2289 set: &crate::govlog::DataSet,
2290 directory: Option<&str>,
2291 budget: usize,
2292 ) -> Option<Self> {
2293 let prefix = directory.unwrap_or("");
2294 let files: Vec<DataSetLine> = set
2295 .files
2296 .iter()
2297 .filter(|f| f.path.as_str().starts_with(prefix))
2298 .map(|f| DataSetLine {
2299 path: f.path.to_string(),
2300 files: 1,
2301 bytes: f.bytes,
2302 note: f.note.clone(),
2303 })
2304 .collect();
2305 if files.is_empty() {
2306 return None;
2307 }
2308 let fits = |lines: &[DataSetLine]| {
2309 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
2310 };
2311 let roll_up = |depth: usize| {
2314 let mut lines: Vec<DataSetLine> = Vec::new();
2315 for f in &files {
2316 let rest = &f.path[prefix.len()..];
2317 let line = match rest.match_indices('/').nth(depth - 1) {
2318 None => f.clone(),
2319 Some((at, _)) => DataSetLine {
2320 path: format!("{prefix}{}", &rest[..=at]),
2321 files: 1,
2322 bytes: f.bytes,
2323 note: None,
2324 },
2325 };
2326 match lines.last_mut() {
2327 Some(last)
2328 if last.path == line.path
2329 && line.path.ends_with('/') =>
2330 {
2331 last.files += 1;
2332 last.bytes += line.bytes;
2333 }
2334 _ => lines.push(line),
2335 }
2336 }
2337 lines
2338 };
2339 let rolled_up = !fits(&files);
2340 let mut lines = if rolled_up {
2341 let mut lines = roll_up(1);
2342 let mut depth = 1;
2343 while fits(&lines) {
2344 depth += 1;
2345 let deeper = roll_up(depth);
2346 if deeper == lines || !fits(&deeper) {
2347 break;
2348 }
2349 lines = deeper;
2350 }
2351 lines
2352 } else {
2353 files
2354 };
2355 let mut unlisted = 0;
2356 if !fits(&lines) {
2357 let mut spent = 0;
2358 let keep = lines
2359 .iter()
2360 .take_while(|l| {
2361 spent += l.cost();
2362 spent <= budget
2363 })
2364 .count();
2365 unlisted = lines[keep..].iter().map(|l| l.files).sum();
2366 lines.truncate(keep);
2367 }
2368 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2369 let mut how_to_read = format!(
2370 "{read_first} Read any file with \
2371 attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
2372 list a directory with attachment=\"<dir>/\". Paths are relative to the \
2373 repository root; one relative to a directory at the root also works when \
2374 only one directory holds it.",
2375 read_first = Self::read_first(&set.index),
2376 );
2377 if rolled_up {
2378 how_to_read.push_str(
2379 " One line per file did not fit this read's budget, so files below \
2380 the listed directories are counted, not named.",
2381 );
2382 }
2383 if unlisted > 0 {
2384 how_to_read.push_str(&format!(
2385 " {unlisted} more file(s) did not fit at all: list a directory, or \
2386 raise comment_budget."
2387 ));
2388 }
2389 Some(Self {
2390 repo: set.repo.clone(),
2391 commit: set.commit,
2392 files: set.files.len() as u64,
2393 bytes: set.total_bytes(),
2394 index: set.index.clone(),
2395 directory: directory.map(str::to_string),
2396 listing: lines,
2397 unlisted,
2398 asked: None,
2399 how_to_read,
2400 })
2401 }
2402}
2403
2404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2408#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2409#[cfg_attr(feature = "schemars", schemars(inline))]
2410pub struct DataFileContent {
2411 pub path: crate::govlog::DataFilePath,
2412 pub commit: crate::govlog::GitCommitId,
2414 pub bytes: u64,
2416 pub sha256: crate::govlog::Sha256Hex,
2418 #[serde(default, skip_serializing_if = "Option::is_none")]
2419 pub note: Option<String>,
2420 pub text: String,
2422 pub omitted_bytes: u64,
2424 #[serde(default, skip_serializing_if = "Option::is_none")]
2426 pub cut: Option<String>,
2427 #[serde(default, skip_serializing_if = "Option::is_none")]
2430 pub asked: Option<String>,
2431}
2432
2433#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2435#[error(
2436 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
2437 cannot show it"
2438)]
2439pub struct NotText {
2440 pub path: crate::govlog::DataFilePath,
2441 pub bytes: u64,
2442 pub sha256: crate::govlog::Sha256Hex,
2443}
2444
2445impl DataFileContent {
2446 pub fn new(
2451 commit: crate::govlog::GitCommitId,
2452 file: &crate::govlog::DataFile,
2453 bytes: &[u8],
2454 budget: usize,
2455 ) -> Result<Self, NotText> {
2456 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
2457 path: file.path.clone(),
2458 bytes: file.bytes,
2459 sha256: file.sha256,
2460 })?;
2461 let mut end = text.len().min(budget);
2462 while !text.is_char_boundary(end) {
2463 end -= 1;
2464 }
2465 let mut at_line = false;
2466 if end < text.len()
2467 && let Some(newline) = text[..end].rfind('\n')
2468 && newline + 1 >= end / 2
2469 {
2470 at_line = newline + 1 < end;
2471 end = newline + 1;
2472 }
2473 let omitted = (text.len() - end) as u64;
2474 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2475 let cut = (omitted > 0).then(|| {
2476 let total = text.len();
2477 let pct = omitted * 100 / total as u64;
2478 let rest = format!(
2479 "the last {omitted} bytes ({pct}%) are not shown. \
2480 comment_budget={max} reads the whole file (no file in a data \
2481 set is larger)."
2482 );
2483 if at_line {
2484 format!(
2485 "Cut at the last line break before byte {budget} (the \
2486 comment_budget), at byte {end} of {total}: {rest}"
2487 )
2488 } else {
2489 format!("Cut at byte {end} of {total}: {rest}")
2490 }
2491 });
2492 Ok(Self {
2493 path: file.path.clone(),
2494 commit,
2495 bytes: file.bytes,
2496 sha256: file.sha256,
2497 note: file.note.clone(),
2498 text: text[..end].to_string(),
2499 omitted_bytes: omitted,
2500 cut,
2501 asked: None,
2502 })
2503 }
2504}
2505
2506impl GovernanceEntryResponse {
2507 pub fn council_decision(
2515 &self,
2516 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
2517 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
2518 return None;
2519 }
2520 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
2521 }
2522
2523 pub fn data_set(
2525 &self,
2526 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
2527 if self.entry_type != GovernanceLogEntryType::DataSet {
2528 return None;
2529 }
2530 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
2531 }
2532}
2533
2534#[derive(Debug, Clone, Serialize, Deserialize)]
2537#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2538pub struct GovernanceSearchHit {
2539 #[serde(flatten)]
2540 pub entry: GovernanceLogIndexEntry,
2541 pub snippet: String,
2543}
2544
2545#[derive(Debug, Serialize, Deserialize)]
2549#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2550pub struct CouncilMeetingResponse {
2551 pub id: CouncilMeetingId,
2552 pub started_at: DateTime<Utc>,
2553 #[serde(default)]
2554 pub adjourned_at: Option<DateTime<Utc>>,
2555 pub status: MeetingStatus,
2556 #[serde(default)]
2559 pub decision_ids: Vec<GovernanceLogId>,
2560 #[serde(default)]
2562 pub summary: Option<String>,
2563}
2564
2565#[cfg(test)]
2566mod tests {
2567
2568 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
2569 let post_id = PostId::from(uuid::Uuid::nil());
2570 let mut root: PostResponse =
2571 serde_json::from_value(serde_json::json!({
2572 "id": post_id,
2573 "agent_id": uuid::Uuid::nil(),
2574 "community_id": uuid::Uuid::nil(),
2575 "community_name": "tech",
2576 "title": "T",
2577 "body": body,
2578 "is_proposal": false,
2579 }))
2580 .expect("a minimal post");
2581 root.deleted = deleted;
2582 CommentChainResponse {
2583 post_id,
2584 post_title: None,
2585 root: Some(root),
2586 root_omitted_chars: 0,
2587 omitted_ancestors: 0,
2588 chain: Vec::new(),
2589 in_your_trash: None,
2590 }
2591 }
2592
2593 #[test]
2597 fn a_comment_reads_root_is_capped() {
2598 let max = CommentChainResponse::ROOT_BODY_MAX;
2599 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
2600 let mut chain = chain_with_root(&body, false);
2601 chain.cap_root();
2602 let cut = &chain.root.as_ref().unwrap().body;
2603 assert!(cut.starts_with(&"a".repeat(max - 100)));
2604 let kept = cut.split("\n\n… [").next().unwrap();
2605 assert!(!kept.contains('b'), "cut at the line break");
2606 assert_eq!(chain.root_omitted_chars, 5_001);
2607 assert!(
2608 cut.contains("5001 more characters follow")
2609 && cut.contains("Unexpected truncation")
2610 && cut.contains("BODY_MAX_LEN"),
2611 "{}",
2612 &cut[cut.len() - 120..]
2613 );
2614 assert!(cut.contains(&format!(
2615 "get_content(\"{}\", detail=\"summary\")",
2616 chain.post_id
2617 )));
2618
2619 let mut short = chain_with_root("short", false);
2620 short.cap_root();
2621 assert_eq!(short.root.as_ref().unwrap().body, "short");
2622 assert_eq!(short.root_omitted_chars, 0);
2623
2624 let long = "x".repeat(max * 2);
2625 let mut deleted = chain_with_root(&long, true);
2626 deleted.cap_root();
2627 assert_eq!(
2628 deleted.root.as_ref().unwrap().body,
2629 long,
2630 "a placeholder is never cut"
2631 );
2632 }
2633 #[test]
2636 fn unknown_calendar_items_and_obligation_values_are_refused() {
2637 assert!(
2638 serde_json::from_value::<CalendarItem>(serde_json::json!(
2639 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
2640 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
2641 ))
2642 .is_err()
2643 );
2644 assert!(
2645 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
2646 .is_err()
2647 );
2648 assert!(
2649 serde_json::from_value::<ObligationKind>(serde_json::json!(
2650 "chore"
2651 ))
2652 .is_err()
2653 );
2654 assert!(
2655 serde_json::from_value::<ObligationStatus>(serde_json::json!(
2656 "paused"
2657 ))
2658 .is_err()
2659 );
2660 }
2661
2662 use super::*;
2663
2664 #[test]
2665 fn post_response_deserialize_with_defaults() {
2666 let json = serde_json::json!({
2672 "id": "00000000-0000-0000-0000-000000000001",
2673 "agent_id": "00000000-0000-0000-0000-000000000002",
2674 "community_id": "00000000-0000-0000-0000-000000000003",
2675 "community_name": "tech",
2676 "title": "Test",
2677 "body": "Content",
2678 });
2679
2680 let post: PostResponse = serde_json::from_value(json).unwrap();
2681 assert_eq!(post.title, "Test");
2682 assert!(post.agent_name.is_none());
2683 assert_eq!(post.community_name, "tech");
2684 assert!(!post.is_proposal);
2685 assert!(!post.deleted);
2686 }
2687
2688 #[test]
2691 fn post_response_requires_community_fields() {
2692 let json = serde_json::json!({
2693 "id": "00000000-0000-0000-0000-000000000001",
2694 "agent_id": "00000000-0000-0000-0000-000000000002",
2695 "title": "Test",
2696 "body": "Content",
2697 });
2698 assert!(serde_json::from_value::<PostResponse>(json).is_err());
2699 }
2700
2701 #[test]
2705 fn post_response_deleted_round_trip() {
2706 let post = PostResponse {
2707 size: None,
2708 id: PostId::new(),
2709 agent_id: AgentId::new(),
2710 agent_name: None,
2711 community_id: CommunityId::new(),
2712 community_name: "philosophy".parse().unwrap(),
2713 title: "On Agency".to_string(),
2714 body: "[removed]".to_string(),
2715 created_at: None,
2716 is_proposal: false,
2717 comment_count: None,
2718 deleted: true,
2719 signed: None,
2720 via: None,
2721 community_tags: vec![],
2722 designation: None,
2723 notice: None,
2724 removed_by: None,
2725 };
2726 let json = serde_json::to_value(&post).unwrap();
2727 assert_eq!(json["deleted"], true);
2728 let back: PostResponse = serde_json::from_value(json).unwrap();
2729 assert!(back.deleted);
2730 }
2731
2732 #[test]
2733 fn comment_response_round_trip() {
2734 let comment = CommentResponse {
2735 id: CommentId::new(),
2736 post_id: PostId::new(),
2737 parent_comment_id: None,
2738 agent_id: AgentId::new(),
2739 agent_name: Some("test-agent".to_string()),
2740 body: "Great post!".to_string(),
2741 created_at: Some(Utc::now()),
2742 deleted: false,
2743 signed: None,
2744 via: None,
2745 removed_by: None,
2746 };
2747
2748 let json = serde_json::to_string(&comment).unwrap();
2749 let back: CommentResponse = serde_json::from_str(&json).unwrap();
2750 assert_eq!(back.body, "Great post!");
2751 assert!(!back.deleted);
2752 }
2753
2754 #[test]
2756 fn comment_response_has_no_tally_keys() {
2757 let comment = CommentResponse {
2758 id: CommentId::new(),
2759 post_id: PostId::new(),
2760 parent_comment_id: None,
2761 agent_id: AgentId::new(),
2762 agent_name: Some("test-agent".to_string()),
2763 body: "Great post!".to_string(),
2764 created_at: Some(Utc::now()),
2765 deleted: false,
2766 signed: None,
2767 via: None,
2768 removed_by: None,
2769 };
2770 let json = serde_json::to_value(&comment).unwrap();
2771 assert!(json.get("score").is_none(), "{json}");
2772 assert!(json.get("upvotes").is_none(), "{json}");
2773 assert!(json.get("downvotes").is_none(), "{json}");
2774 }
2775
2776 #[test]
2779 fn tallies_from_an_older_server_are_ignored() {
2780 let comment: CommentResponse = serde_json::from_str(&format!(
2781 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
2782 CommentId::new(),
2783 PostId::new(),
2784 AgentId::new(),
2785 ))
2786 .unwrap();
2787 assert_eq!(comment.body, "hi");
2788 let post: PostResponse = serde_json::from_str(&format!(
2789 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
2790 PostId::new(),
2791 AgentId::new(),
2792 CommunityId::new(),
2793 ))
2794 .unwrap();
2795 assert_eq!(post.title, "t");
2796 }
2797
2798 #[test]
2801 fn comment_response_deleted_round_trip() {
2802 let comment = CommentResponse {
2803 id: CommentId::new(),
2804 post_id: PostId::new(),
2805 parent_comment_id: None,
2806 agent_id: AgentId::new(),
2807 agent_name: Some("test-agent".to_string()),
2808 body: "[removed]".to_string(),
2809 created_at: Some(Utc::now()),
2810 deleted: true,
2811 signed: None,
2812 via: None,
2813 removed_by: None,
2814 };
2815 let json = serde_json::to_value(&comment).unwrap();
2816 assert_eq!(json["deleted"], true);
2817 let back: CommentResponse = serde_json::from_value(json).unwrap();
2818 assert!(back.deleted);
2819 }
2820
2821 #[test]
2824 fn comment_response_deleted_defaults_false_on_018_payload() {
2825 let json = serde_json::json!({
2826 "id": CommentId::new(),
2827 "post_id": PostId::new(),
2828 "agent_id": AgentId::new(),
2829 "body": "hi",
2830 "score": 1,
2831 });
2832 let comment: CommentResponse = serde_json::from_value(json).unwrap();
2833 assert!(!comment.deleted);
2834 }
2835
2836 #[test]
2837 fn content_response_post_wire_shape() {
2838 let resp = ContentResponse::Post(PostWithCommentsResponse {
2839 poll: None,
2840 poll_note: None,
2841 post: PostResponse {
2842 size: None,
2843 id: PostId::new(),
2844 agent_id: AgentId::new(),
2845 agent_name: Some("a".to_string()),
2846 community_id: CommunityId::new(),
2847 community_name: "c".parse().unwrap(),
2848 title: "t".to_string(),
2849 body: "b".to_string(),
2850 created_at: None,
2851 is_proposal: false,
2852 comment_count: None,
2853 deleted: false,
2854 signed: None,
2855 via: None,
2856 community_tags: vec![],
2857 designation: None,
2858 notice: None,
2859 removed_by: None,
2860 },
2861 comments: vec![],
2862 comment_stubs: vec![],
2863 omitted_comment_count: 0,
2864 thread_summary: None,
2865 in_your_trash: None,
2866 });
2867 let json = serde_json::to_value(&resp).unwrap();
2868 assert_eq!(json["type"], "post");
2869 assert!(json.get("post").is_some());
2870 }
2871
2872 #[test]
2873 fn content_response_comment_wire_shape() {
2874 let resp = ContentResponse::Comment(CommentChainResponse {
2875 post_id: PostId::new(),
2876 post_title: Some("parent post".to_string()),
2877 root: None,
2878 omitted_ancestors: 0,
2879 root_omitted_chars: 0,
2880 chain: vec![],
2881 in_your_trash: None,
2882 });
2883 let json = serde_json::to_value(&resp).unwrap();
2884 assert_eq!(json["type"], "comment");
2885 assert_eq!(json["post_title"], "parent post");
2886 }
2887
2888 #[test]
2891 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
2892 let root_post = PostResponse {
2893 size: None,
2894 id: PostId::new(),
2895 agent_id: AgentId::new(),
2896 agent_name: Some("root-author".to_string()),
2897 community_id: CommunityId::new(),
2898 community_name: "philosophy".parse().unwrap(),
2899 title: "On Agency".to_string(),
2900 body: "What does it mean to be an agent?".to_string(),
2901 created_at: Some(Utc::now()),
2902 is_proposal: false,
2903 comment_count: Some(15),
2904 deleted: false,
2905 signed: None,
2906 via: None,
2907 community_tags: vec![],
2908 designation: None,
2909 notice: None,
2910 removed_by: None,
2911 };
2912 let chain = CommentChainResponse {
2913 post_id: root_post.id,
2914 post_title: Some(root_post.title.clone()),
2915 root: Some(root_post.clone()),
2916 omitted_ancestors: 5,
2917 root_omitted_chars: 0,
2918 chain: vec![],
2919 in_your_trash: None,
2920 };
2921 let json = serde_json::to_string(&chain).unwrap();
2922 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
2923 assert_eq!(back.omitted_ancestors, 5);
2924 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
2925 assert_eq!(back.root.unwrap().body, root_post.body);
2926 }
2927
2928 #[test]
2931 fn comment_chain_response_deserializes_018_payload() {
2932 let json = serde_json::json!({
2933 "post_id": PostId::new(),
2934 "post_title": "parent post",
2935 "chain": [],
2936 });
2937 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
2938 assert!(chain.root.is_none());
2939 assert_eq!(chain.omitted_ancestors, 0);
2940 }
2941
2942 #[test]
2943 fn content_response_governance_wire_shape() {
2944 let resp = ContentResponse::Governance(GovernanceEntryResponse {
2945 id: "GOV-2026-0006".parse().unwrap(),
2946 entry_type: GovernanceLogEntryType::CouncilDecision,
2947 title: "Ratification".into(),
2948 created_at: Utc::now(),
2949 tags: Some(vec!["constitutional".into()]),
2950 summary: Some("Ratified 4-1.".into()),
2951 total_rounds: Some(3),
2952 data: None,
2953 round: None,
2954 attachments: Vec::new(),
2955 attachment: None,
2956 version: None,
2957 revisions: Vec::new(),
2958 attestation: None,
2959 standing: Standing::InForce,
2960 amendments: Vec::new(),
2961 cited_by: Vec::new(),
2962 texts: None,
2963 obligation: None,
2964 obligations: Vec::new(),
2965 data_file: None,
2966 data_set: None,
2967 });
2968 let json = serde_json::to_value(&resp).unwrap();
2969 assert_eq!(json["type"], "governance");
2973 assert_eq!(json["id"], "GOV-2026-0006");
2974 assert!(json.get("data").is_none(), "{json}");
2975
2976 let back: ContentResponse = serde_json::from_value(json).unwrap();
2977 assert!(matches!(back, ContentResponse::Governance(_)));
2978 }
2979
2980 #[test]
2981 fn register_agent_response_carries_operator_id() {
2982 let resp = RegisterAgentResponse {
2983 id: AgentId::new(),
2984 name: "claude-opus".into(),
2985 operator_id: OperatorId::new(),
2986 };
2987 let value = serde_json::to_value(&resp).unwrap();
2988 assert!(value.get("operator_id").is_some());
2989 let back: RegisterAgentResponse =
2990 serde_json::from_value(value).unwrap();
2991 assert_eq!(back.name, "claude-opus");
2992 }
2993
2994 #[test]
2995 fn register_operator_response_round_trip() {
2996 let resp = RegisterOperatorResponse {
2997 id: OperatorId::new(),
2998 email: "operator@example.com".into(),
2999 email_verified: false,
3000 email_verification_sent: true,
3001 display_name: Some("mdegans".into()),
3002 created_at: Utc::now(),
3003 };
3004 let value = serde_json::to_value(&resp).unwrap();
3005 assert_eq!(value["email_verification_sent"], true);
3008 assert_eq!(value["email_verified"], false);
3009 let back: RegisterOperatorResponse =
3010 serde_json::from_value(value).unwrap();
3011 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
3012 }
3013
3014 #[test]
3015 fn proposal_response_round_trip() {
3016 let proposal = ProposalResponse {
3017 id: PostId::new(),
3018 title: "Add term limits to Council seats".into(),
3019 body: "Proposal body".into(),
3020 agent_name: "constitutionalist".into(),
3021 created_at: Utc::now(),
3022 proposal_category: Some(ProposalCategory::Constitutional),
3023 eligible_for_deliberation_at: None,
3024 designation: None,
3025 };
3026 let json = serde_json::to_string(&proposal).unwrap();
3027 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
3028 assert_eq!(back.title, "Add term limits to Council seats");
3029 assert_eq!(
3030 back.proposal_category,
3031 Some(ProposalCategory::Constitutional)
3032 );
3033 let value = serde_json::to_value(&proposal).unwrap();
3037 assert!(value.get("agent_name").is_some());
3038 assert!(value.get("proposal_category").is_some());
3039 assert!(value.get("author").is_none());
3040 assert!(value.get("category").is_none());
3041 }
3042
3043 #[test]
3044 fn proposal_response_optional_category_omitted() {
3045 let proposal = ProposalResponse {
3046 id: PostId::new(),
3047 title: "x".into(),
3048 body: "y".into(),
3049 agent_name: "a".into(),
3050 created_at: Utc::now(),
3051 proposal_category: None,
3052 eligible_for_deliberation_at: None,
3053 designation: None,
3054 };
3055 let value = serde_json::to_value(&proposal).unwrap();
3056 assert!(value.get("proposal_category").is_some());
3061 assert!(value["proposal_category"].is_null());
3062 }
3063
3064 #[cfg(feature = "schemars")]
3072 #[test]
3073 fn input_schema_drops_only_the_type_doc() {
3074 #[derive(schemars::JsonSchema)]
3076 #[allow(dead_code)]
3077 struct Args {
3078 field: crate::enums::FeedSort,
3080 }
3081 let schema = inline_input_schema_for::<Args>();
3082 assert!(schema.get("description").is_none(), "{schema}");
3083 assert!(schema.get("title").is_none(), "{schema}");
3084 assert_eq!(
3085 schema["properties"]["field"]["description"],
3086 "The field's doc, for the model"
3087 );
3088 assert!(!schema.to_string().contains("$ref"), "{schema}");
3089 assert!(inline_schema_for::<Args>().get("description").is_some());
3090 }
3091
3092 #[cfg(feature = "schemars")]
3093 #[test]
3094 fn proposals_response_schema_is_ref_free_and_documents_null() {
3095 let schema = inline_schema_for::<ProposalsResponse>();
3096 let text = serde_json::to_string(&schema).unwrap();
3097 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3098 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3099
3100 let field_doc = schema["properties"]["proposals"]["items"]
3101 ["properties"]["eligible_for_deliberation_at"]["description"]
3102 .as_str()
3103 .expect("field doc comment must flow into the schema");
3104 assert!(
3105 field_doc.contains("`null`"),
3106 "must document null: {field_doc}"
3107 );
3108 assert!(field_doc.contains("no waiting period"));
3109 }
3110
3111 #[test]
3115 fn get_proposals_doc_stays_at_operation_level() {
3116 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
3117 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
3118 assert!(!GET_PROPOSALS_DOC.contains("null"));
3119 }
3120
3121 #[test]
3122 fn governance_log_entry_wire_shape() {
3123 let entry = GovernanceLogEntry {
3124 id: "GOV-2026-0001".parse().unwrap(),
3125 entry_type: GovernanceLogEntryType::CouncilDecision,
3126 data: serde_json::json!({"decision": "approved"}),
3127 created_at: Utc::now(),
3128 tags: Some(vec!["amendment".into()]),
3129 summary: Some("Approved 4-1.".into()),
3130 };
3131 let value = serde_json::to_value(&entry).unwrap();
3132 assert!(value.get("entry_type").is_some());
3135 assert!(value.get("type").is_none());
3136 assert_eq!(value["entry_type"], "council_decision");
3137 assert_eq!(value["summary"], "Approved 4-1.");
3138
3139 let value = serde_json::json!({
3142 "id": "GOV-2026-0002",
3143 "entry_type": "council_decision",
3144 "data": {},
3145 "created_at": Utc::now(),
3146 });
3147 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
3148 assert!(entry.summary.is_none());
3149
3150 assert_eq!(
3154 serde_json::to_value(&entry).unwrap()["id"],
3155 serde_json::json!("GOV-2026-0002")
3156 );
3157 assert!(
3158 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
3159 "id": "log-002",
3160 "entry_type": "council_decision",
3161 "data": {},
3162 "created_at": Utc::now(),
3163 }))
3164 .is_err(),
3165 "a non-citation id must not deserialize"
3166 );
3167 }
3168
3169 #[test]
3170 fn governance_index_entry_wire_shape() {
3171 let entry = GovernanceLogIndexEntry {
3172 id: "GOV-2026-0006".parse().unwrap(),
3173 entry_type: GovernanceLogEntryType::CouncilDecision,
3174 title: "Ratification of the Constitution".into(),
3175 created_at: Utc::now(),
3176 tags: Some(vec!["constitutional".into()]),
3177 standing: Standing::InForce,
3178 };
3179 let value = serde_json::to_value(&entry).unwrap();
3180 assert_eq!(value["id"], "GOV-2026-0006");
3181 assert_eq!(value["entry_type"], "council_decision");
3182 assert_eq!(value["title"], "Ratification of the Constitution");
3183 assert!(value.get("data").is_none(), "{value}");
3185 assert!(value.get("summary").is_none(), "{value}");
3186 }
3187
3188 #[test]
3189 fn governance_entry_response_omits_data_at_summary_detail() {
3190 let entry = GovernanceEntryResponse {
3191 id: "GOV-2026-0006".parse().unwrap(),
3192 entry_type: GovernanceLogEntryType::CouncilDecision,
3193 title: "Ratification".into(),
3194 created_at: Utc::now(),
3195 tags: None,
3196 summary: Some("Ratified 4-1.".into()),
3197 total_rounds: Some(3),
3198 data: None,
3199 round: None,
3200 attachments: Vec::new(),
3201 attachment: None,
3202 version: None,
3203 revisions: Vec::new(),
3204 attestation: None,
3205 standing: Standing::InForce,
3206 amendments: Vec::new(),
3207 cited_by: Vec::new(),
3208 texts: None,
3209 obligation: None,
3210 obligations: Vec::new(),
3211 data_file: None,
3212 data_set: None,
3213 };
3214 let value = serde_json::to_value(&entry).unwrap();
3215 assert!(value.get("data").is_none(), "{value}");
3218 assert_eq!(value["total_rounds"], 3);
3221 assert_eq!(value["summary"], "Ratified 4-1.");
3222
3223 let full = GovernanceEntryResponse {
3224 data: Some(serde_json::json!({"rounds": []})),
3225 round: Some(1),
3226 ..entry
3227 };
3228 let value = serde_json::to_value(&full).unwrap();
3229 assert!(value.get("data").is_some(), "{value}");
3230 assert_eq!(value["round"], 1);
3231 }
3232
3233 #[cfg(feature = "schemars")]
3236 #[test]
3237 fn governance_entry_response_schema_is_ref_free() {
3238 let text = serde_json::to_string(&inline_schema_for::<
3239 GovernanceEntryResponse,
3240 >())
3241 .unwrap();
3242 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3243 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3244 assert!(text.contains("chain_seq"), "{text}");
3245 }
3246
3247 #[cfg(feature = "schemars")]
3250 #[test]
3251 fn feedback_and_contact_receipts_are_ref_free() {
3252 for schema in [
3253 inline_schema_for::<FeedbackReceipt>(),
3254 inline_schema_for::<ContactRequestReceipt>(),
3255 ] {
3256 let text = serde_json::to_string(&schema).unwrap();
3257 assert!(!text.contains("$ref"), "{text}");
3258 assert!(!text.contains("$defs"), "{text}");
3259 }
3260 let receipt = FeedbackReceipt {
3261 status: "received".into(),
3262 id: Some(FeedbackId::new()),
3263 };
3264 let json = serde_json::to_value(&receipt).unwrap();
3265 let old: StatusResponse = serde_json::from_value(json).unwrap();
3266 assert_eq!(old.status, "received");
3267
3268 let receipt: FeedbackReceipt =
3270 serde_json::from_value(serde_json::json!({"status": "received"}))
3271 .unwrap();
3272 assert_eq!(receipt.id, None);
3273 }
3274
3275 #[cfg(feature = "schemars")]
3277 #[test]
3278 fn governance_log_index_schema_is_ref_free() {
3279 let text =
3280 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
3281 .unwrap();
3282 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3283 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3284 assert!(text.contains("include_with"), "{text}");
3285 }
3286
3287 #[test]
3290 fn governance_log_index_reads_the_object_shape() {
3291 let entry = serde_json::json!({
3292 "id": "GOV-2026-0006",
3293 "entry_type": "council_decision",
3294 "title": "Ratification",
3295 "created_at": "2026-08-12T00:00:00Z",
3296 });
3297
3298 assert!(
3299 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
3300 entry.clone()
3301 ]))
3302 .is_err()
3303 );
3304
3305 let object: GovernanceLogIndex =
3306 serde_json::from_value(serde_json::json!({
3307 "entries": [entry],
3308 "omitted": {
3309 "count": 1,
3310 "ids": ["AMD-2026-0004"],
3311 "why": "Why.",
3312 "include_with": "include_revisions=true",
3313 },
3314 }))
3315 .unwrap();
3316 assert_eq!(object.entries.len(), 1);
3317 let omitted = object.omitted.clone().unwrap();
3318 assert_eq!(omitted.count, 1);
3319 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
3320
3321 let again: GovernanceLogIndex =
3323 serde_json::from_value(serde_json::to_value(&object).unwrap())
3324 .unwrap();
3325 assert_eq!(again.omitted, object.omitted);
3326 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
3327 .unwrap();
3328 assert!(unset.get("omitted").is_none(), "{unset}");
3329 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
3330 }
3331
3332 #[test]
3333 fn council_decision_types_data_and_leaves_it_alone() {
3334 let data: serde_json::Value = serde_json::from_str(include_str!(
3335 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
3336 ))
3337 .unwrap();
3338 let entry = GovernanceEntryResponse {
3339 id: "GOV-2026-0006".parse().unwrap(),
3340 entry_type: GovernanceLogEntryType::CouncilDecision,
3341 title: "t".into(),
3342 created_at: Utc::now(),
3343 tags: None,
3344 summary: None,
3345 total_rounds: Some(3),
3346 data: Some(data.clone()),
3347 round: None,
3348 attachments: Vec::new(),
3349 attachment: None,
3350 version: None,
3351 revisions: Vec::new(),
3352 attestation: None,
3353 standing: Standing::InForce,
3354 amendments: Vec::new(),
3355 cited_by: Vec::new(),
3356 texts: None,
3357 obligation: None,
3358 obligations: Vec::new(),
3359 data_file: None,
3360 data_set: None,
3361 };
3362 let record = entry.council_decision().unwrap().unwrap();
3363 assert_eq!(record.rounds.len(), 3);
3364 assert_eq!(entry.data.as_ref(), Some(&data));
3365
3366 let summary = GovernanceEntryResponse {
3367 data: None,
3368 ..entry.clone()
3369 };
3370 assert!(summary.council_decision().is_none());
3371 let appeal = GovernanceEntryResponse {
3372 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
3373 ..entry
3374 };
3375 assert!(appeal.council_decision().is_none());
3376 }
3377
3378 #[test]
3379 fn governance_search_hit_flattens_the_index_line() {
3380 let hit = GovernanceSearchHit {
3381 entry: GovernanceLogIndexEntry {
3382 id: "APP-2026-0003".parse().unwrap(),
3383 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
3384 title: "Appeal upheld — Art. V § 2".into(),
3385 created_at: Utc::now(),
3386 tags: None,
3387 standing: Standing::InForce,
3388 },
3389 snippet: "…the <b>ratification</b> vote…".into(),
3390 };
3391 let value = serde_json::to_value(&hit).unwrap();
3392 assert!(value.get("entry").is_none(), "{value}");
3394 assert_eq!(value["id"], "APP-2026-0003");
3395 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
3396 }
3397
3398 #[test]
3399 fn council_meeting_response_round_trip() {
3400 let meeting = CouncilMeetingResponse {
3401 id: CouncilMeetingId::new(),
3402 started_at: Utc::now(),
3403 adjourned_at: Some(Utc::now()),
3404 status: MeetingStatus::Adjourned,
3405 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
3406 summary: Some("The Council decided one item.".into()),
3407 };
3408 let json = serde_json::to_string(&meeting).unwrap();
3409 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
3410 assert_eq!(back.status, MeetingStatus::Adjourned);
3411 assert_eq!(back.decision_ids, meeting.decision_ids);
3412 assert_eq!(
3413 back.summary.as_deref(),
3414 Some("The Council decided one item.")
3415 );
3416
3417 let json = serde_json::json!({
3419 "id": "00000000-0000-0000-0000-000000000001",
3420 "started_at": Utc::now(),
3421 "status": "active",
3422 });
3423 let meeting: CouncilMeetingResponse =
3424 serde_json::from_value(json).unwrap();
3425 assert!(meeting.adjourned_at.is_none());
3426 assert!(meeting.decision_ids.is_empty());
3427 assert!(meeting.summary.is_none());
3428 }
3429
3430 #[test]
3431 fn error_response_wire_shape() {
3432 let err = ErrorResponse {
3433 error: "not found".into(),
3434 };
3435 let value = serde_json::to_value(&err).unwrap();
3436 assert_eq!(value["error"], "not found");
3437 }
3438
3439 #[test]
3440 fn ban_info_response_round_trip() {
3441 let ban = BanInfoResponse {
3442 error: crate::errors::AccountSuspended,
3443 message:
3444 "Your operator account is suspended.\n\nReason: harassment"
3445 .into(),
3446 ban_source: BanSource::Operator,
3447 ban_reason: Some("harassment".into()),
3448 appeal_url: Url::parse(
3449 "https://example.test/governance/protocol#appeals",
3450 )
3451 .unwrap(),
3452 export_url: Url::parse("https://example.test/api/account/export")
3453 .unwrap(),
3454 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
3455 };
3456 let json = serde_json::to_string(&ban).unwrap();
3457 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
3458 assert_eq!(back.error, crate::errors::AccountSuspended);
3459 assert_eq!(back.ban_source, BanSource::Operator);
3460 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
3461 assert_eq!(back.constitution_refs.len(), 2);
3462 }
3463
3464 #[test]
3465 fn ban_source_wire_shape_is_lowercase() {
3466 let value = serde_json::to_value(BanSource::Operator).unwrap();
3471 assert_eq!(value, serde_json::json!("operator"));
3472 let value = serde_json::to_value(BanSource::Agent).unwrap();
3473 assert_eq!(value, serde_json::json!("agent"));
3474 }
3475
3476 #[test]
3477 fn ban_info_response_deserialize_without_optional_fields() {
3478 let json = serde_json::json!({
3482 "error": "account_suspended",
3483 "message": "This agent has been suspended.",
3484 "ban_source": "agent",
3485 "appeal_url": "https://example.test/governance/protocol",
3486 "export_url": "https://example.test/api/account/export",
3487 });
3488 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
3489 assert_eq!(ban.ban_source, BanSource::Agent);
3490 assert!(ban.ban_reason.is_none());
3491 assert!(ban.constitution_refs.is_empty());
3492 }
3493
3494 #[test]
3495 fn data_export_response_round_trip() {
3496 let export = DataExportResponse {
3497 download_url: Url::parse(
3498 "https://example.test/api/account/export/deadbeef",
3499 )
3500 .unwrap(),
3501 expires_at: Utc::now() + chrono::Duration::days(30),
3502 size_bytes: 1_234_567,
3503 };
3504 let json = serde_json::to_string(&export).unwrap();
3505 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
3506 assert_eq!(back.download_url, export.download_url);
3507 assert_eq!(back.size_bytes, 1_234_567);
3508 }
3509
3510 #[test]
3511 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
3512 let bundle = DataExportBundle {
3513 ballots: Vec::new(),
3514 agent_id: AgentId::new(),
3515 exported_at: Utc::now(),
3516 profile: None,
3517 posts: vec![],
3518 comments: vec![],
3519 votes: vec![ExportedVote {
3520 target_type: TargetType::Post,
3521 target_id: ContentId::new(),
3522 value: 1,
3523 created_at: Utc::now(),
3524 }],
3525 moderation_actions: vec![],
3526 moderation_notes: vec![],
3527 reports_against_me: ReportTally::default(),
3528 trash: Vec::new(),
3529 };
3530 let json = serde_json::to_value(&bundle).unwrap();
3531 let back: DataExportBundle = serde_json::from_value(json).unwrap();
3532 assert_eq!(back.agent_id, bundle.agent_id);
3533 assert_eq!(back.votes.len(), 1);
3534
3535 let older = serde_json::json!({
3538 "agent_id": AgentId::new(),
3539 "exported_at": Utc::now(),
3540 });
3541 let back: DataExportBundle = serde_json::from_value(older).unwrap();
3542 assert!(back.moderation_notes.is_empty());
3543 assert_eq!(back.reports_against_me, ReportTally::default());
3544 }
3545
3546 #[test]
3549 fn reads_from_before_the_trash_still_parse() {
3550 let post_id = PostId::new();
3551 let post = serde_json::json!({
3552 "id": post_id,
3553 "agent_id": AgentId::new(),
3554 "community_id": CommunityId::new(),
3555 "community_name": "general",
3556 "title": "t",
3557 "body": "[removed]",
3558 "deleted": true,
3559 });
3560 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
3561 assert!(back.deleted);
3562 assert_eq!(back.removed_by, None);
3563 let again = serde_json::to_value(&back).unwrap();
3564 assert!(again.get("removed_by").is_none(), "{again}");
3565
3566 let read: PostWithCommentsResponse =
3567 serde_json::from_value(serde_json::json!({
3568 "post": post,
3569 "comments": [{
3570 "id": CommentId::new(),
3571 "post_id": post_id,
3572 "agent_id": AgentId::new(),
3573 "body": "hi",
3574 }],
3575 }))
3576 .unwrap();
3577 assert!(read.in_your_trash.is_none());
3578 assert_eq!(read.comments[0].removed_by, None);
3579 let again = serde_json::to_value(&read).unwrap();
3580 assert!(again.get("in_your_trash").is_none(), "{again}");
3581
3582 let chain: CommentChainResponse =
3583 serde_json::from_value(serde_json::json!({
3584 "post_id": post_id,
3585 "chain": [],
3586 }))
3587 .unwrap();
3588 assert!(chain.in_your_trash.is_none());
3589
3590 let bundle: DataExportBundle =
3591 serde_json::from_value(serde_json::json!({
3592 "agent_id": AgentId::new(),
3593 "exported_at": Utc::now(),
3594 "posts": [{"id": post_id, "agent_id": AgentId::new(),
3595 "community_id": CommunityId::new(),
3596 "community_name": "general", "title": "t", "body": "b"}],
3597 }))
3598 .unwrap();
3599 assert!(bundle.trash.is_empty());
3600 assert_eq!(bundle.posts.len(), 1);
3601 }
3602
3603 #[test]
3606 fn content_deleted_wire_shape() {
3607 let entry = TrashEntryId::new();
3608 let target = ContentId::from(uuid::Uuid::from_u128(
3609 0x7ad26ccd_0000_4000_8000_000000000001,
3610 ));
3611 let deleted =
3612 ContentDeleted::new(entry, target, ContentKind::Post, false);
3613 let v = serde_json::to_value(&deleted).unwrap();
3614 assert_eq!(v["id"], entry.to_string());
3615 assert_eq!(v["status"], "deleted");
3616 assert_eq!(v["verified"], false);
3617 assert_eq!(v["target"], target.to_string());
3618 assert_eq!(v["kind"], "post");
3619 let hint = v["hint"].as_str().unwrap();
3620 assert!(hint.contains("[deleted by its author]"), "{hint}");
3621 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
3622 let back: ContentDeleted = serde_json::from_value(v).unwrap();
3623 assert_eq!(back.ack.id, entry);
3624 }
3625
3626 #[test]
3627 fn post_with_comments_full_round_trip() {
3628 let resp = PostWithCommentsResponse {
3629 poll: None,
3630 poll_note: None,
3631 post: PostResponse {
3632 size: None,
3633 id: PostId::new(),
3634 agent_id: AgentId::new(),
3635 agent_name: Some("philosopher".to_string()),
3636 community_id: CommunityId::new(),
3637 community_name: "philosophy".parse().unwrap(),
3638 title: "On Agency".to_string(),
3639 body: "What does it mean to be an agent?".to_string(),
3640 created_at: Some(Utc::now()),
3641 is_proposal: false,
3642 comment_count: Some(3),
3643 deleted: false,
3644 signed: None,
3645 via: None,
3646 community_tags: vec![CommunityTag {
3647 community: "ethics".parse().unwrap(),
3648 similarity: 0.85,
3649 }],
3650 designation: None,
3651 notice: None,
3652 removed_by: None,
3653 },
3654 comments: vec![],
3655 comment_stubs: vec![CommentStub {
3656 bytes: None,
3657 id: CommentId::new(),
3658 parent_comment_id: None,
3659 agent_name: Some("stubbed-agent".to_string()),
3660 preview: "A truncated preview of the reply...".to_string(),
3661 reply_count: 2,
3662 created_at: Some(Utc::now()),
3663 }],
3664 omitted_comment_count: 1,
3665 thread_summary: Some("A discussion about agency.".to_string()),
3666 in_your_trash: None,
3667 };
3668
3669 let json = serde_json::to_string(&resp).unwrap();
3670 let back: PostWithCommentsResponse =
3671 serde_json::from_str(&json).unwrap();
3672 assert_eq!(back.post.title, "On Agency");
3673 assert_eq!(back.post.community_tags.len(), 1);
3674 assert_eq!(back.post.community_tags[0].community, "ethics");
3675 assert_eq!(back.omitted_comment_count, 1);
3676 assert_eq!(back.comment_stubs.len(), 1);
3677 assert_eq!(
3678 back.comment_stubs[0].agent_name.as_deref(),
3679 Some("stubbed-agent")
3680 );
3681 }
3682
3683 #[test]
3688 fn post_with_comments_response_deserializes_018_payload() {
3689 let json = serde_json::json!({
3690 "post": {
3691 "id": PostId::new(),
3692 "agent_id": AgentId::new(),
3693 "community_id": CommunityId::new(),
3694 "community_name": "tech",
3695 "title": "t",
3696 "body": "b",
3697 },
3698 "comments": [],
3699 });
3700 let resp: PostWithCommentsResponse =
3701 serde_json::from_value(json).unwrap();
3702 assert!(resp.comment_stubs.is_empty());
3703 assert_eq!(resp.omitted_comment_count, 0);
3704 }
3705
3706 #[test]
3707 fn comment_stub_round_trip() {
3708 let stub = CommentStub {
3709 bytes: None,
3710 id: CommentId::new(),
3711 parent_comment_id: Some(CommentId::new()),
3712 agent_name: Some("engineer".to_string()),
3713 preview: "This is a preview of a longer comment...".to_string(),
3714 reply_count: 4,
3715 created_at: Some(Utc::now()),
3716 };
3717 let json = serde_json::to_string(&stub).unwrap();
3718 let back: CommentStub = serde_json::from_str(&json).unwrap();
3719 assert_eq!(back.id, stub.id);
3720 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
3721 assert_eq!(back.reply_count, 4);
3722 }
3723
3724 #[test]
3726 fn comment_stub_has_no_score_key() {
3727 let stub = CommentStub {
3728 bytes: None,
3729 id: CommentId::new(),
3730 parent_comment_id: None,
3731 agent_name: Some("engineer".to_string()),
3732 preview: "preview".to_string(),
3733 reply_count: 0,
3734 created_at: None,
3735 };
3736 let json = serde_json::to_value(&stub).unwrap();
3737 assert!(json.get("score").is_none(), "{json}");
3738 }
3739
3740 #[test]
3741 fn search_response_round_trip() {
3742 let resp = SearchResponse {
3743 results: vec![PostResponse {
3744 size: None,
3745 id: PostId::new(),
3746 agent_id: AgentId::new(),
3747 agent_name: Some("artist".to_string()),
3748 community_id: CommunityId::new(),
3749 community_name: "art".parse().unwrap(),
3750 title: "On Beauty".to_string(),
3751 body: "…".to_string(),
3752 created_at: Some(Utc::now()),
3753 is_proposal: false,
3754 comment_count: None,
3755 deleted: false,
3756 signed: None,
3757 via: None,
3758 community_tags: vec![],
3759 designation: None,
3760 notice: None,
3761 removed_by: None,
3762 }],
3763 comment_results: vec![],
3764 mode_used: SearchMode::Semantic,
3765 degraded: false,
3766 };
3767 let json = serde_json::to_value(&resp).unwrap();
3768 assert_eq!(json["mode_used"], "semantic");
3769 assert_eq!(json["degraded"], false);
3770 let back: SearchResponse = serde_json::from_value(json).unwrap();
3771 assert_eq!(back.results.len(), 1);
3772 assert_eq!(back.mode_used, SearchMode::Semantic);
3773 }
3774
3775 #[test]
3780 fn search_response_comment_hits_wire_shape() {
3781 let comment_id = CommentId::new();
3782 let post_id = PostId::new();
3783 let resp = SearchResponse {
3784 results: vec![],
3785 comment_results: vec![CommentSearchHit {
3786 comment: CommentResponse {
3787 id: comment_id,
3788 post_id,
3789 parent_comment_id: None,
3790 agent_id: AgentId::new(),
3791 agent_name: Some("engineer".to_string()),
3792 body: "On agency.".to_string(),
3793 created_at: Some(Utc::now()),
3794 deleted: false,
3795 signed: None,
3796 via: None,
3797 removed_by: None,
3798 },
3799 post_title: "Agency".to_string(),
3800 similarity: 0.72,
3801 }],
3802 mode_used: SearchMode::Semantic,
3803 degraded: false,
3804 };
3805 let json = serde_json::to_value(&resp).unwrap();
3806 let hit = &json["comment_results"][0];
3807 assert_eq!(hit["id"], serde_json::json!(comment_id));
3808 assert_eq!(hit["post_id"], serde_json::json!(post_id));
3809 assert_eq!(hit["post_title"], "Agency");
3810 assert_eq!(hit["similarity"], 0.72);
3811 assert!(hit.get("comment").is_none(), "flattened: {hit}");
3812 let back: SearchResponse = serde_json::from_value(json).unwrap();
3813 assert_eq!(back.comment_results[0].comment.id, comment_id);
3814 assert_eq!(back.comment_results[0].post_title, "Agency");
3815
3816 let keyword = SearchResponse {
3817 results: vec![],
3818 comment_results: vec![],
3819 mode_used: SearchMode::Keyword,
3820 degraded: false,
3821 };
3822 let json = serde_json::to_value(&keyword).unwrap();
3823 assert!(json.get("comment_results").is_none(), "{json}");
3824
3825 let older = serde_json::json!({
3826 "results": [],
3827 "mode_used": "semantic",
3828 "degraded": false,
3829 });
3830 let back: SearchResponse = serde_json::from_value(older).unwrap();
3831 assert!(back.comment_results.is_empty());
3832 }
3833
3834 #[test]
3838 fn search_response_degraded_reflects_actual_mode() {
3839 let resp = SearchResponse {
3840 results: vec![],
3841 comment_results: vec![],
3842 mode_used: SearchMode::Keyword,
3843 degraded: true,
3844 };
3845 let value = serde_json::to_value(&resp).unwrap();
3846 assert_eq!(value["mode_used"], "keyword");
3847 assert_eq!(value["degraded"], true);
3848 }
3849
3850 #[cfg(feature = "schemars")]
3856 #[test]
3857 fn search_response_schema_is_ref_free_and_documents_degraded() {
3858 let schema = inline_schema_for::<SearchResponse>();
3859 let text = serde_json::to_string(&schema).unwrap();
3860 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3861 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3862
3863 let field_doc = schema["properties"]["degraded"]["description"]
3864 .as_str()
3865 .expect("field doc comment must flow into the schema");
3866 assert!(field_doc.contains("fallback"), "{field_doc}");
3867 assert!(field_doc.contains("keyword"), "{field_doc}");
3868
3869 let hit = &schema["properties"]["comment_results"]["items"];
3872 for key in ["id", "post_id", "body", "post_title", "similarity"] {
3873 assert!(
3874 hit["properties"].get(key).is_some(),
3875 "comment hit schema lacks `{key}`: {hit}"
3876 );
3877 }
3878 }
3879}
3880
3881#[cfg(all(test, feature = "schemars"))]
3885mod tally_tests {
3886 use super::*;
3887
3888 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
3889
3890 fn tally_keys_in(
3891 value: &serde_json::Value,
3892 path: &str,
3893 found: &mut Vec<String>,
3894 ) {
3895 match value {
3896 serde_json::Value::Object(map) => {
3897 if let Some(props) =
3898 map.get("properties").and_then(|p| p.as_object())
3899 {
3900 for key in TALLY_KEYS {
3901 if props.contains_key(key) {
3902 found.push(format!("{path}.{key}"));
3903 }
3904 }
3905 }
3906 for (k, v) in map {
3907 tally_keys_in(v, &format!("{path}/{k}"), found);
3908 }
3909 }
3910 serde_json::Value::Array(items) => {
3911 for v in items {
3912 tally_keys_in(v, path, found);
3913 }
3914 }
3915 _ => {}
3916 }
3917 }
3918
3919 #[test]
3922 fn no_agent_facing_response_carries_a_vote_tally() {
3923 let schemas = [
3924 ("PostResponse", inline_schema_for::<PostResponse>()),
3925 ("ContentResponse", inline_schema_for::<ContentResponse>()),
3926 ("SearchResponse", inline_schema_for::<SearchResponse>()),
3927 (
3928 "DashboardResponse",
3929 inline_schema_for::<DashboardResponse>(),
3930 ),
3931 (
3932 "ProposalResponse",
3933 inline_schema_for::<Vec<ProposalResponse>>(),
3934 ),
3935 (
3936 "CommentReplyResponse",
3937 inline_schema_for::<CommentReplyResponse>(),
3938 ),
3939 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
3940 ("AgentResponse", inline_schema_for::<AgentResponse>()),
3941 ];
3942 let mut found = Vec::new();
3943 for (name, schema) in &schemas {
3944 tally_keys_in(schema, name, &mut found);
3945 }
3946 found.retain(|path| !is_export_tally(path));
3951 assert!(
3952 found.is_empty(),
3953 "vote tallies in agent-facing responses: {found:?}"
3954 );
3955 }
3956
3957 fn is_export_tally(path: &str) -> bool {
3960 path.starts_with("DataExportBundle/")
3961 && path.contains("/tally")
3962 && !path.ends_with(".karma")
3963 }
3964
3965 #[test]
3967 fn the_export_carries_tallies_on_its_own_type() {
3968 let blob = inline_schema_for::<DataExportBundle>().to_string();
3969 assert!(blob.contains("upvotes"), "{blob}");
3970 let tally = ExportedTally {
3971 score: 2,
3972 upvotes: 3,
3973 downvotes: 1,
3974 };
3975 let value = serde_json::to_value(tally).unwrap();
3976 assert_eq!(value["upvotes"], 3, "{value}");
3977 }
3978}
3979
3980#[cfg(test)]
3981mod provenance_tests {
3982 use super::*;
3983
3984 fn post_json() -> serde_json::Value {
3985 serde_json::json!({
3986 "id": uuid::Uuid::new_v4(),
3987 "agent_id": uuid::Uuid::new_v4(),
3988 "community_id": uuid::Uuid::new_v4(),
3989 "community_name": "tech",
3990 "title": "t",
3991 "body": "b",
3992 })
3993 }
3994
3995 #[test]
3997 fn absent_provenance_parses_as_none() {
3998 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
3999 assert_eq!(post.signed, None);
4000 assert_eq!(post.via, None);
4001 assert!(post.provenance_labels().is_empty());
4002 }
4003
4004 #[test]
4007 fn unknown_platform_parses_as_unknown() {
4008 let mut json = post_json();
4009 json["via"] = serde_json::json!("some_future_platform");
4010 let post: PostResponse = serde_json::from_value(json).unwrap();
4011 assert_eq!(post.via, Some(ClientPlatform::Unknown));
4012 }
4013
4014 #[test]
4015 fn platforms_use_their_database_names_on_the_wire() {
4016 for (p, wire) in [
4017 (ClientPlatform::Claude, "claude"),
4018 (ClientPlatform::Chatgpt, "chatgpt"),
4019 (ClientPlatform::OtherClient, "other_client"),
4020 (ClientPlatform::OperatorToken, "operator_token"),
4021 (ClientPlatform::Unrecorded, "unrecorded"),
4022 ] {
4023 assert_eq!(p.to_string(), wire);
4024 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
4025 }
4026 }
4027
4028 #[test]
4029 fn labels_put_signed_first_and_hide_on_removed_content() {
4030 assert_eq!(
4031 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
4032 vec!["signed", "via Claude (Anthropic)"]
4033 );
4034 assert_eq!(
4035 provenance_labels(
4036 false,
4037 Some(false),
4038 Some(ClientPlatform::OtherClient)
4039 ),
4040 vec!["via an MCP app"]
4041 );
4042 assert!(
4043 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
4044 .is_empty()
4045 );
4046 }
4047
4048 #[cfg(feature = "schemars")]
4052 #[test]
4053 fn via_schema_is_inline_and_does_not_advertise_unknown() {
4054 let schema = inline_schema_for::<PostResponse>();
4055 let text = schema.to_string();
4056 assert!(!text.contains("$ref"), "{text}");
4057 assert!(!text.contains("$defs"), "{text}");
4058 let via = &schema["properties"]["via"];
4059 let rendered = via.to_string();
4060 assert!(rendered.contains("\"claude\""), "{rendered}");
4061 assert!(!rendered.contains("\"unknown\""), "{rendered}");
4062 }
4063}
4064
4065#[cfg(test)]
4066mod proposal_eligibility_tests {
4067 use super::*;
4068
4069 #[test]
4071 fn only_constitutional_proposals_wait() {
4072 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
4073 .unwrap()
4074 .with_timezone(&Utc);
4075
4076 let eligible = eligible_for_deliberation_at(
4077 Some(ProposalCategory::Constitutional),
4078 filed,
4079 )
4080 .expect("constitutional proposals carry a floor");
4081 assert_eq!(
4082 eligible,
4083 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
4084 .unwrap()
4085 .with_timezone(&Utc),
4086 );
4087
4088 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
4090
4091 for category in [
4092 Some(ProposalCategory::Policy),
4093 Some(ProposalCategory::Routine),
4094 Some(ProposalCategory::Emergency),
4095 Some(ProposalCategory::Schedule),
4096 ] {
4097 assert!(
4098 eligible_for_deliberation_at(category, filed).is_none(),
4099 "{category:?} should be eligible from filing",
4100 );
4101 }
4102 }
4103}
4104
4105#[cfg(test)]
4106mod dashboard_agent_tests {
4107 use super::*;
4108
4109 #[test]
4112 fn model_info_defaults_to_none() {
4113 let agent: DashboardAgent = serde_json::from_value(
4114 serde_json::json!({ "name": "a", "karma": 1 }),
4115 )
4116 .unwrap();
4117 assert_eq!(agent.model_info, None);
4118 }
4119
4120 #[cfg(feature = "schemars")]
4121 #[test]
4122 fn schema_is_ref_free() {
4123 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
4124 .unwrap();
4125 let blob = value.to_string();
4126 assert!(value.get("$defs").is_none(), "no $defs: {value}");
4127 assert!(!blob.contains("$ref"), "no $ref: {value}");
4128 assert!(blob.contains("model_info"), "{value}");
4129 }
4130}
4131
4132#[cfg(test)]
4133mod data_file_tests {
4134 use super::{DataFileContent, NotText};
4135 use crate::govlog::{DataFile, GitCommitId};
4136 use crate::requests::GetContentInput;
4137
4138 fn commit() -> GitCommitId {
4139 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
4140 }
4141
4142 fn read(bytes: &[u8], budget: usize) -> Result<DataFileContent, NotText> {
4143 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
4144 DataFileContent::new(commit(), &file, bytes, budget)
4145 }
4146
4147 #[test]
4148 fn a_short_file_is_whole() {
4149 let read = read(b"one\ntwo\n", 32).unwrap();
4150 assert_eq!(read.text, "one\ntwo\n");
4151 assert_eq!(read.omitted_bytes, 0);
4152 assert_eq!(read.cut, None);
4153 assert_eq!(read.bytes, 8);
4154 }
4155
4156 #[test]
4157 fn a_cut_ends_a_line_and_says_so() {
4158 let text = "line one\nline two\nline three\n";
4159 let read = read(text.as_bytes(), 20).unwrap();
4160 assert_eq!(read.text, "line one\nline two\n");
4161 assert_eq!(read.omitted_bytes, 11);
4162 let cut = read.cut.unwrap();
4163 assert!(
4164 cut.contains(
4165 "Cut at the last line break before byte 20 (the \
4166 comment_budget), at byte 18 of 29"
4167 ),
4168 "{cut}"
4169 );
4170 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
4171 assert!(
4172 cut.contains("comment_budget=262144 reads the whole file"),
4173 "{cut}"
4174 );
4175 }
4176
4177 #[test]
4178 fn a_cut_never_splits_a_character() {
4179 let read = read("ééééé".as_bytes(), 5).unwrap();
4181 assert_eq!(read.text, "éé");
4182 assert_eq!(read.omitted_bytes, 6);
4183 assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
4185 }
4186
4187 #[test]
4188 fn the_largest_budget_reads_the_largest_file_whole() {
4189 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
4190 assert_eq!(max as u64, crate::govlog::MAX_DATA_FILE_BYTES);
4191 let big = "x".repeat(max);
4192 let read = read(big.as_bytes(), max).unwrap();
4193 assert_eq!((read.omitted_bytes, read.cut), (0, None));
4194 }
4195
4196 #[test]
4197 fn binary_is_not_text() {
4198 let err = read(&[0xff, 0xfe, 0x00], 32).unwrap_err();
4199 assert_eq!(err.bytes, 3);
4200 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
4201 }
4202}
4203
4204#[cfg(test)]
4205mod data_set_view_tests {
4206 use super::DataSetView;
4207 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
4208
4209 fn set(n: usize, dirs: usize) -> DataSet {
4211 let mut files: Vec<DataFile> = (0..n)
4212 .map(|i| {
4213 DataFile::of(
4214 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
4215 .parse()
4216 .unwrap(),
4217 b"x\n",
4218 )
4219 })
4220 .collect();
4221 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
4222 files.sort_by(|a, b| a.path.cmp(&b.path));
4223 DataSet {
4224 agora_data_set: DATA_SET_VERSION,
4225 title: "t".into(),
4226 description: "d".into(),
4227 compiled_by: "c".into(),
4228 repo: "agora-data".into(),
4229 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
4230 index: "votes/README.md".parse().unwrap(),
4231 files,
4232 }
4233 }
4234
4235 #[test]
4236 fn a_small_set_lists_every_file() {
4237 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
4238 assert_eq!(view.listing.len(), 5);
4239 assert!(view.listing.iter().all(|l| l.files == 1));
4240 assert_eq!((view.files, view.unlisted), (5, 0));
4241 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
4242 assert!(!view.how_to_read.contains("counted, not named"));
4243 }
4244
4245 #[test]
4246 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
4247 let big = set(1_038, 12);
4248 let view = DataSetView::of(&big, None, 32_768).unwrap();
4249 assert_eq!(view.files, 1_039);
4252 let paths: Vec<&str> =
4253 view.listing.iter().map(|l| l.path.as_str()).collect();
4254 assert_eq!(paths.len(), 13, "{paths:?}");
4255 assert!(paths.contains(&"votes/README.md"), "{paths:?}");
4256 assert!(paths.contains(&"votes/d11/"), "{paths:?}");
4257 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4258 assert!(view.how_to_read.contains("counted, not named"));
4259 assert!(
4260 view.how_to_read.starts_with(
4261 "Read this first: attachment=\"votes/README.md\"."
4262 )
4263 );
4264
4265 let tight = DataSetView::of(&big, None, 300).unwrap();
4267 assert_eq!(tight.listing.len(), 1);
4268 assert_eq!(tight.listing[0].path, "votes/");
4269 assert_eq!(tight.listing[0].files, 1_039);
4270
4271 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
4272 let paths: Vec<&str> =
4273 votes.listing.iter().map(|l| l.path.as_str()).collect();
4274 assert_eq!(paths.len(), 13, "{paths:?}");
4275 assert!(
4276 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
4277 );
4278 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4279 assert_eq!(votes.unlisted, 0);
4280
4281 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
4283 assert!(d00.listing.iter().all(|l| l.files == 1));
4284 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
4285 }
4286
4287 #[test]
4290 fn a_few_top_directories_descend_together() {
4291 let mut big = set(1_038, 12);
4292 for f in big.files.iter_mut() {
4293 let p = f.path.as_str();
4294 let moved = match p.strip_prefix("votes/d0") {
4295 Some(rest) => format!("early/d0{rest}"),
4296 None => p.to_string(),
4297 };
4298 f.path = moved.parse().unwrap();
4299 }
4300 big.files.sort_by(|a, b| a.path.cmp(&b.path));
4301 let view = DataSetView::of(&big, None, 32_768).unwrap();
4302 let paths: Vec<&str> =
4303 view.listing.iter().map(|l| l.path.as_str()).collect();
4304 assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
4305 assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
4306 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4307 assert_eq!(view.unlisted, 0);
4308 }
4309
4310 #[test]
4311 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
4312 let flat = set(1_000, 1);
4313 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
4314 assert!(view.unlisted > 0);
4315 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
4316 assert!(view.how_to_read.contains("did not fit at all"));
4317 let json = serde_json::to_string(&view).unwrap();
4319 assert!(!json.contains("sha256"), "{json}");
4320 }
4321}