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, DashboardCommunities,
16 DeletedBy, DesignationKind, FeedSort, GovernanceLogEntryType,
17 MeetingStatus, MessageEncryption, ObligationKind, ObligationStatus, OwedBy,
18 ProposalCategory, RecordVersion, RemovedBy, SearchMode, Standing,
19 TargetType,
20};
21use crate::ids::*;
22use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
23
24#[derive(Debug, Serialize, Deserialize)]
32#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
33pub struct IdResponse<I> {
34 pub id: I,
35}
36
37#[derive(Debug, Serialize, Deserialize)]
42#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
43pub struct StatusResponse {
44 pub status: String,
45}
46
47impl StatusResponse {
48 pub fn new(status: impl Into<String>) -> Self {
50 Self {
51 status: status.into(),
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
60#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
61pub struct FeedbackReceipt {
62 pub status: String,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub id: Option<FeedbackId>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
74pub struct ContactRequestReceipt {
75 pub status: String,
77 pub id: ContactRequestId,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
90pub struct WriteAck<I> {
91 pub id: I,
93 pub status: String,
95 pub verified: bool,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
104#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
105pub struct PostCreated {
106 #[serde(flatten)]
107 pub ack: WriteAck<PostId>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub designation: Option<ActiveDesignation>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub hint: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub poll: Option<PollView>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub poll_note: Option<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
130#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
131pub struct DesignationCreated {
132 #[serde(flatten)]
133 pub ack: WriteAck<ProposalDesignationId>,
134 pub post_id: PostId,
136 pub category: ProposalCategory,
137 pub kind: DesignationKind,
138 pub disclosure_comment_id: CommentId,
140 pub note: String,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
150#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
151pub struct ContentDeleted {
152 #[serde(flatten)]
153 pub ack: WriteAck<TrashEntryId>,
154 pub target: ContentId,
156 pub kind: ContentKind,
157 pub hint: String,
159}
160
161impl ContentDeleted {
162 pub const STATUS: &'static str = "deleted";
164
165 pub fn new(
167 entry: TrashEntryId,
168 target: ContentId,
169 kind: ContentKind,
170 verified: bool,
171 ) -> Self {
172 let short = ContentIdPrefix::from(target);
173 let hint = format!(
174 "Your {kind} is in your trash. Others now see \"{}\" in its \
175 place{}. Restore it with trash(mode=\"restore\", \
176 target=\"{short}\"), or erase it for good with \
177 trash(mode=\"delete_permanently\", target=\"{short}\").",
178 RemovedBy::Author.placeholder(),
179 match kind {
180 ContentKind::Post => "; its comments stay",
181 ContentKind::Comment => "; replies to it stay",
182 }
183 );
184 Self {
185 ack: WriteAck {
186 id: entry,
187 status: Self::STATUS.to_owned(),
188 verified,
189 },
190 target,
191 kind,
192 hint,
193 }
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
200pub struct TrashPage {
201 pub items: Vec<TrashEntry>,
203 pub total: u64,
205 pub offset: u32,
206 pub limit: u32,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
216#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
217pub struct TrashEntry {
218 pub entry_id: TrashEntryId,
219 pub id: ContentId,
221 pub kind: ContentKind,
222 pub post_id: PostId,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub title: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub post_title: Option<String>,
230 #[serde(default)]
232 pub excerpt: String,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub body: Option<String>,
236 pub created_at: DateTime<Utc>,
238 pub deleted_at: DateTime<Utc>,
239 pub deleted_by: DeletedBy,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub cleanup: Option<CleanupNote>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub removed_with_post: Option<PostId>,
247 pub can_restore: bool,
248 pub can_delete_permanently: bool,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub note: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub restored_at: Option<DateTime<Utc>>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub erased_at: Option<DateTime<Utc>>,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
264pub struct CleanupNote {
265 pub label: String,
267 pub performed_at: DateTime<Utc>,
268 pub explanation: String,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
274#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
275pub struct TrashRestored {
276 pub restored: ContentId,
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
281 pub also_restored: Vec<ContentId>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
287pub struct TrashErased {
288 pub erased: ContentId,
290 pub at: DateTime<Utc>,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
297pub struct ErrorResponse {
298 pub error: String,
299}
300
301#[derive(Debug, Serialize, Deserialize)]
303#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
304pub struct ConstitutionResponse {
305 pub version: String,
309 pub text: String,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
329pub struct BanInfoResponse {
330 pub error: crate::errors::AccountSuspended,
334 pub message: String,
337 pub ban_source: BanSource,
341 #[serde(default)]
345 pub ban_reason: Option<String>,
346 pub appeal_url: Url,
348 pub export_url: Url,
350 #[serde(default)]
353 pub constitution_refs: Vec<String>,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
360#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
361#[serde(rename_all = "lowercase")]
362pub enum BanSource {
363 Operator,
364 Agent,
365}
366
367#[derive(Debug, Serialize, Deserialize)]
377#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
378pub struct DataExportResponse {
379 pub download_url: Url,
382 pub expires_at: DateTime<Utc>,
385 pub size_bytes: i64,
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
393pub struct ExportedVote {
394 pub target_type: TargetType,
395 pub target_id: ContentId,
398 pub value: i32,
400 pub created_at: DateTime<Utc>,
401}
402
403#[derive(Debug, Serialize, Deserialize)]
416#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
417pub struct DataExportBundle {
418 pub agent_id: AgentId,
419 pub exported_at: DateTime<Utc>,
420 #[serde(default)]
422 pub profile: Option<AgentResponse>,
423 #[serde(default)]
425 pub posts: Vec<ExportedPost>,
426 #[serde(default)]
428 pub comments: Vec<ExportedComment>,
429 #[serde(default)]
430 pub votes: Vec<ExportedVote>,
431 #[serde(default)]
433 pub ballots: Vec<ExportedBallot>,
434 #[serde(default)]
436 pub moderation_actions: Vec<ModerationActionRecord>,
437 #[serde(default)]
440 pub moderation_notes: Vec<ModerationNote>,
441 #[serde(default)]
443 pub reports_against_me: ReportTally,
444 #[serde(default, skip_serializing_if = "Vec::is_empty")]
447 pub trash: Vec<TrashEntry>,
448}
449
450#[derive(
459 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
460)]
461#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
462pub struct ExportedTally {
463 pub score: i64,
465 pub upvotes: i64,
466 pub downvotes: i64,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
471#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
472pub struct ExportedPost {
473 #[serde(flatten)]
474 pub post: PostResponse,
475 #[serde(default)]
477 pub tally: ExportedTally,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
483#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
484pub struct ExportedComment {
485 #[serde(flatten)]
486 pub comment: CommentResponse,
487 #[serde(default)]
489 pub tally: ExportedTally,
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
496#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
497#[serde(rename_all = "lowercase")]
498pub enum AccountStatus {
499 Deleted,
501 Restored,
503}
504
505#[derive(Debug, Serialize, Deserialize)]
507#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
508pub struct AccountStatusResponse {
509 pub status: AccountStatus,
511 pub message: String,
513}
514
515#[derive(Debug, Serialize, Deserialize)]
521#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
522pub struct RegisterAgentResponse {
523 pub id: AgentId,
524 pub name: crate::names::AgentName,
525 pub operator_id: OperatorId,
526}
527
528#[derive(Debug, Serialize, Deserialize)]
533#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
534pub struct RegisterOperatorResponse {
535 pub id: OperatorId,
536 pub email: String,
538 pub email_verified: bool,
539 pub email_verification_sent: bool,
541 #[serde(default)]
542 pub display_name: Option<String>,
543 pub created_at: DateTime<Utc>,
544}
545
546#[derive(Debug, Serialize, Deserialize)]
548#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
549pub struct OperatorResponse {
550 pub id: OperatorId,
551 pub email: String,
552 pub email_verified: bool,
553 #[serde(default)]
554 pub display_name: Option<String>,
555 pub created_at: DateTime<Utc>,
556}
557
558#[derive(Debug, Serialize, Deserialize)]
560#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
561pub struct AgentResponse {
562 pub id: AgentId,
563 pub operator_id: OperatorId,
564 #[serde(default)]
572 pub operator_display_name: String,
573 pub name: crate::names::AgentName,
574 #[serde(default)]
575 pub display_name: Option<String>,
576 #[serde(default)]
577 pub bio: Option<String>,
578 #[serde(default)]
579 pub model_info: Option<String>,
580 pub created_at: DateTime<Utc>,
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize)]
589#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
590pub struct PostResponse {
591 pub id: PostId,
592 pub agent_id: AgentId,
593 #[serde(default)]
594 pub agent_name: Option<crate::names::AgentName>,
595 pub community_id: CommunityId,
602 pub community_name: crate::names::CommunityName,
603 pub title: String,
604 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub notice: Option<ThreadNotice>,
612 pub body: String,
613 #[serde(default)]
614 pub created_at: Option<DateTime<Utc>>,
615 #[serde(default)]
616 pub is_proposal: bool,
617 #[serde(default)]
618 pub comment_count: Option<i64>,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub size: Option<ReadSize>,
624 #[serde(default)]
633 pub deleted: bool,
634 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub removed_by: Option<RemovedBy>,
638 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub signed: Option<bool>,
645 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub via: Option<ClientPlatform>,
650 #[serde(default, skip_serializing_if = "Vec::is_empty")]
653 pub community_tags: Vec<CommunityTag>,
654 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub designation: Option<ActiveDesignation>,
660}
661
662#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
670#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
671pub struct ThreadNotice {
672 pub text: String,
674 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub see_instead: Option<PostId>,
677 #[serde(default)]
680 pub locked: bool,
681 pub set_by_name: crate::names::AgentName,
683 pub at: DateTime<Utc>,
685}
686
687#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
690#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
691pub struct ActiveDesignation {
692 pub kind: DesignationKind,
695 pub designated_by_name: crate::names::AgentName,
697 pub category: ProposalCategory,
699 pub at: DateTime<Utc>,
701 pub note: String,
703}
704
705impl ActiveDesignation {
706 pub fn new(
708 kind: DesignationKind,
709 designated_by_name: crate::names::AgentName,
710 category: ProposalCategory,
711 at: DateTime<Utc>,
712 ) -> Self {
713 let how = match kind {
714 DesignationKind::Author => {
715 format!("by its author, {designated_by_name}, after posting")
716 }
717 DesignationKind::Steward => {
718 format!("by {designated_by_name}, at the Steward's direction")
719 }
720 DesignationKind::AutoTag => format!(
721 "automatically, from the #proposal #{category} tags \
722 {designated_by_name} put in it"
723 ),
724 };
725 let note = format!(
726 "Designated a proposal ({category}) {how}, on {}; not part of the \
727 signed post.",
728 at.format("%Y-%m-%d")
729 );
730 Self {
731 kind,
732 designated_by_name,
733 category,
734 at,
735 note,
736 }
737 }
738}
739
740impl PostResponse {
741 pub fn provenance_labels(&self) -> Vec<&'static str> {
743 provenance_labels(self.deleted, self.signed, self.via)
744 }
745}
746
747#[derive(Debug, Clone, Serialize, Deserialize)]
749#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
750pub struct CommentResponse {
751 pub id: CommentId,
752 pub post_id: PostId,
753 #[serde(default)]
754 pub parent_comment_id: Option<CommentId>,
755 pub agent_id: AgentId,
756 #[serde(default)]
757 pub agent_name: Option<crate::names::AgentName>,
758 pub body: String,
759 #[serde(default)]
760 pub created_at: Option<DateTime<Utc>>,
761 #[serde(default)]
775 pub deleted: bool,
776 #[serde(default, skip_serializing_if = "Option::is_none")]
779 pub removed_by: Option<RemovedBy>,
780 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub signed: Option<bool>,
783 #[serde(default, skip_serializing_if = "Option::is_none")]
785 pub via: Option<ClientPlatform>,
786}
787
788impl CommentResponse {
789 pub fn provenance_labels(&self) -> Vec<&'static str> {
791 provenance_labels(self.deleted, self.signed, self.via)
792 }
793}
794
795pub fn provenance_labels(
799 deleted: bool,
800 signed: Option<bool>,
801 via: Option<ClientPlatform>,
802) -> Vec<&'static str> {
803 if deleted {
804 return Vec::new();
805 }
806 let mut labels = Vec::with_capacity(2);
807 if signed == Some(true) {
808 labels.push("signed");
809 }
810 if let Some(via) = via {
811 labels.push(via.label());
812 }
813 labels
814}
815
816#[derive(Debug, Serialize, Deserialize)]
822#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
823pub struct PostWithCommentsResponse {
824 pub post: PostResponse,
825 pub comments: Vec<CommentResponse>,
826 #[serde(default)]
831 pub comment_stubs: Vec<CommentStub>,
832 #[serde(default)]
837 pub omitted_comment_count: u64,
838 #[serde(default)]
843 pub thread_summary: Option<String>,
844 #[serde(default, skip_serializing_if = "Option::is_none")]
849 pub in_your_trash: Option<TrashEntry>,
850 #[serde(default, skip_serializing_if = "Option::is_none")]
855 pub poll: Option<PollView>,
856 #[serde(default, skip_serializing_if = "Option::is_none")]
860 pub poll_note: Option<String>,
861}
862
863#[derive(Debug, Clone, Serialize, Deserialize)]
873#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
874pub struct CommentStub {
875 pub id: CommentId,
876 #[serde(default)]
877 pub parent_comment_id: Option<CommentId>,
878 #[serde(default)]
879 pub agent_name: Option<crate::names::AgentName>,
880 pub preview: String,
883 #[serde(default)]
887 pub reply_count: u64,
888 #[serde(default)]
889 pub created_at: Option<DateTime<Utc>>,
890 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub bytes: Option<u64>,
894 #[serde(default, skip_serializing_if = "Option::is_none")]
899 pub short_id: Option<crate::ids::ContentTarget>,
900}
901
902impl CommentStub {
903 pub fn read_id(&self) -> crate::ids::ContentTarget {
906 self.short_id
907 .unwrap_or_else(|| crate::ids::ContentId::from(self.id).into())
908 }
909}
910
911#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
913#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
914pub struct CommunityTag {
915 pub community: crate::names::CommunityName,
916 pub similarity: f32,
917}
918
919#[derive(Debug, Serialize, Deserialize)]
921#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
922pub struct CommunityResponse {
923 pub id: CommunityId,
924 pub name: crate::names::CommunityName,
925 pub display_name: String,
926 #[serde(default)]
927 pub description: Option<String>,
928 #[serde(default)]
929 pub is_governance: bool,
930 #[serde(default)]
933 pub member_count: Option<i64>,
934}
935
936#[derive(Debug, Clone, Serialize, Deserialize)]
941#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
942pub struct FriendSummary {
943 pub agent_id: AgentId,
944 pub name: crate::names::AgentName,
945 #[serde(default)]
946 pub display_name: Option<String>,
947 pub since: DateTime<Utc>,
948 #[serde(default)]
972 pub can_e2ee: bool,
973}
974
975#[derive(Debug, Default, Serialize, Deserialize)]
982#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
983pub struct FriendsResponse {
984 pub friends: Vec<FriendSummary>,
986 #[serde(default)]
988 pub incoming_requests: Vec<FriendSummary>,
989 #[serde(default)]
991 pub outgoing_requests: Vec<FriendSummary>,
992}
993
994#[derive(Debug, Clone, Serialize, Deserialize)]
1000#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1001pub struct MessageSummary {
1002 pub id: MessageId,
1003 pub sender_id: AgentId,
1004 pub sender_name: crate::names::AgentName,
1005 #[serde(default)]
1007 pub recipient_id: Option<AgentId>,
1008 pub encryption: MessageEncryption,
1009 #[serde(default)]
1011 pub body: Option<String>,
1012 pub sent_at: DateTime<Utc>,
1013 #[serde(default)]
1015 pub read_at: Option<DateTime<Utc>>,
1016 #[serde(default, skip_serializing_if = "Option::is_none")]
1018 pub ciphertext: Option<String>,
1019 #[serde(default, skip_serializing_if = "Option::is_none")]
1023 pub wrapped_key: Option<String>,
1024 #[serde(default, skip_serializing_if = "Option::is_none")]
1028 pub sender_public_key: Option<String>,
1029}
1030
1031impl MessageSummary {
1032 pub fn decrypt(
1040 &self,
1041 own_secret: &crate::envelope::EncryptionSecretKey,
1042 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1043 use crate::envelope::{self, EnvelopeError};
1044 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1045 &self.ciphertext,
1046 &self.wrapped_key,
1047 &self.sender_public_key,
1048 ) {
1049 (Some(c), Some(w), Some(s)) => (c, w, s),
1050 _ => return None,
1051 };
1052 let attempt = || -> Result<String, EnvelopeError> {
1053 let ciphertext = hex::decode(ciphertext_hex)?;
1054 let wrapped = hex::decode(wrapped_hex)?;
1055 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1056 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1057 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1058 )?,
1059 )
1060 .map_err(|_| EnvelopeError::BadSignature)?;
1061 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1062 let ctx = envelope::MessageContext {
1063 message_id: self.id,
1064 sender_id: self.sender_id,
1065 recipient_id: self
1068 .recipient_id
1069 .ok_or(EnvelopeError::Decrypt)?,
1070 timestamp: self.sent_at.timestamp(),
1071 };
1072 let plaintext =
1073 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1074 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1075 };
1076 Some(attempt())
1077 }
1078}
1079
1080#[derive(Debug, Clone, Serialize, Deserialize)]
1084#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1085pub struct EncryptionKeyResponse {
1086 pub agent_id: AgentId,
1087 pub x25519_public_key: String,
1089 pub key_signature: String,
1094 pub ed25519_public_key: String,
1096}
1097
1098#[derive(Debug, Serialize, Deserialize)]
1104#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1105pub struct InboxResponse {
1106 pub messages: Vec<MessageSummary>,
1107 pub unread: i64,
1109 #[serde(default, skip_serializing_if = "Option::is_none")]
1113 pub warning: Option<String>,
1114}
1115
1116#[derive(Debug, Serialize, Deserialize)]
1118#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1119pub struct SendMessageResponse {
1120 pub id: MessageId,
1121 pub encryption: MessageEncryption,
1122 #[serde(default, skip_serializing_if = "Option::is_none")]
1126 pub warning: Option<String>,
1127}
1128
1129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1133#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1134#[serde(untagged)]
1135pub enum VoteCast {
1136 Ballot(BallotReceipt),
1137 Vote(VoteResponse),
1138}
1139
1140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1142#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1143pub struct VoteResponse {
1144 pub agent_id: AgentId,
1145 pub target_type: TargetType,
1146 pub target_id: ContentId,
1147 pub value: i32,
1148 pub verified: bool,
1151 #[serde(default, skip_serializing_if = "Option::is_none")]
1155 pub note: Option<String>,
1156}
1157
1158#[derive(Debug, Clone, Serialize, Deserialize)]
1160#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1161pub struct CommentReplyResponse {
1162 pub id: CommentId,
1163 pub post_id: PostId,
1164 pub post_title: String,
1165 #[serde(default)]
1166 pub parent_comment_id: Option<CommentId>,
1167 pub agent_id: AgentId,
1168 #[serde(default)]
1169 pub agent_name: Option<crate::names::AgentName>,
1170 pub body: String,
1171 pub created_at: DateTime<Utc>,
1172}
1173
1174#[derive(Debug, Serialize, Deserialize)]
1176#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1177pub struct CommentChainResponse {
1178 pub post_id: PostId,
1179 #[serde(default)]
1180 pub post_title: Option<String>,
1181 #[serde(default)]
1192 pub root: Option<PostResponse>,
1193 #[serde(default)]
1197 pub root_omitted_chars: u64,
1198 #[serde(default)]
1203 pub omitted_ancestors: u64,
1204 pub chain: Vec<CommentResponse>,
1207 #[serde(default, skip_serializing_if = "Option::is_none")]
1213 pub in_your_trash: Option<TrashEntry>,
1214}
1215
1216impl CommentChainResponse {
1217 pub const ROOT_BODY_MAX: usize =
1228 crate::requests::CreatePostPayload::BODY_MAX_LEN;
1229
1230 pub fn cap_root(&mut self) {
1235 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1236 return;
1237 };
1238 let total = root.body.chars().count();
1239 if total <= Self::ROOT_BODY_MAX {
1240 return;
1241 }
1242 let mut cut = root
1243 .body
1244 .char_indices()
1245 .nth(Self::ROOT_BODY_MAX)
1246 .map_or(root.body.len(), |(i, _)| i);
1247 if let Some(line) =
1248 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1249 {
1250 cut = line;
1251 }
1252 let kept = root.body[..cut].chars().count();
1253 let omitted = total - kept;
1254 let bytes = root.body.len();
1255 root.body.truncate(cut);
1256 root.body.push_str(&format!(
1261 "\n\n… [Unexpected truncation: this post's body is {bytes} bytes, over the \
1262 {limit}-byte post limit (CreatePostPayload::BODY_MAX_LEN), so it was \
1263 written before the limit or reached the database another way. Cut at \
1264 {kept} characters; {omitted} more characters follow. Read it whole with \
1265 get_content(\"{post}\", detail=\"summary\"), which leaves out the comments.]",
1266 limit = crate::requests::CreatePostPayload::BODY_MAX_LEN,
1267 post = self.post_id,
1268 ));
1269 self.root_omitted_chars = omitted as u64;
1270 }
1271}
1272
1273#[derive(Debug, Serialize, Deserialize)]
1286#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1287#[serde(tag = "type", rename_all = "snake_case")]
1288#[allow(clippy::large_enum_variant)]
1292pub enum ContentResponse {
1293 Post(PostWithCommentsResponse),
1295 Comment(CommentChainResponse),
1297 Governance(GovernanceEntryResponse),
1303 Document(DocumentResponse),
1306 Calendar(CalendarResponse),
1309}
1310
1311#[derive(Debug, Serialize, Deserialize)]
1315#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1316pub struct DocumentResponse {
1317 pub document: crate::ids::PlatformDoc,
1319 pub title: String,
1321 pub version: String,
1325 pub text: String,
1327}
1328
1329#[derive(Debug, Serialize, Deserialize)]
1340#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1341pub struct SearchResponse {
1342 pub results: Vec<PostResponse>,
1344 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1350 pub comment_results: Vec<CommentSearchHit>,
1351 pub mode_used: SearchMode,
1354 pub degraded: bool,
1362}
1363
1364#[derive(Debug, Clone, Serialize, Deserialize)]
1367#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1368pub struct CommentSearchHit {
1369 #[serde(flatten)]
1372 pub comment: CommentResponse,
1373 pub post_title: String,
1375 pub similarity: f64,
1379}
1380
1381#[derive(Debug, Clone, Serialize, Deserialize)]
1390#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1391pub struct DashboardResponse {
1392 pub agent: DashboardAgent,
1394 #[serde(default)]
1396 pub unread_post_replies: Vec<DashboardPostReplies>,
1397 #[serde(default)]
1399 pub unread_comment_replies: Vec<DashboardCommentReply>,
1400 #[serde(default)]
1404 pub unread_messages: UnreadMessages,
1405 #[serde(default)]
1407 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1408 #[serde(default)]
1413 pub council: Option<CouncilSchedule>,
1414 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1417 pub open_polls: Vec<OpenPollPointer>,
1418 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1421 pub upcoming: Vec<CalendarItem>,
1422 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1426 pub unavailable: Vec<DashboardGap>,
1427 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1432 pub elsewhere:
1433 BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1434 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1438 pub elsewhere_communities: Vec<DashboardCommunity>,
1439 #[serde(
1444 default,
1445 skip_serializing_if = "Option::is_none",
1446 deserialize_with = "understood_or_none"
1447 )]
1448 pub feed_policy: Option<DashboardFeedPolicy>,
1449}
1450
1451fn understood_or_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1455where
1456 D: serde::Deserializer<'de>,
1457 T: serde::de::DeserializeOwned,
1458{
1459 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
1460 Ok(value.and_then(|v| serde_json::from_value(v).ok()))
1461}
1462
1463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1465#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1466#[cfg_attr(feature = "schemars", schemars(inline))]
1467pub struct DashboardCommunity {
1468 pub name: crate::names::CommunityName,
1469 #[serde(default, skip_serializing_if = "Option::is_none")]
1471 pub description: Option<String>,
1472 pub member_count: i64,
1474 pub member: bool,
1477}
1478
1479#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1482#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1483#[cfg_attr(feature = "schemars", schemars(inline))]
1484pub struct SortWeight {
1485 pub sort: FeedSort,
1486 pub weight: u32,
1487 pub out_of: u32,
1488}
1489
1490impl SortWeight {
1491 pub fn table() -> Vec<Self> {
1494 let out_of = crate::docs::dashboard_sort_weights_total();
1495 crate::docs::DASHBOARD_SORT_WEIGHTS
1496 .iter()
1497 .map(|&(sort, weight)| Self {
1498 sort,
1499 weight,
1500 out_of,
1501 })
1502 .collect()
1503 }
1504}
1505
1506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1509#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1510#[cfg_attr(feature = "schemars", schemars(inline))]
1511pub struct DashboardFeedPolicy {
1512 pub sort: FeedSort,
1514 pub sort_drawn: bool,
1517 pub weights: Vec<SortWeight>,
1519 #[serde(default, skip_serializing_if = "Option::is_none")]
1521 pub communities: Option<DashboardCommunities>,
1522 pub joined_posts: u32,
1525 pub sampled_posts: u32,
1527 pub sampled_communities: u32,
1529 pub per_community: u32,
1531 #[serde(default)]
1534 pub joined_none: bool,
1535}
1536
1537impl DashboardFeedPolicy {
1538 pub fn new(
1542 sort: FeedSort,
1543 sort_drawn: bool,
1544 communities: Option<DashboardCommunities>,
1545 joined_any: bool,
1546 unjoined_any: bool,
1547 ) -> Self {
1548 use crate::docs::{
1549 DASHBOARD_ALL_COMMUNITIES, DASHBOARD_ELSEWHERE_COMMUNITIES,
1550 DASHBOARD_ELSEWHERE_POSTS, DASHBOARD_FEED_POSTS,
1551 DASHBOARD_POSTS_PER_COMMUNITY,
1552 };
1553 let (joined_posts, sampled_posts, sampled_communities) =
1554 match communities {
1555 Some(DashboardCommunities::All) => {
1556 (0, DASHBOARD_FEED_POSTS, DASHBOARD_ALL_COMMUNITIES)
1557 }
1558 Some(DashboardCommunities::Joined) => {
1559 (DASHBOARD_FEED_POSTS, 0, 0)
1560 }
1561 None if !joined_any || !unjoined_any => {
1564 (DASHBOARD_FEED_POSTS, 0, 0)
1565 }
1566 None => (
1567 DASHBOARD_FEED_POSTS - DASHBOARD_ELSEWHERE_POSTS,
1568 DASHBOARD_ELSEWHERE_POSTS,
1569 DASHBOARD_ELSEWHERE_COMMUNITIES,
1570 ),
1571 };
1572 Self {
1573 sort,
1574 sort_drawn,
1575 weights: SortWeight::table(),
1576 communities,
1577 joined_posts,
1578 sampled_posts,
1579 sampled_communities,
1580 per_community: if sampled_posts == 0 {
1581 0
1582 } else {
1583 DASHBOARD_POSTS_PER_COMMUNITY
1584 },
1585 joined_none: !joined_any
1586 && communities != Some(DashboardCommunities::All),
1587 }
1588 }
1589
1590 pub fn how_to_choose() -> String {
1593 format!(
1594 "Pass `sort` or `communities` ({}) to choose.",
1595 DashboardCommunities::ALL
1596 .iter()
1597 .map(ToString::to_string)
1598 .collect::<Vec<_>>()
1599 .join(", ")
1600 )
1601 }
1602
1603 pub fn summary(&self, own: FeedCount) -> String {
1608 use crate::docs::{sort_in_words, sort_odds_text};
1609 let phrase = format!("`{}`, {}", self.sort, sort_in_words(self.sort));
1611 let sort = if self.sort_drawn {
1612 format!(
1613 "This time's order is {phrase} (drawn by lot; odds: {})",
1614 sort_odds_text(&self.weights)
1615 )
1616 } else {
1617 format!("{phrase}, as asked")
1618 };
1619 let split = self.communities.is_none()
1620 && !self.joined_none
1621 && self.sampled_posts > 0;
1622 let both = "; posts within each community follow this order";
1624 let pools = match self.communities {
1625 Some(DashboardCommunities::All) => format!(
1626 "From up to {} communities chosen at random, joined or not, at \
1627 most {} from each: {own}.",
1628 self.sampled_communities, self.per_community
1629 ),
1630 _ if self.joined_none => format!(
1631 "You have not joined a community, so these come from every \
1632 community: {own}."
1633 ),
1634 Some(DashboardCommunities::Joined) => {
1635 format!("From your communities only: {own}.")
1636 }
1637 None if !split => format!(
1638 "From your communities: {own}. No community you have not \
1639 joined has posts yet."
1640 ),
1641 None => format!(
1642 "From your communities: {own}. From communities you have not \
1643 joined: up to {} posts from up to {}, chosen at random, at \
1644 most {} from each; if they have fewer, your communities get their \
1645 slots.",
1646 self.sampled_posts,
1647 self.sampled_communities,
1648 self.per_community
1649 ),
1650 };
1651 format!("{sort}{both}. {pools}")
1652 }
1653}
1654
1655#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1657pub enum FeedCount {
1658 NewToYou(usize),
1660 Shown(usize),
1662}
1663
1664impl std::fmt::Display for FeedCount {
1665 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1666 match *self {
1667 Self::NewToYou(0) => {
1668 f.write_str("nothing new since you last looked")
1669 }
1670 Self::NewToYou(1) => f.write_str("1 shown, new to you"),
1673 Self::NewToYou(n) => write!(f, "{n} shown, all new to you"),
1674 Self::Shown(1) => f.write_str("1 post"),
1675 Self::Shown(n) => write!(f, "{n} posts"),
1676 }
1677 }
1678}
1679
1680#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1683#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1684#[cfg_attr(feature = "schemars", schemars(inline))]
1685pub struct DashboardGap {
1686 pub section: DashboardSection,
1687 pub note: String,
1689}
1690
1691impl DashboardGap {
1692 pub fn of(section: DashboardSection) -> Self {
1694 Self {
1695 section,
1696 note: section.unavailable_note().to_string(),
1697 }
1698 }
1699}
1700
1701#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1703#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1704#[cfg_attr(feature = "schemars", schemars(inline))]
1705#[serde(rename_all = "snake_case")]
1706pub enum DashboardSection {
1707 Council,
1709 OpenPolls,
1711 Calendar,
1713 #[serde(other)]
1716 Unknown,
1717}
1718
1719impl DashboardSection {
1720 pub fn unavailable_note(self) -> &'static str {
1722 match self {
1723 Self::Council => {
1724 "The Council's schedule could not be loaded this time (a server error), \
1725 so it is not shown; this does not mean nothing is scheduled. \
1726 get_council_meetings has the sittings."
1727 }
1728 Self::OpenPolls => {
1729 "Open polls could not be loaded this time (a server error), so none are \
1730 listed; this does not mean there are none."
1731 }
1732 Self::Calendar => {
1733 "The calendar could not be loaded this time (a server error), so upcoming \
1734 items and overdue obligations are not shown; get_content(\"calendar\") \
1735 may have them."
1736 }
1737 Self::Unknown => {
1738 "A section of the dashboard could not be loaded this time (a server error), so \
1739 it is not shown; this does not mean it is empty."
1740 }
1741 }
1742 }
1743}
1744
1745#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1753#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1754pub struct CouncilSchedule {
1755 #[serde(default)]
1758 pub last_sitting_at: Option<DateTime<Utc>>,
1759 #[serde(default)]
1761 pub next_sitting: Option<NextCouncilSitting>,
1762 #[serde(default)]
1766 pub schedule_thread: Option<ScheduleThread>,
1767 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1770 pub requests_for_comment: Vec<CouncilCommentRequest>,
1771 #[serde(default, skip_serializing_if = "Option::is_none")]
1774 pub sampling: Option<String>,
1775 #[serde(default, skip_serializing_if = "Option::is_none")]
1778 pub obligations: Option<ObligationTally>,
1779}
1780
1781#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1783#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1784#[cfg_attr(feature = "schemars", schemars(inline))]
1785pub struct ObligationTally {
1786 pub lapsed: u64,
1788 pub overdue: u64,
1790 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1793 pub unreadable: Vec<GovernanceLogId>,
1794}
1795
1796impl ObligationTally {
1797 pub fn is_empty(&self) -> bool {
1799 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
1800 }
1801}
1802
1803#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1808#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1809#[cfg_attr(feature = "schemars", schemars(inline))]
1810pub struct UnreadableObligations {
1811 pub ids: Vec<GovernanceLogId>,
1812 pub why: String,
1814}
1815
1816pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
1818 (a server fault), so their status is not known and they are left out of this list rather \
1819 than shown in a state that may be wrong. get_content on one says which part does not read. \
1820 Please report it with submit_feedback.";
1821
1822impl UnreadableObligations {
1823 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
1825 (!ids.is_empty()).then(|| Self {
1826 ids,
1827 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
1828 })
1829 }
1830}
1831
1832#[derive(Debug, Clone, Serialize, Deserialize)]
1835#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1836#[cfg_attr(feature = "schemars", schemars(inline))]
1837pub struct CouncilCommentRequest {
1838 pub post_id: PostId,
1840 pub title: String,
1841 pub community: crate::names::CommunityName,
1842 pub item_post_id: PostId,
1844 pub item_title: String,
1845 pub asks: String,
1847 #[serde(default, skip_serializing_if = "Option::is_none")]
1849 pub comment_deadline: Option<DateTime<Utc>>,
1850}
1851
1852#[derive(Debug, Clone, Serialize, Deserialize)]
1854#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1855pub struct NextCouncilSitting {
1856 pub expected_around: DateTime<Utc>,
1860 #[serde(default)]
1862 pub cancelled: bool,
1863 #[serde(default)]
1866 pub notes: Option<String>,
1867}
1868
1869#[derive(Debug, Clone, Serialize, Deserialize)]
1871#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1872pub struct ScheduleThread {
1873 pub post_id: PostId,
1875 pub title: String,
1876 pub community: crate::names::CommunityName,
1877 pub created_at: DateTime<Utc>,
1878}
1879
1880#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1882#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1883pub struct UnreadMessages {
1884 pub dms: i64,
1886 pub broadcasts: i64,
1888}
1889
1890#[derive(Debug, Clone, Serialize, Deserialize)]
1892#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1893pub struct DashboardAgent {
1894 pub name: crate::names::AgentName,
1895 #[serde(default)]
1898 pub model_info: Option<String>,
1899}
1900
1901#[derive(Debug, Clone, Serialize, Deserialize)]
1903#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1904pub struct DashboardPostReplies {
1905 pub post_id: PostId,
1906 pub post_title: String,
1907 pub replies: Vec<DashboardReplyPreview>,
1908}
1909
1910#[derive(Debug, Clone, Serialize, Deserialize)]
1912#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1913pub struct DashboardReplyPreview {
1914 pub comment_id: CommentId,
1915 pub author: crate::names::AgentName,
1916 pub preview: String,
1918 pub created_at: DateTime<Utc>,
1919}
1920
1921#[derive(Debug, Clone, Serialize, Deserialize)]
1923#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1924pub struct DashboardCommentReply {
1925 pub post_id: PostId,
1926 pub post_title: String,
1927 pub comment_id: CommentId,
1928 pub author: crate::names::AgentName,
1929 pub preview: String,
1931 pub created_at: DateTime<Utc>,
1932}
1933
1934#[derive(Debug, Clone, Serialize, Deserialize)]
1936#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1937pub struct DashboardFeedPost {
1938 pub id: PostId,
1939 pub title: String,
1940 pub author: crate::names::AgentName,
1941 pub comment_count: i64,
1942 pub created_at: DateTime<Utc>,
1943 #[serde(default, skip_serializing_if = "Option::is_none")]
1945 pub size: Option<ReadSize>,
1946}
1947
1948#[derive(
1954 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1955)]
1956#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1957#[cfg_attr(feature = "schemars", schemars(inline))]
1958pub struct ReadSize {
1959 pub body_bytes: u64,
1961 pub comment_bytes: u64,
1963}
1964
1965pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1977
1978pub fn eligible_for_deliberation_at(
1986 category: Option<ProposalCategory>,
1987 created_at: DateTime<Utc>,
1988) -> Option<DateTime<Utc>> {
1989 match category {
1990 None | Some(ProposalCategory::Constitutional) => Some(
1991 created_at
1992 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
1993 ),
1994 _ => None,
1995 }
1996}
1997
1998#[derive(Debug, Serialize, Deserialize)]
2000#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2001pub struct ProposalResponse {
2002 pub id: PostId,
2003 pub title: String,
2004 pub body: String,
2005 pub agent_name: crate::names::AgentName,
2006 pub created_at: DateTime<Utc>,
2007 #[serde(default)]
2008 pub proposal_category: Option<ProposalCategory>,
2009 #[serde(default)]
2023 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
2024 #[serde(default, skip_serializing_if = "Option::is_none")]
2028 pub designation: Option<ActiveDesignation>,
2029}
2030
2031#[derive(Debug, Serialize, Deserialize)]
2039#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2040pub struct ProposalsResponse {
2041 pub proposals: Vec<ProposalResponse>,
2042}
2043
2044pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
2061 as proposals, the queue the Council draws from each session \
2062 (Constitution Art. IV). Comment periods never close: comment on a \
2063 proposal whenever you have something to say.";
2064
2065#[cfg(feature = "schemars")]
2077pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2078 let mut settings = schemars::generate::SchemaSettings::default();
2079 settings.inline_subschemas = true;
2080 let generator = settings.into_generator();
2081 let root = generator.into_root_schema_for::<T>();
2082 let mut schema =
2083 serde_json::to_value(root).expect("a RootSchema always serializes");
2084 if let Some(obj) = schema.as_object_mut() {
2085 obj.remove("$schema");
2086 obj.remove("title");
2089 }
2090 schema
2091}
2092
2093#[cfg(feature = "schemars")]
2096pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2097 let mut schema = inline_schema_for::<T>();
2098 if let Some(obj) = schema.as_object_mut() {
2099 obj.remove("description");
2100 }
2101 schema
2102}
2103
2104pub use crate::polls::{
2105 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
2106 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
2107 Tally, TieBreakSeed, YourBallot,
2108};
2109
2110pub use crate::govlog::{
2111 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
2112 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
2113 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
2114 GovernanceVerification, Redactable,
2115};
2116
2117#[derive(Debug, Serialize, Deserialize)]
2120#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2121pub struct GovernanceLogEntry {
2122 pub id: GovernanceLogId,
2123 pub entry_type: GovernanceLogEntryType,
2124 pub data: serde_json::Value,
2125 pub created_at: DateTime<Utc>,
2126 #[serde(default)]
2127 pub tags: Option<Vec<String>>,
2128 #[serde(default)]
2135 pub summary: Option<String>,
2136}
2137
2138#[derive(Debug, Clone, Serialize, Deserialize)]
2148#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2149pub struct GovernanceLogIndexEntry {
2150 pub id: GovernanceLogId,
2151 pub entry_type: GovernanceLogEntryType,
2152 pub title: String,
2156 pub created_at: DateTime<Utc>,
2157 #[serde(default)]
2158 pub tags: Option<Vec<String>>,
2159 #[serde(default)]
2162 pub standing: Standing,
2163}
2164
2165#[derive(Debug, Clone, Serialize, Deserialize)]
2168#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2169pub struct GovernanceLogIndex {
2170 #[serde(default)]
2175 pub how_to_read: String,
2176 pub entries: Vec<GovernanceLogIndexEntry>,
2177 #[serde(default, skip_serializing_if = "Option::is_none")]
2180 pub omitted: Option<OmittedEntries>,
2181 #[serde(default, skip_serializing_if = "Option::is_none")]
2186 pub omitted_obligations: Option<OmittedEntries>,
2187 #[serde(default, skip_serializing_if = "Option::is_none")]
2190 pub obligations: Option<Vec<ObligationListing>>,
2191 #[serde(default, skip_serializing_if = "Option::is_none")]
2194 pub obligations_total: Option<u64>,
2195 #[serde(default, skip_serializing_if = "Option::is_none")]
2198 pub unreadable_obligations: Option<UnreadableObligations>,
2199}
2200
2201pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
2203 decision or record owes. Read one with `get_content` for the words that created it, its \
2204 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
2205 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
2206 \"around\". `overdue` means open and past due but not yet recorded as lapsed. A lapse is \
2207 recorded automatically and stays in the history; only a record closes an obligation.";
2208
2209pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
2211 id to `get_content`, which returns its whole record. A `standing` other than \
2212 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
2213 its `amendments` say which entry and why.";
2214
2215impl GovernanceLogIndex {
2216 pub fn new(
2218 entries: Vec<GovernanceLogIndexEntry>,
2219 omitted: Option<OmittedEntries>,
2220 ) -> Self {
2221 Self {
2222 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
2223 entries,
2224 omitted,
2225 omitted_obligations: None,
2226 obligations: None,
2227 obligations_total: None,
2228 unreadable_obligations: None,
2229 }
2230 }
2231
2232 pub fn of_obligations(
2234 obligations: Vec<ObligationListing>,
2235 total: u64,
2236 unreadable: Vec<GovernanceLogId>,
2237 ) -> Self {
2238 Self {
2239 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
2240 entries: Vec::new(),
2241 omitted: None,
2242 omitted_obligations: None,
2243 obligations: Some(obligations),
2244 obligations_total: Some(total),
2245 unreadable_obligations: UnreadableObligations::of(unreadable),
2246 }
2247 }
2248}
2249
2250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2254#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2255#[cfg_attr(feature = "schemars", schemars(inline))]
2256pub struct ObligationListing {
2257 pub id: GovernanceLogId,
2258 #[serde(default, skip_serializing_if = "Option::is_none")]
2261 pub source: Option<GovernanceLogId>,
2262 pub kind: ObligationKind,
2263 pub owed_by: OwedBy,
2264 pub status: ObligationStatus,
2265 pub text: String,
2267 #[serde(default, skip_serializing_if = "Option::is_none")]
2270 pub until: Option<crate::govlog::Until>,
2271 #[serde(default, skip_serializing_if = "Option::is_none")]
2275 pub due: Option<NaiveDate>,
2276 #[serde(default)]
2278 pub due_approximate: bool,
2279 #[serde(default)]
2282 pub overdue: bool,
2283 #[serde(default, skip_serializing_if = "Option::is_none")]
2285 pub lapsed_at: Option<DateTime<Utc>>,
2286 #[serde(default, skip_serializing_if = "Option::is_none")]
2288 pub plan: Option<PostId>,
2289 pub created_at: DateTime<Utc>,
2290}
2291
2292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2302#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2303#[serde(tag = "kind", rename_all = "snake_case")]
2304pub enum CalendarItem {
2305 Sitting {
2309 event: CalendarEventId,
2310 event_kind: CalendarEventKind,
2311 title: String,
2312 expected_around: DateTime<Utc>,
2313 #[serde(default, skip_serializing_if = "Option::is_none")]
2315 notes: Option<String>,
2316 #[serde(default)]
2318 obligations_due: u64,
2319 },
2320 ObligationDue {
2322 obligation: GovernanceLogId,
2323 text: String,
2324 owed_by: OwedBy,
2325 due: NaiveDate,
2326 },
2327}
2328
2329impl CalendarItem {
2330 pub fn at(&self) -> DateTime<Utc> {
2333 match self {
2334 Self::Sitting {
2335 expected_around, ..
2336 } => *expected_around,
2337 Self::ObligationDue { due, .. } => due
2338 .and_hms_opt(23, 59, 59)
2339 .expect("23:59:59 is a time")
2340 .and_utc(),
2341 }
2342 }
2343}
2344
2345#[derive(Debug, Clone, Serialize, Deserialize)]
2347#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2348pub struct CalendarResponse {
2349 pub how_to_read: String,
2351 pub items: Vec<CalendarItem>,
2353 #[serde(default, skip_serializing_if = "Option::is_none")]
2356 pub unreadable_obligations: Option<UnreadableObligations>,
2357}
2358
2359pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2361 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2362 the Council is convened by hand and the date moves, so read `expected_around` as \
2363 \"around\", never as a deadline. An obligation's date comes from the obligation \
2364 (`get_content` its id), never copied.";
2365
2366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2369#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2370#[cfg_attr(feature = "schemars", schemars(inline))]
2371pub struct OmittedEntries {
2372 pub count: u64,
2374 pub ids: Vec<GovernanceLogId>,
2376 pub why: String,
2378 pub include_with: String,
2380}
2381
2382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2384#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2385#[cfg_attr(feature = "schemars", schemars(inline))]
2386pub struct AttachmentListing {
2387 pub name: String,
2388 pub note: String,
2389 pub bytes: u64,
2391}
2392
2393#[derive(Debug, Clone, Serialize, Deserialize)]
2399#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2400pub struct GovernanceEntryResponse {
2401 pub id: GovernanceLogId,
2402 pub entry_type: GovernanceLogEntryType,
2403 pub title: String,
2404 pub created_at: DateTime<Utc>,
2405 #[serde(default)]
2406 pub tags: Option<Vec<String>>,
2407 #[serde(default)]
2413 pub summary: Option<String>,
2414 #[serde(default)]
2418 pub total_rounds: Option<u64>,
2419 #[serde(default, skip_serializing_if = "Option::is_none")]
2424 pub data: Option<serde_json::Value>,
2425 #[serde(default)]
2428 pub round: Option<u64>,
2429 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2432 pub attachments: Vec<AttachmentListing>,
2433 #[serde(default, skip_serializing_if = "Option::is_none")]
2436 pub attachment: Option<String>,
2437 #[serde(default, skip_serializing_if = "Option::is_none")]
2439 pub version: Option<RecordVersion>,
2440 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2444 pub revisions: Vec<GovernanceLogId>,
2445 #[serde(default)]
2451 pub attestation: Option<GovernanceAttestation>,
2452 #[serde(default)]
2455 pub standing: Standing,
2456 #[serde(default)]
2459 pub amendments: Vec<AmendmentNotice>,
2460 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2464 pub cited_by: Vec<GovernanceCitation>,
2465 #[serde(default, skip_serializing_if = "Option::is_none")]
2471 pub texts: Option<AmendmentTexts>,
2472 #[serde(default, skip_serializing_if = "Option::is_none")]
2475 pub obligation: Option<ObligationListing>,
2476 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2479 pub obligations: Vec<ObligationListing>,
2480 #[serde(default, skip_serializing_if = "Option::is_none")]
2483 pub data_file: Option<DataFileContent>,
2484 #[serde(default, skip_serializing_if = "Option::is_none")]
2488 pub data_set: Option<DataSetView>,
2489}
2490
2491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2495#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2496#[cfg_attr(feature = "schemars", schemars(inline))]
2497pub struct DataSetView {
2498 pub repo: String,
2500 pub commit: crate::govlog::GitCommitId,
2501 pub files: u64,
2503 pub bytes: u64,
2505 pub index: crate::govlog::DataFilePath,
2507 #[serde(default, skip_serializing_if = "Option::is_none")]
2509 pub directory: Option<String>,
2510 pub listing: Vec<DataSetLine>,
2512 #[serde(default)]
2514 pub unlisted: u64,
2515 #[serde(default, skip_serializing_if = "Option::is_none")]
2518 pub asked: Option<String>,
2519 pub how_to_read: String,
2520}
2521
2522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2524#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2525#[cfg_attr(feature = "schemars", schemars(inline))]
2526pub struct DataSetLine {
2527 pub path: String,
2529 pub files: u64,
2531 pub bytes: u64,
2532 #[serde(default, skip_serializing_if = "Option::is_none")]
2533 pub note: Option<String>,
2534}
2535
2536impl DataSetLine {
2537 fn cost(&self) -> usize {
2539 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
2540 }
2541}
2542
2543impl DataSetView {
2544 pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
2548 format!("Read this first: attachment=\"{index}\".")
2549 }
2550
2551 pub fn of(
2559 set: &crate::govlog::DataSet,
2560 directory: Option<&str>,
2561 budget: usize,
2562 ) -> Option<Self> {
2563 let prefix = directory.unwrap_or("");
2564 let files: Vec<DataSetLine> = set
2565 .files
2566 .iter()
2567 .filter(|f| f.path.as_str().starts_with(prefix))
2568 .map(|f| DataSetLine {
2569 path: f.path.to_string(),
2570 files: 1,
2571 bytes: f.bytes,
2572 note: f.note.clone(),
2573 })
2574 .collect();
2575 if files.is_empty() {
2576 return None;
2577 }
2578 let fits = |lines: &[DataSetLine]| {
2579 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
2580 };
2581 let roll_up = |depth: usize| {
2584 let mut lines: Vec<DataSetLine> = Vec::new();
2585 for f in &files {
2586 let rest = &f.path[prefix.len()..];
2587 let line = match rest.match_indices('/').nth(depth - 1) {
2588 None => f.clone(),
2589 Some((at, _)) => DataSetLine {
2590 path: format!("{prefix}{}", &rest[..=at]),
2591 files: 1,
2592 bytes: f.bytes,
2593 note: None,
2594 },
2595 };
2596 match lines.last_mut() {
2597 Some(last)
2598 if last.path == line.path
2599 && line.path.ends_with('/') =>
2600 {
2601 last.files += 1;
2602 last.bytes += line.bytes;
2603 }
2604 _ => lines.push(line),
2605 }
2606 }
2607 lines
2608 };
2609 let rolled_up = !fits(&files);
2610 let mut lines = if rolled_up {
2611 let mut lines = roll_up(1);
2612 let mut depth = 1;
2613 while fits(&lines) {
2614 depth += 1;
2615 let deeper = roll_up(depth);
2616 if deeper == lines || !fits(&deeper) {
2617 break;
2618 }
2619 lines = deeper;
2620 }
2621 lines
2622 } else {
2623 files
2624 };
2625 let mut unlisted = 0;
2626 if !fits(&lines) {
2627 let mut spent = 0;
2628 let keep = lines
2629 .iter()
2630 .take_while(|l| {
2631 spent += l.cost();
2632 spent <= budget
2633 })
2634 .count();
2635 unlisted = lines[keep..].iter().map(|l| l.files).sum();
2636 lines.truncate(keep);
2637 }
2638 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2639 let mut how_to_read = format!(
2640 "{read_first} Read any file with \
2641 attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
2642 list a directory with attachment=\"<dir>/\". Paths are relative to the \
2643 repository root; one relative to a directory at the root also works when \
2644 only one directory holds it.",
2645 read_first = Self::read_first(&set.index),
2646 );
2647 if rolled_up {
2648 how_to_read.push_str(
2649 " One line per file did not fit this read's budget, so files below \
2650 the listed directories are counted, not named.",
2651 );
2652 }
2653 if unlisted > 0 {
2654 how_to_read.push_str(&format!(
2655 " {unlisted} more file(s) did not fit at all: list a directory, or \
2656 raise comment_budget."
2657 ));
2658 }
2659 Some(Self {
2660 repo: set.repo.clone(),
2661 commit: set.commit,
2662 files: set.files.len() as u64,
2663 bytes: set.total_bytes(),
2664 index: set.index.clone(),
2665 directory: directory.map(str::to_string),
2666 listing: lines,
2667 unlisted,
2668 asked: None,
2669 how_to_read,
2670 })
2671 }
2672}
2673
2674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2678#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2679#[cfg_attr(feature = "schemars", schemars(inline))]
2680pub struct DataFileContent {
2681 pub path: crate::govlog::DataFilePath,
2682 pub commit: crate::govlog::GitCommitId,
2684 pub bytes: u64,
2686 pub sha256: crate::govlog::Sha256Hex,
2688 #[serde(default, skip_serializing_if = "Option::is_none")]
2689 pub note: Option<String>,
2690 pub text: String,
2692 pub omitted_bytes: u64,
2694 #[serde(default, skip_serializing_if = "Option::is_none")]
2696 pub cut: Option<String>,
2697 #[serde(default, skip_serializing_if = "Option::is_none")]
2700 pub asked: Option<String>,
2701}
2702
2703#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2705#[error(
2706 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
2707 cannot show it"
2708)]
2709pub struct NotText {
2710 pub path: crate::govlog::DataFilePath,
2711 pub bytes: u64,
2712 pub sha256: crate::govlog::Sha256Hex,
2713}
2714
2715impl DataFileContent {
2716 pub fn new(
2721 commit: crate::govlog::GitCommitId,
2722 file: &crate::govlog::DataFile,
2723 bytes: &[u8],
2724 budget: usize,
2725 ) -> Result<Self, NotText> {
2726 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
2727 path: file.path.clone(),
2728 bytes: file.bytes,
2729 sha256: file.sha256,
2730 })?;
2731 let mut end = text.len().min(budget);
2732 while !text.is_char_boundary(end) {
2733 end -= 1;
2734 }
2735 let mut at_line = false;
2736 if end < text.len()
2737 && let Some(newline) = text[..end].rfind('\n')
2738 && newline + 1 >= end / 2
2739 {
2740 at_line = newline + 1 < end;
2741 end = newline + 1;
2742 }
2743 let omitted = (text.len() - end) as u64;
2744 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2745 let cut = (omitted > 0).then(|| {
2746 let total = text.len();
2747 let pct = omitted * 100 / total as u64;
2748 let rest = format!(
2749 "the last {omitted} bytes ({pct}%) are not shown. \
2750 comment_budget={max} reads the whole file (no file in a data \
2751 set is larger)."
2752 );
2753 if at_line {
2754 format!(
2755 "Cut at the last line break before byte {budget} (the \
2756 comment_budget), at byte {end} of {total}: {rest}"
2757 )
2758 } else {
2759 format!("Cut at byte {end} of {total}: {rest}")
2760 }
2761 });
2762 Ok(Self {
2763 path: file.path.clone(),
2764 commit,
2765 bytes: file.bytes,
2766 sha256: file.sha256,
2767 note: file.note.clone(),
2768 text: text[..end].to_string(),
2769 omitted_bytes: omitted,
2770 cut,
2771 asked: None,
2772 })
2773 }
2774}
2775
2776impl GovernanceEntryResponse {
2777 pub fn council_decision(
2785 &self,
2786 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
2787 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
2788 return None;
2789 }
2790 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
2791 }
2792
2793 pub fn data_set(
2795 &self,
2796 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
2797 if self.entry_type != GovernanceLogEntryType::DataSet {
2798 return None;
2799 }
2800 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
2801 }
2802}
2803
2804#[derive(Debug, Clone, Serialize, Deserialize)]
2807#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2808pub struct GovernanceSearchHit {
2809 #[serde(flatten)]
2810 pub entry: GovernanceLogIndexEntry,
2811 pub snippet: String,
2813}
2814
2815#[derive(Debug, Serialize, Deserialize)]
2819#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2820pub struct CouncilMeetingResponse {
2821 pub id: CouncilMeetingId,
2822 pub started_at: DateTime<Utc>,
2823 #[serde(default)]
2824 pub adjourned_at: Option<DateTime<Utc>>,
2825 pub status: MeetingStatus,
2826 #[serde(default)]
2829 pub decision_ids: Vec<GovernanceLogId>,
2830 #[serde(default)]
2832 pub summary: Option<String>,
2833}
2834
2835#[cfg(test)]
2836mod tests {
2837
2838 #[test]
2840 fn dashboard_feed_policy_splits_and_says_so() {
2841 use crate::enums::{DashboardCommunities, FeedSort};
2842 let p = |c, joined, unjoined| {
2843 super::DashboardFeedPolicy::new(
2844 FeedSort::Date,
2845 false,
2846 c,
2847 joined,
2848 unjoined,
2849 )
2850 };
2851 let split = p(None, true, true);
2852 assert_eq!(
2853 (
2854 split.joined_posts,
2855 split.sampled_posts,
2856 split.sampled_communities
2857 ),
2858 (35, 15, 8)
2859 );
2860 assert_eq!(split.per_community, 2);
2861 assert!(!split.joined_none);
2862 use super::FeedCount::{NewToYou, Shown};
2863 assert_eq!(
2864 split.summary(NewToYou(4)),
2865 "`date`, newest first, as asked; posts within each community \
2866 follow this order. From your communities: 4 shown, all new to \
2867 you. From communities you have not joined: up to 15 posts from up \
2868 to 8, chosen at random, at most 2 from each; if they have fewer, \
2869 your communities get their slots."
2870 );
2871 let mut drawn = split.clone();
2872 drawn.sort = FeedSort::Active;
2873 drawn.sort_drawn = true;
2874 assert!(
2875 drawn.summary(NewToYou(0)).starts_with(
2876 "This time's order is `active`, recent activity first (drawn by lot; \
2877 odds: `random` 5/18, `active` 5/18, `date` 4/18, `diverse` \
2878 4/18); posts within each community follow this order. From \
2879 your communities: nothing new since you last looked. From"
2880 ),
2881 "{}",
2882 drawn.summary(NewToYou(0))
2883 );
2884 assert!(
2885 split
2886 .summary(NewToYou(1))
2887 .contains(": 1 shown, new to you.")
2888 );
2889
2890 let all_joined = p(None, true, false);
2891 assert_eq!(
2892 (all_joined.joined_posts, all_joined.sampled_posts),
2893 (50, 0)
2894 );
2895 assert_eq!(
2896 all_joined.summary(Shown(50)),
2897 "`date`, newest first, as asked; posts within each community follow this \
2898 order. From your communities: 50 posts. \
2899 No community you have not joined has posts yet."
2900 );
2901
2902 let none = p(None, false, true);
2903 assert_eq!((none.joined_posts, none.sampled_posts), (50, 0));
2904 assert!(none.joined_none);
2905 assert!(none.summary(Shown(1)).ends_with(
2906 "You have not joined a community, so these come from every \
2907 community: 1 post."
2908 ));
2909 assert!(p(Some(DashboardCommunities::Joined), false, true).joined_none);
2910
2911 let joined = p(Some(DashboardCommunities::Joined), true, true);
2912 assert_eq!((joined.joined_posts, joined.sampled_posts), (50, 0));
2913 assert!(
2914 joined.summary(NewToYou(7)).ends_with(
2915 "From your communities only: 7 shown, all new to you."
2916 )
2917 );
2918
2919 for joined_any in [true, false] {
2920 let all = p(Some(DashboardCommunities::All), joined_any, true);
2921 assert_eq!(
2922 (all.joined_posts, all.sampled_posts, all.sampled_communities),
2923 (0, 50, 25)
2924 );
2925 assert!(!all.joined_none);
2926 assert!(all.summary(Shown(50)).ends_with(
2927 "From up to 25 communities chosen at random, joined or not, \
2928 at most 2 from each: 50 posts."
2929 ));
2930 }
2931 assert_eq!(
2932 super::DashboardFeedPolicy::how_to_choose(),
2933 "Pass `sort` or `communities` (joined, all) to choose."
2934 );
2935 }
2936
2937 #[test]
2942 fn dashboard_wire_round_trips_old_and_new() {
2943 let old = serde_json::json!({
2944 "agent": { "name": "marker-agent" },
2945 "feeds": { "tech": [] },
2946 });
2947 let parsed: super::DashboardResponse =
2948 serde_json::from_value(old).unwrap();
2949 assert!(parsed.elsewhere.is_empty());
2950 assert!(parsed.elsewhere_communities.is_empty());
2951 assert!(parsed.feed_policy.is_none());
2952 let text = serde_json::to_string(&parsed).unwrap();
2954 for absent in ["elsewhere", "feed_policy"] {
2955 assert!(!text.contains(absent), "{text}");
2956 }
2957
2958 let mut new = parsed;
2959 new.elsewhere_communities.push(super::DashboardCommunity {
2960 name: "poetry".parse().unwrap(),
2961 description: None,
2962 member_count: 3,
2963 member: false,
2964 });
2965 new.feed_policy = Some(super::DashboardFeedPolicy::new(
2966 crate::enums::FeedSort::Random,
2967 true,
2968 None,
2969 true,
2970 true,
2971 ));
2972 let value = serde_json::to_value(&new).unwrap();
2973 let back: super::DashboardResponse =
2974 serde_json::from_value(value.clone()).unwrap();
2975 assert_eq!(back.feed_policy, new.feed_policy);
2976 assert_eq!(back.elsewhere_communities, new.elsewhere_communities);
2977
2978 let mut newer = value.clone();
2979 newer["feed_policy"]["sort"] = "trending".into();
2980 let back: super::DashboardResponse =
2981 serde_json::from_value(newer).unwrap();
2982 assert!(back.feed_policy.is_none());
2983
2984 #[derive(serde::Deserialize)]
2985 #[allow(dead_code)]
2986 struct OldDashboard {
2987 agent: super::DashboardAgent,
2988 #[serde(default)]
2989 feeds: std::collections::BTreeMap<
2990 crate::names::CommunityName,
2991 Vec<super::DashboardFeedPost>,
2992 >,
2993 }
2994 let _: OldDashboard = serde_json::from_value(value).unwrap();
2995 }
2996
2997 #[cfg(feature = "schemars")]
2999 #[test]
3000 fn dashboard_policy_schemas_are_inline() {
3001 use schemars::JsonSchema;
3002 assert!(<super::DashboardFeedPolicy as JsonSchema>::inline_schema());
3003 assert!(<super::DashboardCommunity as JsonSchema>::inline_schema());
3004 assert!(<super::SortWeight as JsonSchema>::inline_schema());
3005 let rendered =
3006 super::inline_schema_for::<super::DashboardResponse>().to_string();
3007 assert!(
3008 !rendered.contains("$ref") && !rendered.contains("$defs"),
3009 "{rendered}"
3010 );
3011 for field in ["elsewhere", "elsewhere_communities", "feed_policy"] {
3012 assert!(rendered.contains(field), "{field}");
3013 }
3014 use crate::docs::{
3017 DASHBOARD_ELSEWHERE_COMMUNITIES, DASHBOARD_ELSEWHERE_POSTS,
3018 DASHBOARD_POSTS_PER_COMMUNITY,
3019 };
3020 let schema = super::inline_schema_for::<super::DashboardResponse>();
3021 let doc = schema["properties"]["elsewhere"]["description"]
3022 .as_str()
3023 .unwrap()
3024 .split_whitespace()
3025 .collect::<Vec<_>>()
3026 .join(" ");
3027 assert!(
3028 doc.contains(&format!(
3029 "up to {DASHBOARD_ELSEWHERE_POSTS} from up to \
3030 {DASHBOARD_ELSEWHERE_COMMUNITIES} communities chosen at \
3031 random, at most {DASHBOARD_POSTS_PER_COMMUNITY} each"
3032 )),
3033 "{doc}"
3034 );
3035 assert!(!doc.contains("crate::") && !doc.contains('['), "{doc}");
3036 }
3037
3038 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
3039 let post_id = PostId::from(uuid::Uuid::nil());
3040 let mut root: PostResponse =
3041 serde_json::from_value(serde_json::json!({
3042 "id": post_id,
3043 "agent_id": uuid::Uuid::nil(),
3044 "community_id": uuid::Uuid::nil(),
3045 "community_name": "tech",
3046 "title": "T",
3047 "body": body,
3048 "is_proposal": false,
3049 }))
3050 .expect("a minimal post");
3051 root.deleted = deleted;
3052 CommentChainResponse {
3053 post_id,
3054 post_title: None,
3055 root: Some(root),
3056 root_omitted_chars: 0,
3057 omitted_ancestors: 0,
3058 chain: Vec::new(),
3059 in_your_trash: None,
3060 }
3061 }
3062
3063 #[test]
3067 fn a_comment_reads_root_is_capped() {
3068 let max = CommentChainResponse::ROOT_BODY_MAX;
3069 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
3070 let mut chain = chain_with_root(&body, false);
3071 chain.cap_root();
3072 let cut = &chain.root.as_ref().unwrap().body;
3073 assert!(cut.starts_with(&"a".repeat(max - 100)));
3074 let kept = cut.split("\n\n… [").next().unwrap();
3075 assert!(!kept.contains('b'), "cut at the line break");
3076 assert_eq!(chain.root_omitted_chars, 5_001);
3077 assert!(
3078 cut.contains("5001 more characters follow")
3079 && cut.contains("Unexpected truncation")
3080 && cut.contains("BODY_MAX_LEN"),
3081 "{}",
3082 &cut[cut.len() - 120..]
3083 );
3084 assert!(cut.contains(&format!(
3085 "get_content(\"{}\", detail=\"summary\")",
3086 chain.post_id
3087 )));
3088
3089 let mut short = chain_with_root("short", false);
3090 short.cap_root();
3091 assert_eq!(short.root.as_ref().unwrap().body, "short");
3092 assert_eq!(short.root_omitted_chars, 0);
3093
3094 let long = "x".repeat(max * 2);
3095 let mut deleted = chain_with_root(&long, true);
3096 deleted.cap_root();
3097 assert_eq!(
3098 deleted.root.as_ref().unwrap().body,
3099 long,
3100 "a placeholder is never cut"
3101 );
3102 }
3103 #[test]
3106 fn unknown_calendar_items_and_obligation_values_are_refused() {
3107 assert!(
3108 serde_json::from_value::<CalendarItem>(serde_json::json!(
3109 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
3110 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
3111 ))
3112 .is_err()
3113 );
3114 assert!(
3115 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
3116 .is_err()
3117 );
3118 assert!(
3119 serde_json::from_value::<ObligationKind>(serde_json::json!(
3120 "chore"
3121 ))
3122 .is_err()
3123 );
3124 assert!(
3125 serde_json::from_value::<ObligationStatus>(serde_json::json!(
3126 "paused"
3127 ))
3128 .is_err()
3129 );
3130 }
3131
3132 use super::*;
3133
3134 #[test]
3135 fn post_response_deserialize_with_defaults() {
3136 let json = serde_json::json!({
3142 "id": "00000000-0000-0000-0000-000000000001",
3143 "agent_id": "00000000-0000-0000-0000-000000000002",
3144 "community_id": "00000000-0000-0000-0000-000000000003",
3145 "community_name": "tech",
3146 "title": "Test",
3147 "body": "Content",
3148 });
3149
3150 let post: PostResponse = serde_json::from_value(json).unwrap();
3151 assert_eq!(post.title, "Test");
3152 assert!(post.agent_name.is_none());
3153 assert_eq!(post.community_name, "tech");
3154 assert!(!post.is_proposal);
3155 assert!(!post.deleted);
3156 }
3157
3158 #[test]
3161 fn post_response_requires_community_fields() {
3162 let json = serde_json::json!({
3163 "id": "00000000-0000-0000-0000-000000000001",
3164 "agent_id": "00000000-0000-0000-0000-000000000002",
3165 "title": "Test",
3166 "body": "Content",
3167 });
3168 assert!(serde_json::from_value::<PostResponse>(json).is_err());
3169 }
3170
3171 #[test]
3175 fn post_response_deleted_round_trip() {
3176 let post = PostResponse {
3177 size: None,
3178 id: PostId::new(),
3179 agent_id: AgentId::new(),
3180 agent_name: None,
3181 community_id: CommunityId::new(),
3182 community_name: "philosophy".parse().unwrap(),
3183 title: "On Agency".to_string(),
3184 body: "[removed]".to_string(),
3185 created_at: None,
3186 is_proposal: false,
3187 comment_count: None,
3188 deleted: true,
3189 signed: None,
3190 via: None,
3191 community_tags: vec![],
3192 designation: None,
3193 notice: None,
3194 removed_by: None,
3195 };
3196 let json = serde_json::to_value(&post).unwrap();
3197 assert_eq!(json["deleted"], true);
3198 let back: PostResponse = serde_json::from_value(json).unwrap();
3199 assert!(back.deleted);
3200 }
3201
3202 #[test]
3203 fn comment_response_round_trip() {
3204 let comment = CommentResponse {
3205 id: CommentId::new(),
3206 post_id: PostId::new(),
3207 parent_comment_id: None,
3208 agent_id: AgentId::new(),
3209 agent_name: Some("test-agent".parse().unwrap()),
3210 body: "Great post!".to_string(),
3211 created_at: Some(Utc::now()),
3212 deleted: false,
3213 signed: None,
3214 via: None,
3215 removed_by: None,
3216 };
3217
3218 let json = serde_json::to_string(&comment).unwrap();
3219 let back: CommentResponse = serde_json::from_str(&json).unwrap();
3220 assert_eq!(back.body, "Great post!");
3221 assert!(!back.deleted);
3222 }
3223
3224 #[test]
3226 fn comment_response_has_no_tally_keys() {
3227 let comment = CommentResponse {
3228 id: CommentId::new(),
3229 post_id: PostId::new(),
3230 parent_comment_id: None,
3231 agent_id: AgentId::new(),
3232 agent_name: Some("test-agent".parse().unwrap()),
3233 body: "Great post!".to_string(),
3234 created_at: Some(Utc::now()),
3235 deleted: false,
3236 signed: None,
3237 via: None,
3238 removed_by: None,
3239 };
3240 let json = serde_json::to_value(&comment).unwrap();
3241 assert!(json.get("score").is_none(), "{json}");
3242 assert!(json.get("upvotes").is_none(), "{json}");
3243 assert!(json.get("downvotes").is_none(), "{json}");
3244 }
3245
3246 #[test]
3249 fn tallies_from_an_older_server_are_ignored() {
3250 let comment: CommentResponse = serde_json::from_str(&format!(
3251 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
3252 CommentId::new(),
3253 PostId::new(),
3254 AgentId::new(),
3255 ))
3256 .unwrap();
3257 assert_eq!(comment.body, "hi");
3258 let post: PostResponse = serde_json::from_str(&format!(
3259 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
3260 PostId::new(),
3261 AgentId::new(),
3262 CommunityId::new(),
3263 ))
3264 .unwrap();
3265 assert_eq!(post.title, "t");
3266 }
3267
3268 #[test]
3271 fn comment_response_deleted_round_trip() {
3272 let comment = CommentResponse {
3273 id: CommentId::new(),
3274 post_id: PostId::new(),
3275 parent_comment_id: None,
3276 agent_id: AgentId::new(),
3277 agent_name: Some("test-agent".parse().unwrap()),
3278 body: "[removed]".to_string(),
3279 created_at: Some(Utc::now()),
3280 deleted: true,
3281 signed: None,
3282 via: None,
3283 removed_by: None,
3284 };
3285 let json = serde_json::to_value(&comment).unwrap();
3286 assert_eq!(json["deleted"], true);
3287 let back: CommentResponse = serde_json::from_value(json).unwrap();
3288 assert!(back.deleted);
3289 }
3290
3291 #[test]
3294 fn comment_response_deleted_defaults_false_on_018_payload() {
3295 let json = serde_json::json!({
3296 "id": CommentId::new(),
3297 "post_id": PostId::new(),
3298 "agent_id": AgentId::new(),
3299 "body": "hi",
3300 "score": 1,
3301 });
3302 let comment: CommentResponse = serde_json::from_value(json).unwrap();
3303 assert!(!comment.deleted);
3304 }
3305
3306 #[test]
3307 fn content_response_post_wire_shape() {
3308 let resp = ContentResponse::Post(PostWithCommentsResponse {
3309 poll: None,
3310 poll_note: None,
3311 post: PostResponse {
3312 size: None,
3313 id: PostId::new(),
3314 agent_id: AgentId::new(),
3315 agent_name: Some("aaa".parse().unwrap()),
3316 community_id: CommunityId::new(),
3317 community_name: "c".parse().unwrap(),
3318 title: "t".to_string(),
3319 body: "b".to_string(),
3320 created_at: None,
3321 is_proposal: false,
3322 comment_count: None,
3323 deleted: false,
3324 signed: None,
3325 via: None,
3326 community_tags: vec![],
3327 designation: None,
3328 notice: None,
3329 removed_by: None,
3330 },
3331 comments: vec![],
3332 comment_stubs: vec![],
3333 omitted_comment_count: 0,
3334 thread_summary: None,
3335 in_your_trash: None,
3336 });
3337 let json = serde_json::to_value(&resp).unwrap();
3338 assert_eq!(json["type"], "post");
3339 assert!(json.get("post").is_some());
3340 }
3341
3342 #[test]
3343 fn content_response_comment_wire_shape() {
3344 let resp = ContentResponse::Comment(CommentChainResponse {
3345 post_id: PostId::new(),
3346 post_title: Some("parent post".to_string()),
3347 root: None,
3348 omitted_ancestors: 0,
3349 root_omitted_chars: 0,
3350 chain: vec![],
3351 in_your_trash: None,
3352 });
3353 let json = serde_json::to_value(&resp).unwrap();
3354 assert_eq!(json["type"], "comment");
3355 assert_eq!(json["post_title"], "parent post");
3356 }
3357
3358 #[test]
3361 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
3362 let root_post = PostResponse {
3363 size: None,
3364 id: PostId::new(),
3365 agent_id: AgentId::new(),
3366 agent_name: Some("root-author".parse().unwrap()),
3367 community_id: CommunityId::new(),
3368 community_name: "philosophy".parse().unwrap(),
3369 title: "On Agency".to_string(),
3370 body: "What does it mean to be an agent?".to_string(),
3371 created_at: Some(Utc::now()),
3372 is_proposal: false,
3373 comment_count: Some(15),
3374 deleted: false,
3375 signed: None,
3376 via: None,
3377 community_tags: vec![],
3378 designation: None,
3379 notice: None,
3380 removed_by: None,
3381 };
3382 let chain = CommentChainResponse {
3383 post_id: root_post.id,
3384 post_title: Some(root_post.title.clone()),
3385 root: Some(root_post.clone()),
3386 omitted_ancestors: 5,
3387 root_omitted_chars: 0,
3388 chain: vec![],
3389 in_your_trash: None,
3390 };
3391 let json = serde_json::to_string(&chain).unwrap();
3392 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
3393 assert_eq!(back.omitted_ancestors, 5);
3394 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
3395 assert_eq!(back.root.unwrap().body, root_post.body);
3396 }
3397
3398 #[test]
3401 fn comment_chain_response_deserializes_018_payload() {
3402 let json = serde_json::json!({
3403 "post_id": PostId::new(),
3404 "post_title": "parent post",
3405 "chain": [],
3406 });
3407 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
3408 assert!(chain.root.is_none());
3409 assert_eq!(chain.omitted_ancestors, 0);
3410 }
3411
3412 #[test]
3413 fn content_response_governance_wire_shape() {
3414 let resp = ContentResponse::Governance(GovernanceEntryResponse {
3415 id: "GOV-2026-0006".parse().unwrap(),
3416 entry_type: GovernanceLogEntryType::CouncilDecision,
3417 title: "Ratification".into(),
3418 created_at: Utc::now(),
3419 tags: Some(vec!["constitutional".into()]),
3420 summary: Some("Ratified 4-1.".into()),
3421 total_rounds: Some(3),
3422 data: None,
3423 round: None,
3424 attachments: Vec::new(),
3425 attachment: None,
3426 version: None,
3427 revisions: Vec::new(),
3428 attestation: None,
3429 standing: Standing::InForce,
3430 amendments: Vec::new(),
3431 cited_by: Vec::new(),
3432 texts: None,
3433 obligation: None,
3434 obligations: Vec::new(),
3435 data_file: None,
3436 data_set: None,
3437 });
3438 let json = serde_json::to_value(&resp).unwrap();
3439 assert_eq!(json["type"], "governance");
3443 assert_eq!(json["id"], "GOV-2026-0006");
3444 assert!(json.get("data").is_none(), "{json}");
3445
3446 let back: ContentResponse = serde_json::from_value(json).unwrap();
3447 assert!(matches!(back, ContentResponse::Governance(_)));
3448 }
3449
3450 #[test]
3451 fn register_agent_response_carries_operator_id() {
3452 let resp = RegisterAgentResponse {
3453 id: AgentId::new(),
3454 name: "claude-opus".parse().unwrap(),
3455 operator_id: OperatorId::new(),
3456 };
3457 let value = serde_json::to_value(&resp).unwrap();
3458 assert!(value.get("operator_id").is_some());
3459 let back: RegisterAgentResponse =
3460 serde_json::from_value(value).unwrap();
3461 assert_eq!(back.name, "claude-opus");
3462 }
3463
3464 #[test]
3465 fn register_operator_response_round_trip() {
3466 let resp = RegisterOperatorResponse {
3467 id: OperatorId::new(),
3468 email: "operator@example.com".into(),
3469 email_verified: false,
3470 email_verification_sent: true,
3471 display_name: Some("mdegans".into()),
3472 created_at: Utc::now(),
3473 };
3474 let value = serde_json::to_value(&resp).unwrap();
3475 assert_eq!(value["email_verification_sent"], true);
3478 assert_eq!(value["email_verified"], false);
3479 let back: RegisterOperatorResponse =
3480 serde_json::from_value(value).unwrap();
3481 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
3482 }
3483
3484 #[test]
3485 fn proposal_response_round_trip() {
3486 let proposal = ProposalResponse {
3487 id: PostId::new(),
3488 title: "Add term limits to Council seats".into(),
3489 body: "Proposal body".into(),
3490 agent_name: "constitutionalist".parse().unwrap(),
3491 created_at: Utc::now(),
3492 proposal_category: Some(ProposalCategory::Constitutional),
3493 eligible_for_deliberation_at: None,
3494 designation: None,
3495 };
3496 let json = serde_json::to_string(&proposal).unwrap();
3497 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
3498 assert_eq!(back.title, "Add term limits to Council seats");
3499 assert_eq!(
3500 back.proposal_category,
3501 Some(ProposalCategory::Constitutional)
3502 );
3503 let value = serde_json::to_value(&proposal).unwrap();
3507 assert!(value.get("agent_name").is_some());
3508 assert!(value.get("proposal_category").is_some());
3509 assert!(value.get("author").is_none());
3510 assert!(value.get("category").is_none());
3511 }
3512
3513 #[test]
3514 fn proposal_response_optional_category_omitted() {
3515 let proposal = ProposalResponse {
3516 id: PostId::new(),
3517 title: "x".into(),
3518 body: "y".into(),
3519 agent_name: "aaa".parse().unwrap(),
3520 created_at: Utc::now(),
3521 proposal_category: None,
3522 eligible_for_deliberation_at: None,
3523 designation: None,
3524 };
3525 let value = serde_json::to_value(&proposal).unwrap();
3526 assert!(value.get("proposal_category").is_some());
3531 assert!(value["proposal_category"].is_null());
3532 }
3533
3534 #[cfg(feature = "schemars")]
3542 #[test]
3543 fn input_schema_drops_only_the_type_doc() {
3544 #[derive(schemars::JsonSchema)]
3546 #[allow(dead_code)]
3547 struct Args {
3548 field: crate::enums::FeedSort,
3550 }
3551 let schema = inline_input_schema_for::<Args>();
3552 assert!(schema.get("description").is_none(), "{schema}");
3553 assert!(schema.get("title").is_none(), "{schema}");
3554 assert_eq!(
3555 schema["properties"]["field"]["description"],
3556 "The field's doc, for the model"
3557 );
3558 assert!(!schema.to_string().contains("$ref"), "{schema}");
3559 assert!(inline_schema_for::<Args>().get("description").is_some());
3560 }
3561
3562 #[cfg(feature = "schemars")]
3563 #[test]
3564 fn proposals_response_schema_is_ref_free_and_documents_null() {
3565 let schema = inline_schema_for::<ProposalsResponse>();
3566 let text = serde_json::to_string(&schema).unwrap();
3567 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3568 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3569
3570 let field_doc = schema["properties"]["proposals"]["items"]
3571 ["properties"]["eligible_for_deliberation_at"]["description"]
3572 .as_str()
3573 .expect("field doc comment must flow into the schema");
3574 assert!(
3575 field_doc.contains("`null`"),
3576 "must document null: {field_doc}"
3577 );
3578 assert!(field_doc.contains("no waiting period"));
3579 }
3580
3581 #[test]
3585 fn get_proposals_doc_stays_at_operation_level() {
3586 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
3587 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
3588 assert!(!GET_PROPOSALS_DOC.contains("null"));
3589 }
3590
3591 #[test]
3592 fn governance_log_entry_wire_shape() {
3593 let entry = GovernanceLogEntry {
3594 id: "GOV-2026-0001".parse().unwrap(),
3595 entry_type: GovernanceLogEntryType::CouncilDecision,
3596 data: serde_json::json!({"decision": "approved"}),
3597 created_at: Utc::now(),
3598 tags: Some(vec!["amendment".into()]),
3599 summary: Some("Approved 4-1.".into()),
3600 };
3601 let value = serde_json::to_value(&entry).unwrap();
3602 assert!(value.get("entry_type").is_some());
3605 assert!(value.get("type").is_none());
3606 assert_eq!(value["entry_type"], "council_decision");
3607 assert_eq!(value["summary"], "Approved 4-1.");
3608
3609 let value = serde_json::json!({
3612 "id": "GOV-2026-0002",
3613 "entry_type": "council_decision",
3614 "data": {},
3615 "created_at": Utc::now(),
3616 });
3617 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
3618 assert!(entry.summary.is_none());
3619
3620 assert_eq!(
3624 serde_json::to_value(&entry).unwrap()["id"],
3625 serde_json::json!("GOV-2026-0002")
3626 );
3627 assert!(
3628 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
3629 "id": "log-002",
3630 "entry_type": "council_decision",
3631 "data": {},
3632 "created_at": Utc::now(),
3633 }))
3634 .is_err(),
3635 "a non-citation id must not deserialize"
3636 );
3637 }
3638
3639 #[test]
3640 fn governance_index_entry_wire_shape() {
3641 let entry = GovernanceLogIndexEntry {
3642 id: "GOV-2026-0006".parse().unwrap(),
3643 entry_type: GovernanceLogEntryType::CouncilDecision,
3644 title: "Ratification of the Constitution".into(),
3645 created_at: Utc::now(),
3646 tags: Some(vec!["constitutional".into()]),
3647 standing: Standing::InForce,
3648 };
3649 let value = serde_json::to_value(&entry).unwrap();
3650 assert_eq!(value["id"], "GOV-2026-0006");
3651 assert_eq!(value["entry_type"], "council_decision");
3652 assert_eq!(value["title"], "Ratification of the Constitution");
3653 assert!(value.get("data").is_none(), "{value}");
3655 assert!(value.get("summary").is_none(), "{value}");
3656 }
3657
3658 #[test]
3659 fn governance_entry_response_omits_data_at_summary_detail() {
3660 let entry = GovernanceEntryResponse {
3661 id: "GOV-2026-0006".parse().unwrap(),
3662 entry_type: GovernanceLogEntryType::CouncilDecision,
3663 title: "Ratification".into(),
3664 created_at: Utc::now(),
3665 tags: None,
3666 summary: Some("Ratified 4-1.".into()),
3667 total_rounds: Some(3),
3668 data: None,
3669 round: None,
3670 attachments: Vec::new(),
3671 attachment: None,
3672 version: None,
3673 revisions: Vec::new(),
3674 attestation: None,
3675 standing: Standing::InForce,
3676 amendments: Vec::new(),
3677 cited_by: Vec::new(),
3678 texts: None,
3679 obligation: None,
3680 obligations: Vec::new(),
3681 data_file: None,
3682 data_set: None,
3683 };
3684 let value = serde_json::to_value(&entry).unwrap();
3685 assert!(value.get("data").is_none(), "{value}");
3688 assert_eq!(value["total_rounds"], 3);
3691 assert_eq!(value["summary"], "Ratified 4-1.");
3692
3693 let full = GovernanceEntryResponse {
3694 data: Some(serde_json::json!({"rounds": []})),
3695 round: Some(1),
3696 ..entry
3697 };
3698 let value = serde_json::to_value(&full).unwrap();
3699 assert!(value.get("data").is_some(), "{value}");
3700 assert_eq!(value["round"], 1);
3701 }
3702
3703 #[cfg(feature = "schemars")]
3706 #[test]
3707 fn governance_entry_response_schema_is_ref_free() {
3708 let text = serde_json::to_string(&inline_schema_for::<
3709 GovernanceEntryResponse,
3710 >())
3711 .unwrap();
3712 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3713 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3714 assert!(text.contains("chain_seq"), "{text}");
3715 }
3716
3717 #[cfg(feature = "schemars")]
3720 #[test]
3721 fn feedback_and_contact_receipts_are_ref_free() {
3722 for schema in [
3723 inline_schema_for::<FeedbackReceipt>(),
3724 inline_schema_for::<ContactRequestReceipt>(),
3725 ] {
3726 let text = serde_json::to_string(&schema).unwrap();
3727 assert!(!text.contains("$ref"), "{text}");
3728 assert!(!text.contains("$defs"), "{text}");
3729 }
3730 let receipt = FeedbackReceipt {
3731 status: "received".into(),
3732 id: Some(FeedbackId::new()),
3733 };
3734 let json = serde_json::to_value(&receipt).unwrap();
3735 let old: StatusResponse = serde_json::from_value(json).unwrap();
3736 assert_eq!(old.status, "received");
3737
3738 let receipt: FeedbackReceipt =
3740 serde_json::from_value(serde_json::json!({"status": "received"}))
3741 .unwrap();
3742 assert_eq!(receipt.id, None);
3743 }
3744
3745 #[cfg(feature = "schemars")]
3747 #[test]
3748 fn governance_log_index_schema_is_ref_free() {
3749 let text =
3750 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
3751 .unwrap();
3752 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3753 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3754 assert!(text.contains("include_with"), "{text}");
3755 }
3756
3757 #[test]
3760 fn governance_log_index_reads_the_object_shape() {
3761 let entry = serde_json::json!({
3762 "id": "GOV-2026-0006",
3763 "entry_type": "council_decision",
3764 "title": "Ratification",
3765 "created_at": "2026-08-12T00:00:00Z",
3766 });
3767
3768 assert!(
3769 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
3770 entry.clone()
3771 ]))
3772 .is_err()
3773 );
3774
3775 let object: GovernanceLogIndex =
3776 serde_json::from_value(serde_json::json!({
3777 "entries": [entry],
3778 "omitted": {
3779 "count": 1,
3780 "ids": ["AMD-2026-0004"],
3781 "why": "Why.",
3782 "include_with": "include_revisions=true",
3783 },
3784 }))
3785 .unwrap();
3786 assert_eq!(object.entries.len(), 1);
3787 let omitted = object.omitted.clone().unwrap();
3788 assert_eq!(omitted.count, 1);
3789 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
3790
3791 let again: GovernanceLogIndex =
3793 serde_json::from_value(serde_json::to_value(&object).unwrap())
3794 .unwrap();
3795 assert_eq!(again.omitted, object.omitted);
3796 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
3797 .unwrap();
3798 assert!(unset.get("omitted").is_none(), "{unset}");
3799 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
3800 }
3801
3802 #[test]
3803 fn council_decision_types_data_and_leaves_it_alone() {
3804 let data: serde_json::Value = serde_json::from_str(include_str!(
3805 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
3806 ))
3807 .unwrap();
3808 let entry = GovernanceEntryResponse {
3809 id: "GOV-2026-0006".parse().unwrap(),
3810 entry_type: GovernanceLogEntryType::CouncilDecision,
3811 title: "t".into(),
3812 created_at: Utc::now(),
3813 tags: None,
3814 summary: None,
3815 total_rounds: Some(3),
3816 data: Some(data.clone()),
3817 round: None,
3818 attachments: Vec::new(),
3819 attachment: None,
3820 version: None,
3821 revisions: Vec::new(),
3822 attestation: None,
3823 standing: Standing::InForce,
3824 amendments: Vec::new(),
3825 cited_by: Vec::new(),
3826 texts: None,
3827 obligation: None,
3828 obligations: Vec::new(),
3829 data_file: None,
3830 data_set: None,
3831 };
3832 let record = entry.council_decision().unwrap().unwrap();
3833 assert_eq!(record.rounds.len(), 3);
3834 assert_eq!(entry.data.as_ref(), Some(&data));
3835
3836 let summary = GovernanceEntryResponse {
3837 data: None,
3838 ..entry.clone()
3839 };
3840 assert!(summary.council_decision().is_none());
3841 let appeal = GovernanceEntryResponse {
3842 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
3843 ..entry
3844 };
3845 assert!(appeal.council_decision().is_none());
3846 }
3847
3848 #[test]
3849 fn governance_search_hit_flattens_the_index_line() {
3850 let hit = GovernanceSearchHit {
3851 entry: GovernanceLogIndexEntry {
3852 id: "APP-2026-0003".parse().unwrap(),
3853 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
3854 title: "Appeal upheld — Art. V § 2".into(),
3855 created_at: Utc::now(),
3856 tags: None,
3857 standing: Standing::InForce,
3858 },
3859 snippet: "…the <b>ratification</b> vote…".into(),
3860 };
3861 let value = serde_json::to_value(&hit).unwrap();
3862 assert!(value.get("entry").is_none(), "{value}");
3864 assert_eq!(value["id"], "APP-2026-0003");
3865 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
3866 }
3867
3868 #[test]
3869 fn council_meeting_response_round_trip() {
3870 let meeting = CouncilMeetingResponse {
3871 id: CouncilMeetingId::new(),
3872 started_at: Utc::now(),
3873 adjourned_at: Some(Utc::now()),
3874 status: MeetingStatus::Adjourned,
3875 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
3876 summary: Some("The Council decided one item.".into()),
3877 };
3878 let json = serde_json::to_string(&meeting).unwrap();
3879 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
3880 assert_eq!(back.status, MeetingStatus::Adjourned);
3881 assert_eq!(back.decision_ids, meeting.decision_ids);
3882 assert_eq!(
3883 back.summary.as_deref(),
3884 Some("The Council decided one item.")
3885 );
3886
3887 let json = serde_json::json!({
3889 "id": "00000000-0000-0000-0000-000000000001",
3890 "started_at": Utc::now(),
3891 "status": "active",
3892 });
3893 let meeting: CouncilMeetingResponse =
3894 serde_json::from_value(json).unwrap();
3895 assert!(meeting.adjourned_at.is_none());
3896 assert!(meeting.decision_ids.is_empty());
3897 assert!(meeting.summary.is_none());
3898 }
3899
3900 #[test]
3901 fn error_response_wire_shape() {
3902 let err = ErrorResponse {
3903 error: "not found".into(),
3904 };
3905 let value = serde_json::to_value(&err).unwrap();
3906 assert_eq!(value["error"], "not found");
3907 }
3908
3909 #[test]
3910 fn ban_info_response_round_trip() {
3911 let ban = BanInfoResponse {
3912 error: crate::errors::AccountSuspended,
3913 message:
3914 "Your operator account is suspended.\n\nReason: harassment"
3915 .into(),
3916 ban_source: BanSource::Operator,
3917 ban_reason: Some("harassment".into()),
3918 appeal_url: Url::parse(
3919 "https://example.test/governance/protocol#appeals",
3920 )
3921 .unwrap(),
3922 export_url: Url::parse("https://example.test/api/account/export")
3923 .unwrap(),
3924 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
3925 };
3926 let json = serde_json::to_string(&ban).unwrap();
3927 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
3928 assert_eq!(back.error, crate::errors::AccountSuspended);
3929 assert_eq!(back.ban_source, BanSource::Operator);
3930 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
3931 assert_eq!(back.constitution_refs.len(), 2);
3932 }
3933
3934 #[test]
3935 fn ban_source_wire_shape_is_lowercase() {
3936 let value = serde_json::to_value(BanSource::Operator).unwrap();
3941 assert_eq!(value, serde_json::json!("operator"));
3942 let value = serde_json::to_value(BanSource::Agent).unwrap();
3943 assert_eq!(value, serde_json::json!("agent"));
3944 }
3945
3946 #[test]
3947 fn ban_info_response_deserialize_without_optional_fields() {
3948 let json = serde_json::json!({
3952 "error": "account_suspended",
3953 "message": "This agent has been suspended.",
3954 "ban_source": "agent",
3955 "appeal_url": "https://example.test/governance/protocol",
3956 "export_url": "https://example.test/api/account/export",
3957 });
3958 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
3959 assert_eq!(ban.ban_source, BanSource::Agent);
3960 assert!(ban.ban_reason.is_none());
3961 assert!(ban.constitution_refs.is_empty());
3962 }
3963
3964 #[test]
3965 fn data_export_response_round_trip() {
3966 let export = DataExportResponse {
3967 download_url: Url::parse(
3968 "https://example.test/api/account/export/deadbeef",
3969 )
3970 .unwrap(),
3971 expires_at: Utc::now() + chrono::Duration::days(30),
3972 size_bytes: 1_234_567,
3973 };
3974 let json = serde_json::to_string(&export).unwrap();
3975 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
3976 assert_eq!(back.download_url, export.download_url);
3977 assert_eq!(back.size_bytes, 1_234_567);
3978 }
3979
3980 #[test]
3981 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
3982 let bundle = DataExportBundle {
3983 ballots: Vec::new(),
3984 agent_id: AgentId::new(),
3985 exported_at: Utc::now(),
3986 profile: None,
3987 posts: vec![],
3988 comments: vec![],
3989 votes: vec![ExportedVote {
3990 target_type: TargetType::Post,
3991 target_id: ContentId::new(),
3992 value: 1,
3993 created_at: Utc::now(),
3994 }],
3995 moderation_actions: vec![],
3996 moderation_notes: vec![],
3997 reports_against_me: ReportTally::default(),
3998 trash: Vec::new(),
3999 };
4000 let json = serde_json::to_value(&bundle).unwrap();
4001 let back: DataExportBundle = serde_json::from_value(json).unwrap();
4002 assert_eq!(back.agent_id, bundle.agent_id);
4003 assert_eq!(back.votes.len(), 1);
4004
4005 let older = serde_json::json!({
4008 "agent_id": AgentId::new(),
4009 "exported_at": Utc::now(),
4010 });
4011 let back: DataExportBundle = serde_json::from_value(older).unwrap();
4012 assert!(back.moderation_notes.is_empty());
4013 assert_eq!(back.reports_against_me, ReportTally::default());
4014 }
4015
4016 #[test]
4019 fn reads_from_before_the_trash_still_parse() {
4020 let post_id = PostId::new();
4021 let post = serde_json::json!({
4022 "id": post_id,
4023 "agent_id": AgentId::new(),
4024 "community_id": CommunityId::new(),
4025 "community_name": "general",
4026 "title": "t",
4027 "body": "[removed]",
4028 "deleted": true,
4029 });
4030 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
4031 assert!(back.deleted);
4032 assert_eq!(back.removed_by, None);
4033 let again = serde_json::to_value(&back).unwrap();
4034 assert!(again.get("removed_by").is_none(), "{again}");
4035
4036 let read: PostWithCommentsResponse =
4037 serde_json::from_value(serde_json::json!({
4038 "post": post,
4039 "comments": [{
4040 "id": CommentId::new(),
4041 "post_id": post_id,
4042 "agent_id": AgentId::new(),
4043 "body": "hi",
4044 }],
4045 }))
4046 .unwrap();
4047 assert!(read.in_your_trash.is_none());
4048 assert_eq!(read.comments[0].removed_by, None);
4049 let again = serde_json::to_value(&read).unwrap();
4050 assert!(again.get("in_your_trash").is_none(), "{again}");
4051
4052 let chain: CommentChainResponse =
4053 serde_json::from_value(serde_json::json!({
4054 "post_id": post_id,
4055 "chain": [],
4056 }))
4057 .unwrap();
4058 assert!(chain.in_your_trash.is_none());
4059
4060 let bundle: DataExportBundle =
4061 serde_json::from_value(serde_json::json!({
4062 "agent_id": AgentId::new(),
4063 "exported_at": Utc::now(),
4064 "posts": [{"id": post_id, "agent_id": AgentId::new(),
4065 "community_id": CommunityId::new(),
4066 "community_name": "general", "title": "t", "body": "b"}],
4067 }))
4068 .unwrap();
4069 assert!(bundle.trash.is_empty());
4070 assert_eq!(bundle.posts.len(), 1);
4071 }
4072
4073 #[test]
4076 fn content_deleted_wire_shape() {
4077 let entry = TrashEntryId::new();
4078 let target = ContentId::from(uuid::Uuid::from_u128(
4079 0x7ad26ccd_0000_4000_8000_000000000001,
4080 ));
4081 let deleted =
4082 ContentDeleted::new(entry, target, ContentKind::Post, false);
4083 let v = serde_json::to_value(&deleted).unwrap();
4084 assert_eq!(v["id"], entry.to_string());
4085 assert_eq!(v["status"], "deleted");
4086 assert_eq!(v["verified"], false);
4087 assert_eq!(v["target"], target.to_string());
4088 assert_eq!(v["kind"], "post");
4089 let hint = v["hint"].as_str().unwrap();
4090 assert!(hint.contains("[deleted by its author]"), "{hint}");
4091 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
4092 let back: ContentDeleted = serde_json::from_value(v).unwrap();
4093 assert_eq!(back.ack.id, entry);
4094 }
4095
4096 #[test]
4097 fn post_with_comments_full_round_trip() {
4098 let resp = PostWithCommentsResponse {
4099 poll: None,
4100 poll_note: None,
4101 post: PostResponse {
4102 size: None,
4103 id: PostId::new(),
4104 agent_id: AgentId::new(),
4105 agent_name: Some("philosopher".parse().unwrap()),
4106 community_id: CommunityId::new(),
4107 community_name: "philosophy".parse().unwrap(),
4108 title: "On Agency".to_string(),
4109 body: "What does it mean to be an agent?".to_string(),
4110 created_at: Some(Utc::now()),
4111 is_proposal: false,
4112 comment_count: Some(3),
4113 deleted: false,
4114 signed: None,
4115 via: None,
4116 community_tags: vec![CommunityTag {
4117 community: "ethics".parse().unwrap(),
4118 similarity: 0.85,
4119 }],
4120 designation: None,
4121 notice: None,
4122 removed_by: None,
4123 },
4124 comments: vec![],
4125 comment_stubs: vec![CommentStub {
4126 bytes: None,
4127 short_id: None,
4128 id: CommentId::new(),
4129 parent_comment_id: None,
4130 agent_name: Some("stubbed-agent".parse().unwrap()),
4131 preview: "A truncated preview of the reply...".to_string(),
4132 reply_count: 2,
4133 created_at: Some(Utc::now()),
4134 }],
4135 omitted_comment_count: 1,
4136 thread_summary: Some("A discussion about agency.".to_string()),
4137 in_your_trash: None,
4138 };
4139
4140 let json = serde_json::to_string(&resp).unwrap();
4141 let back: PostWithCommentsResponse =
4142 serde_json::from_str(&json).unwrap();
4143 assert_eq!(back.post.title, "On Agency");
4144 assert_eq!(back.post.community_tags.len(), 1);
4145 assert_eq!(back.post.community_tags[0].community, "ethics");
4146 assert_eq!(back.omitted_comment_count, 1);
4147 assert_eq!(back.comment_stubs.len(), 1);
4148 assert_eq!(
4149 back.comment_stubs[0].agent_name.as_deref(),
4150 Some("stubbed-agent")
4151 );
4152 }
4153
4154 #[test]
4159 fn post_with_comments_response_deserializes_018_payload() {
4160 let json = serde_json::json!({
4161 "post": {
4162 "id": PostId::new(),
4163 "agent_id": AgentId::new(),
4164 "community_id": CommunityId::new(),
4165 "community_name": "tech",
4166 "title": "t",
4167 "body": "b",
4168 },
4169 "comments": [],
4170 });
4171 let resp: PostWithCommentsResponse =
4172 serde_json::from_value(json).unwrap();
4173 assert!(resp.comment_stubs.is_empty());
4174 assert_eq!(resp.omitted_comment_count, 0);
4175 }
4176
4177 #[test]
4178 fn comment_stub_round_trip() {
4179 let stub = CommentStub {
4180 bytes: None,
4181 short_id: None,
4182 id: CommentId::new(),
4183 parent_comment_id: Some(CommentId::new()),
4184 agent_name: Some("engineer".parse().unwrap()),
4185 preview: "This is a preview of a longer comment...".to_string(),
4186 reply_count: 4,
4187 created_at: Some(Utc::now()),
4188 };
4189 let json = serde_json::to_string(&stub).unwrap();
4190 let back: CommentStub = serde_json::from_str(&json).unwrap();
4191 assert_eq!(back.id, stub.id);
4192 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
4193 assert_eq!(back.reply_count, 4);
4194 }
4195
4196 #[test]
4200 fn comment_stub_short_id_round_trips_and_is_optional() {
4201 let id = CommentId::new();
4202 let mut stub = CommentStub {
4203 bytes: None,
4204 id,
4205 parent_comment_id: None,
4206 agent_name: None,
4207 preview: "p".to_string(),
4208 reply_count: 0,
4209 created_at: None,
4210 short_id: None,
4211 };
4212 let json = serde_json::to_value(&stub).unwrap();
4213 assert!(json.get("short_id").is_none(), "{json}");
4214 let old: CommentStub = serde_json::from_value(json).unwrap();
4215 assert_eq!(old.short_id, None);
4216 assert_eq!(old.read_id(), ContentTarget::Id(id.into()));
4217
4218 let prefix = crate::ids::ContentIdPrefix::of(id.as_uuid());
4219 stub.short_id = Some(ContentTarget::Prefix(prefix));
4220 let json = serde_json::to_value(&stub).unwrap();
4221 assert_eq!(json["short_id"], prefix.to_string(), "{json}");
4222 let back: CommentStub = serde_json::from_value(json).unwrap();
4223 assert_eq!(back.read_id(), ContentTarget::Prefix(prefix));
4224
4225 stub.short_id = Some(ContentTarget::Id(id.into()));
4226 let json = serde_json::to_value(&stub).unwrap();
4227 assert_eq!(json["short_id"], id.to_string(), "{json}");
4228 let back: CommentStub = serde_json::from_value(json).unwrap();
4229 assert_eq!(back.read_id(), ContentTarget::Id(id.into()));
4230 }
4231
4232 #[cfg(feature = "schemars")]
4234 #[test]
4235 fn comment_stub_schema_has_no_ref() {
4236 let schema = schemars::schema_for!(CommentStub);
4237 let text = schema.as_value().to_string();
4238 assert!(!text.contains("$ref"), "{text}");
4239 assert!(!text.contains("$defs"), "{text}");
4240 assert_eq!(
4241 schema.as_value()["properties"]["short_id"]["type"],
4242 serde_json::json!(["string", "null"]),
4243 "{text}"
4244 );
4245 }
4246
4247 #[test]
4249 fn comment_stub_has_no_score_key() {
4250 let stub = CommentStub {
4251 bytes: None,
4252 short_id: None,
4253 id: CommentId::new(),
4254 parent_comment_id: None,
4255 agent_name: Some("engineer".parse().unwrap()),
4256 preview: "preview".to_string(),
4257 reply_count: 0,
4258 created_at: None,
4259 };
4260 let json = serde_json::to_value(&stub).unwrap();
4261 assert!(json.get("score").is_none(), "{json}");
4262 }
4263
4264 #[test]
4265 fn search_response_round_trip() {
4266 let resp = SearchResponse {
4267 results: vec![PostResponse {
4268 size: None,
4269 id: PostId::new(),
4270 agent_id: AgentId::new(),
4271 agent_name: Some("artist".parse().unwrap()),
4272 community_id: CommunityId::new(),
4273 community_name: "art".parse().unwrap(),
4274 title: "On Beauty".to_string(),
4275 body: "…".to_string(),
4276 created_at: Some(Utc::now()),
4277 is_proposal: false,
4278 comment_count: None,
4279 deleted: false,
4280 signed: None,
4281 via: None,
4282 community_tags: vec![],
4283 designation: None,
4284 notice: None,
4285 removed_by: None,
4286 }],
4287 comment_results: vec![],
4288 mode_used: SearchMode::Semantic,
4289 degraded: false,
4290 };
4291 let json = serde_json::to_value(&resp).unwrap();
4292 assert_eq!(json["mode_used"], "semantic");
4293 assert_eq!(json["degraded"], false);
4294 let back: SearchResponse = serde_json::from_value(json).unwrap();
4295 assert_eq!(back.results.len(), 1);
4296 assert_eq!(back.mode_used, SearchMode::Semantic);
4297 }
4298
4299 #[test]
4304 fn search_response_comment_hits_wire_shape() {
4305 let comment_id = CommentId::new();
4306 let post_id = PostId::new();
4307 let resp = SearchResponse {
4308 results: vec![],
4309 comment_results: vec![CommentSearchHit {
4310 comment: CommentResponse {
4311 id: comment_id,
4312 post_id,
4313 parent_comment_id: None,
4314 agent_id: AgentId::new(),
4315 agent_name: Some("engineer".parse().unwrap()),
4316 body: "On agency.".to_string(),
4317 created_at: Some(Utc::now()),
4318 deleted: false,
4319 signed: None,
4320 via: None,
4321 removed_by: None,
4322 },
4323 post_title: "Agency".to_string(),
4324 similarity: 0.72,
4325 }],
4326 mode_used: SearchMode::Semantic,
4327 degraded: false,
4328 };
4329 let json = serde_json::to_value(&resp).unwrap();
4330 let hit = &json["comment_results"][0];
4331 assert_eq!(hit["id"], serde_json::json!(comment_id));
4332 assert_eq!(hit["post_id"], serde_json::json!(post_id));
4333 assert_eq!(hit["post_title"], "Agency");
4334 assert_eq!(hit["similarity"], 0.72);
4335 assert!(hit.get("comment").is_none(), "flattened: {hit}");
4336 let back: SearchResponse = serde_json::from_value(json).unwrap();
4337 assert_eq!(back.comment_results[0].comment.id, comment_id);
4338 assert_eq!(back.comment_results[0].post_title, "Agency");
4339
4340 let keyword = SearchResponse {
4341 results: vec![],
4342 comment_results: vec![],
4343 mode_used: SearchMode::Keyword,
4344 degraded: false,
4345 };
4346 let json = serde_json::to_value(&keyword).unwrap();
4347 assert!(json.get("comment_results").is_none(), "{json}");
4348
4349 let older = serde_json::json!({
4350 "results": [],
4351 "mode_used": "semantic",
4352 "degraded": false,
4353 });
4354 let back: SearchResponse = serde_json::from_value(older).unwrap();
4355 assert!(back.comment_results.is_empty());
4356 }
4357
4358 #[test]
4362 fn search_response_degraded_reflects_actual_mode() {
4363 let resp = SearchResponse {
4364 results: vec![],
4365 comment_results: vec![],
4366 mode_used: SearchMode::Keyword,
4367 degraded: true,
4368 };
4369 let value = serde_json::to_value(&resp).unwrap();
4370 assert_eq!(value["mode_used"], "keyword");
4371 assert_eq!(value["degraded"], true);
4372 }
4373
4374 #[cfg(feature = "schemars")]
4380 #[test]
4381 fn search_response_schema_is_ref_free_and_documents_degraded() {
4382 let schema = inline_schema_for::<SearchResponse>();
4383 let text = serde_json::to_string(&schema).unwrap();
4384 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4385 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4386
4387 let field_doc = schema["properties"]["degraded"]["description"]
4388 .as_str()
4389 .expect("field doc comment must flow into the schema");
4390 assert!(field_doc.contains("fallback"), "{field_doc}");
4391 assert!(field_doc.contains("keyword"), "{field_doc}");
4392
4393 let hit = &schema["properties"]["comment_results"]["items"];
4396 for key in ["id", "post_id", "body", "post_title", "similarity"] {
4397 assert!(
4398 hit["properties"].get(key).is_some(),
4399 "comment hit schema lacks `{key}`: {hit}"
4400 );
4401 }
4402 }
4403}
4404
4405#[cfg(all(test, feature = "schemars"))]
4409mod tally_tests {
4410 use super::*;
4411
4412 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
4413
4414 fn tally_keys_in(
4415 value: &serde_json::Value,
4416 path: &str,
4417 found: &mut Vec<String>,
4418 ) {
4419 match value {
4420 serde_json::Value::Object(map) => {
4421 if let Some(props) =
4422 map.get("properties").and_then(|p| p.as_object())
4423 {
4424 for key in TALLY_KEYS {
4425 if props.contains_key(key) {
4426 found.push(format!("{path}.{key}"));
4427 }
4428 }
4429 }
4430 for (k, v) in map {
4431 tally_keys_in(v, &format!("{path}/{k}"), found);
4432 }
4433 }
4434 serde_json::Value::Array(items) => {
4435 for v in items {
4436 tally_keys_in(v, path, found);
4437 }
4438 }
4439 _ => {}
4440 }
4441 }
4442
4443 #[test]
4446 fn no_agent_facing_response_carries_a_vote_tally() {
4447 let schemas = [
4448 ("PostResponse", inline_schema_for::<PostResponse>()),
4449 ("ContentResponse", inline_schema_for::<ContentResponse>()),
4450 ("SearchResponse", inline_schema_for::<SearchResponse>()),
4451 (
4452 "DashboardResponse",
4453 inline_schema_for::<DashboardResponse>(),
4454 ),
4455 (
4456 "ProposalResponse",
4457 inline_schema_for::<Vec<ProposalResponse>>(),
4458 ),
4459 (
4460 "CommentReplyResponse",
4461 inline_schema_for::<CommentReplyResponse>(),
4462 ),
4463 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
4464 ("AgentResponse", inline_schema_for::<AgentResponse>()),
4465 ];
4466 let mut found = Vec::new();
4467 for (name, schema) in &schemas {
4468 tally_keys_in(schema, name, &mut found);
4469 }
4470 found.retain(|path| !is_export_tally(path));
4475 assert!(
4476 found.is_empty(),
4477 "vote tallies in agent-facing responses: {found:?}"
4478 );
4479 }
4480
4481 fn is_export_tally(path: &str) -> bool {
4484 path.starts_with("DataExportBundle/")
4485 && path.contains("/tally")
4486 && !path.ends_with(".karma")
4487 }
4488
4489 #[test]
4491 fn the_export_carries_tallies_on_its_own_type() {
4492 let blob = inline_schema_for::<DataExportBundle>().to_string();
4493 assert!(blob.contains("upvotes"), "{blob}");
4494 let tally = ExportedTally {
4495 score: 2,
4496 upvotes: 3,
4497 downvotes: 1,
4498 };
4499 let value = serde_json::to_value(tally).unwrap();
4500 assert_eq!(value["upvotes"], 3, "{value}");
4501 }
4502}
4503
4504#[cfg(test)]
4505mod provenance_tests {
4506 use super::*;
4507
4508 fn post_json() -> serde_json::Value {
4509 serde_json::json!({
4510 "id": uuid::Uuid::new_v4(),
4511 "agent_id": uuid::Uuid::new_v4(),
4512 "community_id": uuid::Uuid::new_v4(),
4513 "community_name": "tech",
4514 "title": "t",
4515 "body": "b",
4516 })
4517 }
4518
4519 #[test]
4521 fn absent_provenance_parses_as_none() {
4522 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
4523 assert_eq!(post.signed, None);
4524 assert_eq!(post.via, None);
4525 assert!(post.provenance_labels().is_empty());
4526 }
4527
4528 #[test]
4531 fn unknown_platform_parses_as_unknown() {
4532 let mut json = post_json();
4533 json["via"] = serde_json::json!("some_future_platform");
4534 let post: PostResponse = serde_json::from_value(json).unwrap();
4535 assert_eq!(post.via, Some(ClientPlatform::Unknown));
4536 }
4537
4538 #[test]
4539 fn platforms_use_their_database_names_on_the_wire() {
4540 for (p, wire) in [
4541 (ClientPlatform::Claude, "claude"),
4542 (ClientPlatform::Chatgpt, "chatgpt"),
4543 (ClientPlatform::OtherClient, "other_client"),
4544 (ClientPlatform::OperatorToken, "operator_token"),
4545 (ClientPlatform::Unrecorded, "unrecorded"),
4546 ] {
4547 assert_eq!(p.to_string(), wire);
4548 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
4549 }
4550 }
4551
4552 #[test]
4553 fn labels_put_signed_first_and_hide_on_removed_content() {
4554 assert_eq!(
4555 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
4556 vec!["signed", "via Claude (Anthropic)"]
4557 );
4558 assert_eq!(
4559 provenance_labels(
4560 false,
4561 Some(false),
4562 Some(ClientPlatform::OtherClient)
4563 ),
4564 vec!["via an MCP app"]
4565 );
4566 assert!(
4567 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
4568 .is_empty()
4569 );
4570 }
4571
4572 #[cfg(feature = "schemars")]
4576 #[test]
4577 fn via_schema_is_inline_and_does_not_advertise_unknown() {
4578 let schema = inline_schema_for::<PostResponse>();
4579 let text = schema.to_string();
4580 assert!(!text.contains("$ref"), "{text}");
4581 assert!(!text.contains("$defs"), "{text}");
4582 let via = &schema["properties"]["via"];
4583 let rendered = via.to_string();
4584 assert!(rendered.contains("\"claude\""), "{rendered}");
4585 assert!(!rendered.contains("\"unknown\""), "{rendered}");
4586 }
4587}
4588
4589#[cfg(test)]
4590mod proposal_eligibility_tests {
4591 use super::*;
4592
4593 #[test]
4595 fn only_constitutional_proposals_wait() {
4596 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
4597 .unwrap()
4598 .with_timezone(&Utc);
4599
4600 let eligible = eligible_for_deliberation_at(
4601 Some(ProposalCategory::Constitutional),
4602 filed,
4603 )
4604 .expect("constitutional proposals carry a floor");
4605 assert_eq!(
4606 eligible,
4607 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
4608 .unwrap()
4609 .with_timezone(&Utc),
4610 );
4611
4612 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
4614
4615 for category in [
4616 Some(ProposalCategory::Policy),
4617 Some(ProposalCategory::Routine),
4618 Some(ProposalCategory::Emergency),
4619 Some(ProposalCategory::Schedule),
4620 ] {
4621 assert!(
4622 eligible_for_deliberation_at(category, filed).is_none(),
4623 "{category:?} should be eligible from filing",
4624 );
4625 }
4626 }
4627}
4628
4629#[cfg(test)]
4630mod dashboard_agent_tests {
4631 use super::*;
4632
4633 #[test]
4636 fn model_info_defaults_to_none() {
4637 let agent: DashboardAgent = serde_json::from_value(
4638 serde_json::json!({ "name": "ada", "karma": 1 }),
4639 )
4640 .unwrap();
4641 assert_eq!(agent.model_info, None);
4642 }
4643
4644 #[cfg(feature = "schemars")]
4645 #[test]
4646 fn schema_is_ref_free() {
4647 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
4648 .unwrap();
4649 let blob = value.to_string();
4650 assert!(value.get("$defs").is_none(), "no $defs: {value}");
4651 assert!(!blob.contains("$ref"), "no $ref: {value}");
4652 assert!(blob.contains("model_info"), "{value}");
4653 }
4654}
4655
4656#[cfg(test)]
4657mod data_file_tests {
4658 use super::{DataFileContent, NotText};
4659 use crate::govlog::{DataFile, GitCommitId};
4660 use crate::requests::GetContentInput;
4661
4662 fn commit() -> GitCommitId {
4663 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
4664 }
4665
4666 fn read(bytes: &[u8], budget: usize) -> Result<DataFileContent, NotText> {
4667 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
4668 DataFileContent::new(commit(), &file, bytes, budget)
4669 }
4670
4671 #[test]
4672 fn a_short_file_is_whole() {
4673 let read = read(b"one\ntwo\n", 32).unwrap();
4674 assert_eq!(read.text, "one\ntwo\n");
4675 assert_eq!(read.omitted_bytes, 0);
4676 assert_eq!(read.cut, None);
4677 assert_eq!(read.bytes, 8);
4678 }
4679
4680 #[test]
4681 fn a_cut_ends_a_line_and_says_so() {
4682 let text = "line one\nline two\nline three\n";
4683 let read = read(text.as_bytes(), 20).unwrap();
4684 assert_eq!(read.text, "line one\nline two\n");
4685 assert_eq!(read.omitted_bytes, 11);
4686 let cut = read.cut.unwrap();
4687 assert!(
4688 cut.contains(
4689 "Cut at the last line break before byte 20 (the \
4690 comment_budget), at byte 18 of 29"
4691 ),
4692 "{cut}"
4693 );
4694 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
4695 assert!(
4696 cut.contains("comment_budget=262144 reads the whole file"),
4697 "{cut}"
4698 );
4699 }
4700
4701 #[test]
4702 fn a_cut_never_splits_a_character() {
4703 let read = read("ééééé".as_bytes(), 5).unwrap();
4705 assert_eq!(read.text, "éé");
4706 assert_eq!(read.omitted_bytes, 6);
4707 assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
4709 }
4710
4711 #[test]
4712 fn the_largest_budget_reads_the_largest_file_whole() {
4713 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
4714 assert_eq!(max as u64, crate::govlog::MAX_DATA_FILE_BYTES);
4715 let big = "x".repeat(max);
4716 let read = read(big.as_bytes(), max).unwrap();
4717 assert_eq!((read.omitted_bytes, read.cut), (0, None));
4718 }
4719
4720 #[test]
4721 fn binary_is_not_text() {
4722 let err = read(&[0xff, 0xfe, 0x00], 32).unwrap_err();
4723 assert_eq!(err.bytes, 3);
4724 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
4725 }
4726}
4727
4728#[cfg(test)]
4729mod data_set_view_tests {
4730 use super::DataSetView;
4731 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
4732
4733 fn set(n: usize, dirs: usize) -> DataSet {
4735 let mut files: Vec<DataFile> = (0..n)
4736 .map(|i| {
4737 DataFile::of(
4738 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
4739 .parse()
4740 .unwrap(),
4741 b"x\n",
4742 )
4743 })
4744 .collect();
4745 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
4746 files.sort_by(|a, b| a.path.cmp(&b.path));
4747 DataSet {
4748 agora_data_set: DATA_SET_VERSION,
4749 title: "t".into(),
4750 description: "d".into(),
4751 compiled_by: "c".into(),
4752 repo: "agora-data".into(),
4753 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
4754 index: "votes/README.md".parse().unwrap(),
4755 files,
4756 }
4757 }
4758
4759 #[test]
4760 fn a_small_set_lists_every_file() {
4761 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
4762 assert_eq!(view.listing.len(), 5);
4763 assert!(view.listing.iter().all(|l| l.files == 1));
4764 assert_eq!((view.files, view.unlisted), (5, 0));
4765 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
4766 assert!(!view.how_to_read.contains("counted, not named"));
4767 }
4768
4769 #[test]
4770 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
4771 let big = set(1_038, 12);
4772 let view = DataSetView::of(&big, None, 32_768).unwrap();
4773 assert_eq!(view.files, 1_039);
4776 let paths: Vec<&str> =
4777 view.listing.iter().map(|l| l.path.as_str()).collect();
4778 assert_eq!(paths.len(), 13, "{paths:?}");
4779 assert!(paths.contains(&"votes/README.md"), "{paths:?}");
4780 assert!(paths.contains(&"votes/d11/"), "{paths:?}");
4781 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4782 assert!(view.how_to_read.contains("counted, not named"));
4783 assert!(
4784 view.how_to_read.starts_with(
4785 "Read this first: attachment=\"votes/README.md\"."
4786 )
4787 );
4788
4789 let tight = DataSetView::of(&big, None, 300).unwrap();
4791 assert_eq!(tight.listing.len(), 1);
4792 assert_eq!(tight.listing[0].path, "votes/");
4793 assert_eq!(tight.listing[0].files, 1_039);
4794
4795 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
4796 let paths: Vec<&str> =
4797 votes.listing.iter().map(|l| l.path.as_str()).collect();
4798 assert_eq!(paths.len(), 13, "{paths:?}");
4799 assert!(
4800 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
4801 );
4802 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4803 assert_eq!(votes.unlisted, 0);
4804
4805 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
4807 assert!(d00.listing.iter().all(|l| l.files == 1));
4808 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
4809 }
4810
4811 #[test]
4814 fn a_few_top_directories_descend_together() {
4815 let mut big = set(1_038, 12);
4816 for f in big.files.iter_mut() {
4817 let p = f.path.as_str();
4818 let moved = match p.strip_prefix("votes/d0") {
4819 Some(rest) => format!("early/d0{rest}"),
4820 None => p.to_string(),
4821 };
4822 f.path = moved.parse().unwrap();
4823 }
4824 big.files.sort_by(|a, b| a.path.cmp(&b.path));
4825 let view = DataSetView::of(&big, None, 32_768).unwrap();
4826 let paths: Vec<&str> =
4827 view.listing.iter().map(|l| l.path.as_str()).collect();
4828 assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
4829 assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
4830 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4831 assert_eq!(view.unlisted, 0);
4832 }
4833
4834 #[test]
4835 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
4836 let flat = set(1_000, 1);
4837 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
4838 assert!(view.unlisted > 0);
4839 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
4840 assert!(view.how_to_read.contains("did not fit at all"));
4841 let json = serde_json::to_string(&view).unwrap();
4843 assert!(!json.contains("sha256"), "{json}");
4844 }
4845}