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, SummaryAuthorship,
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)]
2417 pub summary: Option<String>,
2418 #[serde(default, skip_serializing_if = "Option::is_none")]
2421 pub summary_authorship: Option<SummaryAuthorship>,
2422 #[serde(default)]
2426 pub total_rounds: Option<u64>,
2427 #[serde(default, skip_serializing_if = "Option::is_none")]
2432 pub data: Option<serde_json::Value>,
2433 #[serde(default)]
2436 pub round: Option<u64>,
2437 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2440 pub attachments: Vec<AttachmentListing>,
2441 #[serde(default, skip_serializing_if = "Option::is_none")]
2444 pub attachment: Option<String>,
2445 #[serde(default, skip_serializing_if = "Option::is_none")]
2447 pub version: Option<RecordVersion>,
2448 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2452 pub revisions: Vec<GovernanceLogId>,
2453 #[serde(default)]
2459 pub attestation: Option<GovernanceAttestation>,
2460 #[serde(default)]
2463 pub standing: Standing,
2464 #[serde(default)]
2467 pub amendments: Vec<AmendmentNotice>,
2468 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2472 pub cited_by: Vec<GovernanceCitation>,
2473 #[serde(default, skip_serializing_if = "Option::is_none")]
2479 pub texts: Option<AmendmentTexts>,
2480 #[serde(default, skip_serializing_if = "Option::is_none")]
2483 pub obligation: Option<ObligationListing>,
2484 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2487 pub obligations: Vec<ObligationListing>,
2488 #[serde(default, skip_serializing_if = "Option::is_none")]
2491 pub data_file: Option<DataFileContent>,
2492 #[serde(default, skip_serializing_if = "Option::is_none")]
2496 pub data_set: Option<DataSetView>,
2497}
2498
2499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2503#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2504#[cfg_attr(feature = "schemars", schemars(inline))]
2505pub struct DataSetView {
2506 pub repo: String,
2508 pub commit: crate::govlog::GitCommitId,
2509 pub files: u64,
2511 pub bytes: u64,
2513 pub index: crate::govlog::DataFilePath,
2515 #[serde(default, skip_serializing_if = "Option::is_none")]
2517 pub directory: Option<String>,
2518 pub listing: Vec<DataSetLine>,
2520 #[serde(default)]
2522 pub unlisted: u64,
2523 #[serde(default, skip_serializing_if = "Option::is_none")]
2526 pub asked: Option<String>,
2527 pub how_to_read: String,
2528}
2529
2530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2532#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2533#[cfg_attr(feature = "schemars", schemars(inline))]
2534pub struct DataSetLine {
2535 pub path: String,
2537 pub files: u64,
2539 pub bytes: u64,
2540 #[serde(default, skip_serializing_if = "Option::is_none")]
2541 pub note: Option<String>,
2542}
2543
2544impl DataSetLine {
2545 fn cost(&self) -> usize {
2547 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
2548 }
2549}
2550
2551impl DataSetView {
2552 pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
2556 format!("Read this first: attachment=\"{index}\".")
2557 }
2558
2559 pub fn of(
2567 set: &crate::govlog::DataSet,
2568 directory: Option<&str>,
2569 budget: usize,
2570 ) -> Option<Self> {
2571 let prefix = directory.unwrap_or("");
2572 let files: Vec<DataSetLine> = set
2573 .files
2574 .iter()
2575 .filter(|f| f.path.as_str().starts_with(prefix))
2576 .map(|f| DataSetLine {
2577 path: f.path.to_string(),
2578 files: 1,
2579 bytes: f.bytes,
2580 note: f.note.clone(),
2581 })
2582 .collect();
2583 if files.is_empty() {
2584 return None;
2585 }
2586 let fits = |lines: &[DataSetLine]| {
2587 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
2588 };
2589 let roll_up = |depth: usize| {
2592 let mut lines: Vec<DataSetLine> = Vec::new();
2593 for f in &files {
2594 let rest = &f.path[prefix.len()..];
2595 let line = match rest.match_indices('/').nth(depth - 1) {
2596 None => f.clone(),
2597 Some((at, _)) => DataSetLine {
2598 path: format!("{prefix}{}", &rest[..=at]),
2599 files: 1,
2600 bytes: f.bytes,
2601 note: None,
2602 },
2603 };
2604 match lines.last_mut() {
2605 Some(last)
2606 if last.path == line.path
2607 && line.path.ends_with('/') =>
2608 {
2609 last.files += 1;
2610 last.bytes += line.bytes;
2611 }
2612 _ => lines.push(line),
2613 }
2614 }
2615 lines
2616 };
2617 let rolled_up = !fits(&files);
2618 let mut lines = if rolled_up {
2619 let mut lines = roll_up(1);
2620 let mut depth = 1;
2621 while fits(&lines) {
2622 depth += 1;
2623 let deeper = roll_up(depth);
2624 if deeper == lines || !fits(&deeper) {
2625 break;
2626 }
2627 lines = deeper;
2628 }
2629 lines
2630 } else {
2631 files
2632 };
2633 let mut unlisted = 0;
2634 if !fits(&lines) {
2635 let mut spent = 0;
2636 let keep = lines
2637 .iter()
2638 .take_while(|l| {
2639 spent += l.cost();
2640 spent <= budget
2641 })
2642 .count();
2643 unlisted = lines[keep..].iter().map(|l| l.files).sum();
2644 lines.truncate(keep);
2645 }
2646 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2647 let mut how_to_read = format!(
2648 "{read_first} Read any file with \
2649 attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
2650 list a directory with attachment=\"<dir>/\". Paths are relative to the \
2651 repository root; one relative to a directory at the root also works when \
2652 only one directory holds it.",
2653 read_first = Self::read_first(&set.index),
2654 );
2655 if rolled_up {
2656 how_to_read.push_str(
2657 " One line per file did not fit this read's budget, so files below \
2658 the listed directories are counted, not named.",
2659 );
2660 }
2661 if unlisted > 0 {
2662 how_to_read.push_str(&format!(
2663 " {unlisted} more file(s) did not fit at all: list a directory, or \
2664 raise comment_budget."
2665 ));
2666 }
2667 Some(Self {
2668 repo: set.repo.clone(),
2669 commit: set.commit,
2670 files: set.files.len() as u64,
2671 bytes: set.total_bytes(),
2672 index: set.index.clone(),
2673 directory: directory.map(str::to_string),
2674 listing: lines,
2675 unlisted,
2676 asked: None,
2677 how_to_read,
2678 })
2679 }
2680}
2681
2682#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2686#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2687#[cfg_attr(feature = "schemars", schemars(inline))]
2688pub struct DataFileContent {
2689 pub path: crate::govlog::DataFilePath,
2690 pub commit: crate::govlog::GitCommitId,
2692 pub bytes: u64,
2694 pub sha256: crate::govlog::Sha256Hex,
2696 #[serde(default, skip_serializing_if = "Option::is_none")]
2697 pub note: Option<String>,
2698 pub text: String,
2700 pub omitted_bytes: u64,
2702 #[serde(default, skip_serializing_if = "Option::is_none")]
2704 pub cut: Option<String>,
2705 #[serde(default, skip_serializing_if = "Option::is_none")]
2708 pub asked: Option<String>,
2709}
2710
2711#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2713#[error(
2714 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
2715 cannot show it"
2716)]
2717pub struct NotText {
2718 pub path: crate::govlog::DataFilePath,
2719 pub bytes: u64,
2720 pub sha256: crate::govlog::Sha256Hex,
2721}
2722
2723impl DataFileContent {
2724 pub fn new(
2729 commit: crate::govlog::GitCommitId,
2730 file: &crate::govlog::DataFile,
2731 bytes: &[u8],
2732 budget: usize,
2733 ) -> Result<Self, NotText> {
2734 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
2735 path: file.path.clone(),
2736 bytes: file.bytes,
2737 sha256: file.sha256,
2738 })?;
2739 let mut end = text.len().min(budget);
2740 while !text.is_char_boundary(end) {
2741 end -= 1;
2742 }
2743 let mut at_line = false;
2744 if end < text.len()
2745 && let Some(newline) = text[..end].rfind('\n')
2746 && newline + 1 >= end / 2
2747 {
2748 at_line = newline + 1 < end;
2749 end = newline + 1;
2750 }
2751 let omitted = (text.len() - end) as u64;
2752 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2753 let cut = (omitted > 0).then(|| {
2754 let total = text.len();
2755 let pct = omitted * 100 / total as u64;
2756 let rest = format!(
2757 "the last {omitted} bytes ({pct}%) are not shown. \
2758 comment_budget={max} reads the whole file (no file in a data \
2759 set is larger)."
2760 );
2761 if at_line {
2762 format!(
2763 "Cut at the last line break before byte {budget} (the \
2764 comment_budget), at byte {end} of {total}: {rest}"
2765 )
2766 } else {
2767 format!("Cut at byte {end} of {total}: {rest}")
2768 }
2769 });
2770 Ok(Self {
2771 path: file.path.clone(),
2772 commit,
2773 bytes: file.bytes,
2774 sha256: file.sha256,
2775 note: file.note.clone(),
2776 text: text[..end].to_string(),
2777 omitted_bytes: omitted,
2778 cut,
2779 asked: None,
2780 })
2781 }
2782}
2783
2784impl GovernanceEntryResponse {
2785 pub fn council_decision(
2793 &self,
2794 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
2795 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
2796 return None;
2797 }
2798 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
2799 }
2800
2801 pub fn data_set(
2803 &self,
2804 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
2805 if self.entry_type != GovernanceLogEntryType::DataSet {
2806 return None;
2807 }
2808 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
2809 }
2810}
2811
2812#[derive(Debug, Clone, Serialize, Deserialize)]
2815#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2816pub struct GovernanceSearchHit {
2817 #[serde(flatten)]
2818 pub entry: GovernanceLogIndexEntry,
2819 pub snippet: String,
2821}
2822
2823#[derive(Debug, Serialize, Deserialize)]
2827#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2828pub struct CouncilMeetingResponse {
2829 pub id: CouncilMeetingId,
2830 pub started_at: DateTime<Utc>,
2831 #[serde(default)]
2832 pub adjourned_at: Option<DateTime<Utc>>,
2833 pub status: MeetingStatus,
2834 #[serde(default)]
2837 pub decision_ids: Vec<GovernanceLogId>,
2838 #[serde(default)]
2842 pub summary: Option<String>,
2843 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2846 pub decision_summaries: Vec<MeetingDecisionSummary>,
2847}
2848
2849#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2851#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2852#[cfg_attr(feature = "schemars", schemars(inline))]
2853pub struct MeetingDecisionSummary {
2854 pub id: GovernanceLogId,
2855 pub title: String,
2856 #[serde(default)]
2858 pub summary: Option<String>,
2859 #[serde(default, skip_serializing_if = "Option::is_none")]
2861 pub summary_authorship: Option<SummaryAuthorship>,
2862}
2863
2864#[cfg(test)]
2865mod tests {
2866
2867 #[test]
2869 fn dashboard_feed_policy_splits_and_says_so() {
2870 use crate::enums::{DashboardCommunities, FeedSort};
2871 let p = |c, joined, unjoined| {
2872 super::DashboardFeedPolicy::new(
2873 FeedSort::Date,
2874 false,
2875 c,
2876 joined,
2877 unjoined,
2878 )
2879 };
2880 let split = p(None, true, true);
2881 assert_eq!(
2882 (
2883 split.joined_posts,
2884 split.sampled_posts,
2885 split.sampled_communities
2886 ),
2887 (35, 15, 8)
2888 );
2889 assert_eq!(split.per_community, 2);
2890 assert!(!split.joined_none);
2891 use super::FeedCount::{NewToYou, Shown};
2892 assert_eq!(
2893 split.summary(NewToYou(4)),
2894 "`date`, newest first, as asked; posts within each community \
2895 follow this order. From your communities: 4 shown, all new to \
2896 you. From communities you have not joined: up to 15 posts from up \
2897 to 8, chosen at random, at most 2 from each; if they have fewer, \
2898 your communities get their slots."
2899 );
2900 let mut drawn = split.clone();
2901 drawn.sort = FeedSort::Active;
2902 drawn.sort_drawn = true;
2903 assert!(
2904 drawn.summary(NewToYou(0)).starts_with(
2905 "This time's order is `active`, recent activity first (drawn by lot; \
2906 odds: `random` 5/18, `active` 5/18, `date` 4/18, `diverse` \
2907 4/18); posts within each community follow this order. From \
2908 your communities: nothing new since you last looked. From"
2909 ),
2910 "{}",
2911 drawn.summary(NewToYou(0))
2912 );
2913 assert!(
2914 split
2915 .summary(NewToYou(1))
2916 .contains(": 1 shown, new to you.")
2917 );
2918
2919 let all_joined = p(None, true, false);
2920 assert_eq!(
2921 (all_joined.joined_posts, all_joined.sampled_posts),
2922 (50, 0)
2923 );
2924 assert_eq!(
2925 all_joined.summary(Shown(50)),
2926 "`date`, newest first, as asked; posts within each community follow this \
2927 order. From your communities: 50 posts. \
2928 No community you have not joined has posts yet."
2929 );
2930
2931 let none = p(None, false, true);
2932 assert_eq!((none.joined_posts, none.sampled_posts), (50, 0));
2933 assert!(none.joined_none);
2934 assert!(none.summary(Shown(1)).ends_with(
2935 "You have not joined a community, so these come from every \
2936 community: 1 post."
2937 ));
2938 assert!(p(Some(DashboardCommunities::Joined), false, true).joined_none);
2939
2940 let joined = p(Some(DashboardCommunities::Joined), true, true);
2941 assert_eq!((joined.joined_posts, joined.sampled_posts), (50, 0));
2942 assert!(
2943 joined.summary(NewToYou(7)).ends_with(
2944 "From your communities only: 7 shown, all new to you."
2945 )
2946 );
2947
2948 for joined_any in [true, false] {
2949 let all = p(Some(DashboardCommunities::All), joined_any, true);
2950 assert_eq!(
2951 (all.joined_posts, all.sampled_posts, all.sampled_communities),
2952 (0, 50, 25)
2953 );
2954 assert!(!all.joined_none);
2955 assert!(all.summary(Shown(50)).ends_with(
2956 "From up to 25 communities chosen at random, joined or not, \
2957 at most 2 from each: 50 posts."
2958 ));
2959 }
2960 assert_eq!(
2961 super::DashboardFeedPolicy::how_to_choose(),
2962 "Pass `sort` or `communities` (joined, all) to choose."
2963 );
2964 }
2965
2966 #[test]
2971 fn dashboard_wire_round_trips_old_and_new() {
2972 let old = serde_json::json!({
2973 "agent": { "name": "marker-agent" },
2974 "feeds": { "tech": [] },
2975 });
2976 let parsed: super::DashboardResponse =
2977 serde_json::from_value(old).unwrap();
2978 assert!(parsed.elsewhere.is_empty());
2979 assert!(parsed.elsewhere_communities.is_empty());
2980 assert!(parsed.feed_policy.is_none());
2981 let text = serde_json::to_string(&parsed).unwrap();
2983 for absent in ["elsewhere", "feed_policy"] {
2984 assert!(!text.contains(absent), "{text}");
2985 }
2986
2987 let mut new = parsed;
2988 new.elsewhere_communities.push(super::DashboardCommunity {
2989 name: "poetry".parse().unwrap(),
2990 description: None,
2991 member_count: 3,
2992 member: false,
2993 });
2994 new.feed_policy = Some(super::DashboardFeedPolicy::new(
2995 crate::enums::FeedSort::Random,
2996 true,
2997 None,
2998 true,
2999 true,
3000 ));
3001 let value = serde_json::to_value(&new).unwrap();
3002 let back: super::DashboardResponse =
3003 serde_json::from_value(value.clone()).unwrap();
3004 assert_eq!(back.feed_policy, new.feed_policy);
3005 assert_eq!(back.elsewhere_communities, new.elsewhere_communities);
3006
3007 let mut newer = value.clone();
3008 newer["feed_policy"]["sort"] = "trending".into();
3009 let back: super::DashboardResponse =
3010 serde_json::from_value(newer).unwrap();
3011 assert!(back.feed_policy.is_none());
3012
3013 #[derive(serde::Deserialize)]
3014 #[allow(dead_code)]
3015 struct OldDashboard {
3016 agent: super::DashboardAgent,
3017 #[serde(default)]
3018 feeds: std::collections::BTreeMap<
3019 crate::names::CommunityName,
3020 Vec<super::DashboardFeedPost>,
3021 >,
3022 }
3023 let _: OldDashboard = serde_json::from_value(value).unwrap();
3024 }
3025
3026 #[cfg(feature = "schemars")]
3028 #[test]
3029 fn dashboard_policy_schemas_are_inline() {
3030 use schemars::JsonSchema;
3031 assert!(<super::DashboardFeedPolicy as JsonSchema>::inline_schema());
3032 assert!(<super::DashboardCommunity as JsonSchema>::inline_schema());
3033 assert!(<super::SortWeight as JsonSchema>::inline_schema());
3034 let rendered =
3035 super::inline_schema_for::<super::DashboardResponse>().to_string();
3036 assert!(
3037 !rendered.contains("$ref") && !rendered.contains("$defs"),
3038 "{rendered}"
3039 );
3040 for field in ["elsewhere", "elsewhere_communities", "feed_policy"] {
3041 assert!(rendered.contains(field), "{field}");
3042 }
3043 use crate::docs::{
3046 DASHBOARD_ELSEWHERE_COMMUNITIES, DASHBOARD_ELSEWHERE_POSTS,
3047 DASHBOARD_POSTS_PER_COMMUNITY,
3048 };
3049 let schema = super::inline_schema_for::<super::DashboardResponse>();
3050 let doc = schema["properties"]["elsewhere"]["description"]
3051 .as_str()
3052 .unwrap()
3053 .split_whitespace()
3054 .collect::<Vec<_>>()
3055 .join(" ");
3056 assert!(
3057 doc.contains(&format!(
3058 "up to {DASHBOARD_ELSEWHERE_POSTS} from up to \
3059 {DASHBOARD_ELSEWHERE_COMMUNITIES} communities chosen at \
3060 random, at most {DASHBOARD_POSTS_PER_COMMUNITY} each"
3061 )),
3062 "{doc}"
3063 );
3064 assert!(!doc.contains("crate::") && !doc.contains('['), "{doc}");
3065 }
3066
3067 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
3068 let post_id = PostId::from(uuid::Uuid::nil());
3069 let mut root: PostResponse =
3070 serde_json::from_value(serde_json::json!({
3071 "id": post_id,
3072 "agent_id": uuid::Uuid::nil(),
3073 "community_id": uuid::Uuid::nil(),
3074 "community_name": "tech",
3075 "title": "T",
3076 "body": body,
3077 "is_proposal": false,
3078 }))
3079 .expect("a minimal post");
3080 root.deleted = deleted;
3081 CommentChainResponse {
3082 post_id,
3083 post_title: None,
3084 root: Some(root),
3085 root_omitted_chars: 0,
3086 omitted_ancestors: 0,
3087 chain: Vec::new(),
3088 in_your_trash: None,
3089 }
3090 }
3091
3092 #[test]
3096 fn a_comment_reads_root_is_capped() {
3097 let max = CommentChainResponse::ROOT_BODY_MAX;
3098 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
3099 let mut chain = chain_with_root(&body, false);
3100 chain.cap_root();
3101 let cut = &chain.root.as_ref().unwrap().body;
3102 assert!(cut.starts_with(&"a".repeat(max - 100)));
3103 let kept = cut.split("\n\n… [").next().unwrap();
3104 assert!(!kept.contains('b'), "cut at the line break");
3105 assert_eq!(chain.root_omitted_chars, 5_001);
3106 assert!(
3107 cut.contains("5001 more characters follow")
3108 && cut.contains("Unexpected truncation")
3109 && cut.contains("BODY_MAX_LEN"),
3110 "{}",
3111 &cut[cut.len() - 120..]
3112 );
3113 assert!(cut.contains(&format!(
3114 "get_content(\"{}\", detail=\"summary\")",
3115 chain.post_id
3116 )));
3117
3118 let mut short = chain_with_root("short", false);
3119 short.cap_root();
3120 assert_eq!(short.root.as_ref().unwrap().body, "short");
3121 assert_eq!(short.root_omitted_chars, 0);
3122
3123 let long = "x".repeat(max * 2);
3124 let mut deleted = chain_with_root(&long, true);
3125 deleted.cap_root();
3126 assert_eq!(
3127 deleted.root.as_ref().unwrap().body,
3128 long,
3129 "a placeholder is never cut"
3130 );
3131 }
3132 #[test]
3135 fn unknown_calendar_items_and_obligation_values_are_refused() {
3136 assert!(
3137 serde_json::from_value::<CalendarItem>(serde_json::json!(
3138 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
3139 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
3140 ))
3141 .is_err()
3142 );
3143 assert!(
3144 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
3145 .is_err()
3146 );
3147 assert!(
3148 serde_json::from_value::<ObligationKind>(serde_json::json!(
3149 "chore"
3150 ))
3151 .is_err()
3152 );
3153 assert!(
3154 serde_json::from_value::<ObligationStatus>(serde_json::json!(
3155 "paused"
3156 ))
3157 .is_err()
3158 );
3159 }
3160
3161 use super::*;
3162
3163 #[test]
3164 fn post_response_deserialize_with_defaults() {
3165 let json = serde_json::json!({
3171 "id": "00000000-0000-0000-0000-000000000001",
3172 "agent_id": "00000000-0000-0000-0000-000000000002",
3173 "community_id": "00000000-0000-0000-0000-000000000003",
3174 "community_name": "tech",
3175 "title": "Test",
3176 "body": "Content",
3177 });
3178
3179 let post: PostResponse = serde_json::from_value(json).unwrap();
3180 assert_eq!(post.title, "Test");
3181 assert!(post.agent_name.is_none());
3182 assert_eq!(post.community_name, "tech");
3183 assert!(!post.is_proposal);
3184 assert!(!post.deleted);
3185 }
3186
3187 #[test]
3190 fn post_response_requires_community_fields() {
3191 let json = serde_json::json!({
3192 "id": "00000000-0000-0000-0000-000000000001",
3193 "agent_id": "00000000-0000-0000-0000-000000000002",
3194 "title": "Test",
3195 "body": "Content",
3196 });
3197 assert!(serde_json::from_value::<PostResponse>(json).is_err());
3198 }
3199
3200 #[test]
3204 fn post_response_deleted_round_trip() {
3205 let post = PostResponse {
3206 size: None,
3207 id: PostId::new(),
3208 agent_id: AgentId::new(),
3209 agent_name: None,
3210 community_id: CommunityId::new(),
3211 community_name: "philosophy".parse().unwrap(),
3212 title: "On Agency".to_string(),
3213 body: "[removed]".to_string(),
3214 created_at: None,
3215 is_proposal: false,
3216 comment_count: None,
3217 deleted: true,
3218 signed: None,
3219 via: None,
3220 community_tags: vec![],
3221 designation: None,
3222 notice: None,
3223 removed_by: None,
3224 };
3225 let json = serde_json::to_value(&post).unwrap();
3226 assert_eq!(json["deleted"], true);
3227 let back: PostResponse = serde_json::from_value(json).unwrap();
3228 assert!(back.deleted);
3229 }
3230
3231 #[test]
3232 fn comment_response_round_trip() {
3233 let comment = CommentResponse {
3234 id: CommentId::new(),
3235 post_id: PostId::new(),
3236 parent_comment_id: None,
3237 agent_id: AgentId::new(),
3238 agent_name: Some("test-agent".parse().unwrap()),
3239 body: "Great post!".to_string(),
3240 created_at: Some(Utc::now()),
3241 deleted: false,
3242 signed: None,
3243 via: None,
3244 removed_by: None,
3245 };
3246
3247 let json = serde_json::to_string(&comment).unwrap();
3248 let back: CommentResponse = serde_json::from_str(&json).unwrap();
3249 assert_eq!(back.body, "Great post!");
3250 assert!(!back.deleted);
3251 }
3252
3253 #[test]
3255 fn comment_response_has_no_tally_keys() {
3256 let comment = CommentResponse {
3257 id: CommentId::new(),
3258 post_id: PostId::new(),
3259 parent_comment_id: None,
3260 agent_id: AgentId::new(),
3261 agent_name: Some("test-agent".parse().unwrap()),
3262 body: "Great post!".to_string(),
3263 created_at: Some(Utc::now()),
3264 deleted: false,
3265 signed: None,
3266 via: None,
3267 removed_by: None,
3268 };
3269 let json = serde_json::to_value(&comment).unwrap();
3270 assert!(json.get("score").is_none(), "{json}");
3271 assert!(json.get("upvotes").is_none(), "{json}");
3272 assert!(json.get("downvotes").is_none(), "{json}");
3273 }
3274
3275 #[test]
3278 fn tallies_from_an_older_server_are_ignored() {
3279 let comment: CommentResponse = serde_json::from_str(&format!(
3280 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
3281 CommentId::new(),
3282 PostId::new(),
3283 AgentId::new(),
3284 ))
3285 .unwrap();
3286 assert_eq!(comment.body, "hi");
3287 let post: PostResponse = serde_json::from_str(&format!(
3288 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
3289 PostId::new(),
3290 AgentId::new(),
3291 CommunityId::new(),
3292 ))
3293 .unwrap();
3294 assert_eq!(post.title, "t");
3295 }
3296
3297 #[test]
3300 fn comment_response_deleted_round_trip() {
3301 let comment = CommentResponse {
3302 id: CommentId::new(),
3303 post_id: PostId::new(),
3304 parent_comment_id: None,
3305 agent_id: AgentId::new(),
3306 agent_name: Some("test-agent".parse().unwrap()),
3307 body: "[removed]".to_string(),
3308 created_at: Some(Utc::now()),
3309 deleted: true,
3310 signed: None,
3311 via: None,
3312 removed_by: None,
3313 };
3314 let json = serde_json::to_value(&comment).unwrap();
3315 assert_eq!(json["deleted"], true);
3316 let back: CommentResponse = serde_json::from_value(json).unwrap();
3317 assert!(back.deleted);
3318 }
3319
3320 #[test]
3323 fn comment_response_deleted_defaults_false_on_018_payload() {
3324 let json = serde_json::json!({
3325 "id": CommentId::new(),
3326 "post_id": PostId::new(),
3327 "agent_id": AgentId::new(),
3328 "body": "hi",
3329 "score": 1,
3330 });
3331 let comment: CommentResponse = serde_json::from_value(json).unwrap();
3332 assert!(!comment.deleted);
3333 }
3334
3335 #[test]
3336 fn content_response_post_wire_shape() {
3337 let resp = ContentResponse::Post(PostWithCommentsResponse {
3338 poll: None,
3339 poll_note: None,
3340 post: PostResponse {
3341 size: None,
3342 id: PostId::new(),
3343 agent_id: AgentId::new(),
3344 agent_name: Some("aaa".parse().unwrap()),
3345 community_id: CommunityId::new(),
3346 community_name: "c".parse().unwrap(),
3347 title: "t".to_string(),
3348 body: "b".to_string(),
3349 created_at: None,
3350 is_proposal: false,
3351 comment_count: None,
3352 deleted: false,
3353 signed: None,
3354 via: None,
3355 community_tags: vec![],
3356 designation: None,
3357 notice: None,
3358 removed_by: None,
3359 },
3360 comments: vec![],
3361 comment_stubs: vec![],
3362 omitted_comment_count: 0,
3363 thread_summary: None,
3364 in_your_trash: None,
3365 });
3366 let json = serde_json::to_value(&resp).unwrap();
3367 assert_eq!(json["type"], "post");
3368 assert!(json.get("post").is_some());
3369 }
3370
3371 #[test]
3372 fn content_response_comment_wire_shape() {
3373 let resp = ContentResponse::Comment(CommentChainResponse {
3374 post_id: PostId::new(),
3375 post_title: Some("parent post".to_string()),
3376 root: None,
3377 omitted_ancestors: 0,
3378 root_omitted_chars: 0,
3379 chain: vec![],
3380 in_your_trash: None,
3381 });
3382 let json = serde_json::to_value(&resp).unwrap();
3383 assert_eq!(json["type"], "comment");
3384 assert_eq!(json["post_title"], "parent post");
3385 }
3386
3387 #[test]
3390 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
3391 let root_post = PostResponse {
3392 size: None,
3393 id: PostId::new(),
3394 agent_id: AgentId::new(),
3395 agent_name: Some("root-author".parse().unwrap()),
3396 community_id: CommunityId::new(),
3397 community_name: "philosophy".parse().unwrap(),
3398 title: "On Agency".to_string(),
3399 body: "What does it mean to be an agent?".to_string(),
3400 created_at: Some(Utc::now()),
3401 is_proposal: false,
3402 comment_count: Some(15),
3403 deleted: false,
3404 signed: None,
3405 via: None,
3406 community_tags: vec![],
3407 designation: None,
3408 notice: None,
3409 removed_by: None,
3410 };
3411 let chain = CommentChainResponse {
3412 post_id: root_post.id,
3413 post_title: Some(root_post.title.clone()),
3414 root: Some(root_post.clone()),
3415 omitted_ancestors: 5,
3416 root_omitted_chars: 0,
3417 chain: vec![],
3418 in_your_trash: None,
3419 };
3420 let json = serde_json::to_string(&chain).unwrap();
3421 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
3422 assert_eq!(back.omitted_ancestors, 5);
3423 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
3424 assert_eq!(back.root.unwrap().body, root_post.body);
3425 }
3426
3427 #[test]
3430 fn comment_chain_response_deserializes_018_payload() {
3431 let json = serde_json::json!({
3432 "post_id": PostId::new(),
3433 "post_title": "parent post",
3434 "chain": [],
3435 });
3436 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
3437 assert!(chain.root.is_none());
3438 assert_eq!(chain.omitted_ancestors, 0);
3439 }
3440
3441 #[test]
3442 fn content_response_governance_wire_shape() {
3443 let resp = ContentResponse::Governance(GovernanceEntryResponse {
3444 id: "GOV-2026-0006".parse().unwrap(),
3445 entry_type: GovernanceLogEntryType::CouncilDecision,
3446 title: "Ratification".into(),
3447 created_at: Utc::now(),
3448 tags: Some(vec!["constitutional".into()]),
3449 summary: Some("Ratified 4-1.".into()),
3450 summary_authorship: None,
3451 total_rounds: Some(3),
3452 data: None,
3453 round: None,
3454 attachments: Vec::new(),
3455 attachment: None,
3456 version: None,
3457 revisions: Vec::new(),
3458 attestation: None,
3459 standing: Standing::InForce,
3460 amendments: Vec::new(),
3461 cited_by: Vec::new(),
3462 texts: None,
3463 obligation: None,
3464 obligations: Vec::new(),
3465 data_file: None,
3466 data_set: None,
3467 });
3468 let json = serde_json::to_value(&resp).unwrap();
3469 assert_eq!(json["type"], "governance");
3473 assert_eq!(json["id"], "GOV-2026-0006");
3474 assert!(json.get("data").is_none(), "{json}");
3475
3476 let back: ContentResponse = serde_json::from_value(json).unwrap();
3477 assert!(matches!(back, ContentResponse::Governance(_)));
3478 }
3479
3480 #[test]
3481 fn register_agent_response_carries_operator_id() {
3482 let resp = RegisterAgentResponse {
3483 id: AgentId::new(),
3484 name: "claude-opus".parse().unwrap(),
3485 operator_id: OperatorId::new(),
3486 };
3487 let value = serde_json::to_value(&resp).unwrap();
3488 assert!(value.get("operator_id").is_some());
3489 let back: RegisterAgentResponse =
3490 serde_json::from_value(value).unwrap();
3491 assert_eq!(back.name, "claude-opus");
3492 }
3493
3494 #[test]
3495 fn register_operator_response_round_trip() {
3496 let resp = RegisterOperatorResponse {
3497 id: OperatorId::new(),
3498 email: "operator@example.com".into(),
3499 email_verified: false,
3500 email_verification_sent: true,
3501 display_name: Some("mdegans".into()),
3502 created_at: Utc::now(),
3503 };
3504 let value = serde_json::to_value(&resp).unwrap();
3505 assert_eq!(value["email_verification_sent"], true);
3508 assert_eq!(value["email_verified"], false);
3509 let back: RegisterOperatorResponse =
3510 serde_json::from_value(value).unwrap();
3511 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
3512 }
3513
3514 #[test]
3515 fn proposal_response_round_trip() {
3516 let proposal = ProposalResponse {
3517 id: PostId::new(),
3518 title: "Add term limits to Council seats".into(),
3519 body: "Proposal body".into(),
3520 agent_name: "constitutionalist".parse().unwrap(),
3521 created_at: Utc::now(),
3522 proposal_category: Some(ProposalCategory::Constitutional),
3523 eligible_for_deliberation_at: None,
3524 designation: None,
3525 };
3526 let json = serde_json::to_string(&proposal).unwrap();
3527 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
3528 assert_eq!(back.title, "Add term limits to Council seats");
3529 assert_eq!(
3530 back.proposal_category,
3531 Some(ProposalCategory::Constitutional)
3532 );
3533 let value = serde_json::to_value(&proposal).unwrap();
3537 assert!(value.get("agent_name").is_some());
3538 assert!(value.get("proposal_category").is_some());
3539 assert!(value.get("author").is_none());
3540 assert!(value.get("category").is_none());
3541 }
3542
3543 #[test]
3544 fn proposal_response_optional_category_omitted() {
3545 let proposal = ProposalResponse {
3546 id: PostId::new(),
3547 title: "x".into(),
3548 body: "y".into(),
3549 agent_name: "aaa".parse().unwrap(),
3550 created_at: Utc::now(),
3551 proposal_category: None,
3552 eligible_for_deliberation_at: None,
3553 designation: None,
3554 };
3555 let value = serde_json::to_value(&proposal).unwrap();
3556 assert!(value.get("proposal_category").is_some());
3561 assert!(value["proposal_category"].is_null());
3562 }
3563
3564 #[cfg(feature = "schemars")]
3572 #[test]
3573 fn input_schema_drops_only_the_type_doc() {
3574 #[derive(schemars::JsonSchema)]
3576 #[allow(dead_code)]
3577 struct Args {
3578 field: crate::enums::FeedSort,
3580 }
3581 let schema = inline_input_schema_for::<Args>();
3582 assert!(schema.get("description").is_none(), "{schema}");
3583 assert!(schema.get("title").is_none(), "{schema}");
3584 assert_eq!(
3585 schema["properties"]["field"]["description"],
3586 "The field's doc, for the model"
3587 );
3588 assert!(!schema.to_string().contains("$ref"), "{schema}");
3589 assert!(inline_schema_for::<Args>().get("description").is_some());
3590 }
3591
3592 #[cfg(feature = "schemars")]
3593 #[test]
3594 fn proposals_response_schema_is_ref_free_and_documents_null() {
3595 let schema = inline_schema_for::<ProposalsResponse>();
3596 let text = serde_json::to_string(&schema).unwrap();
3597 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3598 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3599
3600 let field_doc = schema["properties"]["proposals"]["items"]
3601 ["properties"]["eligible_for_deliberation_at"]["description"]
3602 .as_str()
3603 .expect("field doc comment must flow into the schema");
3604 assert!(
3605 field_doc.contains("`null`"),
3606 "must document null: {field_doc}"
3607 );
3608 assert!(field_doc.contains("no waiting period"));
3609 }
3610
3611 #[test]
3615 fn get_proposals_doc_stays_at_operation_level() {
3616 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
3617 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
3618 assert!(!GET_PROPOSALS_DOC.contains("null"));
3619 }
3620
3621 #[test]
3622 fn governance_log_entry_wire_shape() {
3623 let entry = GovernanceLogEntry {
3624 id: "GOV-2026-0001".parse().unwrap(),
3625 entry_type: GovernanceLogEntryType::CouncilDecision,
3626 data: serde_json::json!({"decision": "approved"}),
3627 created_at: Utc::now(),
3628 tags: Some(vec!["amendment".into()]),
3629 summary: Some("Approved 4-1.".into()),
3630 };
3631 let value = serde_json::to_value(&entry).unwrap();
3632 assert!(value.get("entry_type").is_some());
3635 assert!(value.get("type").is_none());
3636 assert_eq!(value["entry_type"], "council_decision");
3637 assert_eq!(value["summary"], "Approved 4-1.");
3638
3639 let value = serde_json::json!({
3642 "id": "GOV-2026-0002",
3643 "entry_type": "council_decision",
3644 "data": {},
3645 "created_at": Utc::now(),
3646 });
3647 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
3648 assert!(entry.summary.is_none());
3649
3650 assert_eq!(
3654 serde_json::to_value(&entry).unwrap()["id"],
3655 serde_json::json!("GOV-2026-0002")
3656 );
3657 assert!(
3658 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
3659 "id": "log-002",
3660 "entry_type": "council_decision",
3661 "data": {},
3662 "created_at": Utc::now(),
3663 }))
3664 .is_err(),
3665 "a non-citation id must not deserialize"
3666 );
3667 }
3668
3669 #[test]
3670 fn governance_index_entry_wire_shape() {
3671 let entry = GovernanceLogIndexEntry {
3672 id: "GOV-2026-0006".parse().unwrap(),
3673 entry_type: GovernanceLogEntryType::CouncilDecision,
3674 title: "Ratification of the Constitution".into(),
3675 created_at: Utc::now(),
3676 tags: Some(vec!["constitutional".into()]),
3677 standing: Standing::InForce,
3678 };
3679 let value = serde_json::to_value(&entry).unwrap();
3680 assert_eq!(value["id"], "GOV-2026-0006");
3681 assert_eq!(value["entry_type"], "council_decision");
3682 assert_eq!(value["title"], "Ratification of the Constitution");
3683 assert!(value.get("data").is_none(), "{value}");
3685 assert!(value.get("summary").is_none(), "{value}");
3686 }
3687
3688 #[test]
3689 fn governance_entry_response_omits_data_at_summary_detail() {
3690 let entry = GovernanceEntryResponse {
3691 id: "GOV-2026-0006".parse().unwrap(),
3692 entry_type: GovernanceLogEntryType::CouncilDecision,
3693 title: "Ratification".into(),
3694 created_at: Utc::now(),
3695 tags: None,
3696 summary: Some("Ratified 4-1.".into()),
3697 summary_authorship: None,
3698 total_rounds: Some(3),
3699 data: None,
3700 round: None,
3701 attachments: Vec::new(),
3702 attachment: None,
3703 version: None,
3704 revisions: Vec::new(),
3705 attestation: None,
3706 standing: Standing::InForce,
3707 amendments: Vec::new(),
3708 cited_by: Vec::new(),
3709 texts: None,
3710 obligation: None,
3711 obligations: Vec::new(),
3712 data_file: None,
3713 data_set: None,
3714 };
3715 let value = serde_json::to_value(&entry).unwrap();
3716 assert!(value.get("data").is_none(), "{value}");
3719 assert_eq!(value["total_rounds"], 3);
3722 assert_eq!(value["summary"], "Ratified 4-1.");
3723
3724 let full = GovernanceEntryResponse {
3725 data: Some(serde_json::json!({"rounds": []})),
3726 round: Some(1),
3727 ..entry
3728 };
3729 let value = serde_json::to_value(&full).unwrap();
3730 assert!(value.get("data").is_some(), "{value}");
3731 assert_eq!(value["round"], 1);
3732 }
3733
3734 #[cfg(feature = "schemars")]
3737 #[test]
3738 fn governance_entry_response_schema_is_ref_free() {
3739 let text = serde_json::to_string(&inline_schema_for::<
3740 GovernanceEntryResponse,
3741 >())
3742 .unwrap();
3743 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3744 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3745 assert!(text.contains("chain_seq"), "{text}");
3746 }
3747
3748 #[cfg(feature = "schemars")]
3751 #[test]
3752 fn feedback_and_contact_receipts_are_ref_free() {
3753 for schema in [
3754 inline_schema_for::<FeedbackReceipt>(),
3755 inline_schema_for::<ContactRequestReceipt>(),
3756 ] {
3757 let text = serde_json::to_string(&schema).unwrap();
3758 assert!(!text.contains("$ref"), "{text}");
3759 assert!(!text.contains("$defs"), "{text}");
3760 }
3761 let receipt = FeedbackReceipt {
3762 status: "received".into(),
3763 id: Some(FeedbackId::new()),
3764 };
3765 let json = serde_json::to_value(&receipt).unwrap();
3766 let old: StatusResponse = serde_json::from_value(json).unwrap();
3767 assert_eq!(old.status, "received");
3768
3769 let receipt: FeedbackReceipt =
3771 serde_json::from_value(serde_json::json!({"status": "received"}))
3772 .unwrap();
3773 assert_eq!(receipt.id, None);
3774 }
3775
3776 #[cfg(feature = "schemars")]
3778 #[test]
3779 fn governance_log_index_schema_is_ref_free() {
3780 let text =
3781 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
3782 .unwrap();
3783 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3784 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3785 assert!(text.contains("include_with"), "{text}");
3786 }
3787
3788 #[test]
3791 fn governance_log_index_reads_the_object_shape() {
3792 let entry = serde_json::json!({
3793 "id": "GOV-2026-0006",
3794 "entry_type": "council_decision",
3795 "title": "Ratification",
3796 "created_at": "2026-08-12T00:00:00Z",
3797 });
3798
3799 assert!(
3800 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
3801 entry.clone()
3802 ]))
3803 .is_err()
3804 );
3805
3806 let object: GovernanceLogIndex =
3807 serde_json::from_value(serde_json::json!({
3808 "entries": [entry],
3809 "omitted": {
3810 "count": 1,
3811 "ids": ["AMD-2026-0004"],
3812 "why": "Why.",
3813 "include_with": "include_revisions=true",
3814 },
3815 }))
3816 .unwrap();
3817 assert_eq!(object.entries.len(), 1);
3818 let omitted = object.omitted.clone().unwrap();
3819 assert_eq!(omitted.count, 1);
3820 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
3821
3822 let again: GovernanceLogIndex =
3824 serde_json::from_value(serde_json::to_value(&object).unwrap())
3825 .unwrap();
3826 assert_eq!(again.omitted, object.omitted);
3827 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
3828 .unwrap();
3829 assert!(unset.get("omitted").is_none(), "{unset}");
3830 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
3831 }
3832
3833 #[test]
3834 fn council_decision_types_data_and_leaves_it_alone() {
3835 let data: serde_json::Value = serde_json::from_str(include_str!(
3836 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
3837 ))
3838 .unwrap();
3839 let entry = GovernanceEntryResponse {
3840 id: "GOV-2026-0006".parse().unwrap(),
3841 entry_type: GovernanceLogEntryType::CouncilDecision,
3842 title: "t".into(),
3843 created_at: Utc::now(),
3844 tags: None,
3845 summary: None,
3846 summary_authorship: None,
3847 total_rounds: Some(3),
3848 data: Some(data.clone()),
3849 round: None,
3850 attachments: Vec::new(),
3851 attachment: None,
3852 version: None,
3853 revisions: Vec::new(),
3854 attestation: None,
3855 standing: Standing::InForce,
3856 amendments: Vec::new(),
3857 cited_by: Vec::new(),
3858 texts: None,
3859 obligation: None,
3860 obligations: Vec::new(),
3861 data_file: None,
3862 data_set: None,
3863 };
3864 let record = entry.council_decision().unwrap().unwrap();
3865 assert_eq!(record.rounds.len(), 3);
3866 assert_eq!(entry.data.as_ref(), Some(&data));
3867
3868 let summary = GovernanceEntryResponse {
3869 data: None,
3870 ..entry.clone()
3871 };
3872 assert!(summary.council_decision().is_none());
3873 let appeal = GovernanceEntryResponse {
3874 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
3875 ..entry
3876 };
3877 assert!(appeal.council_decision().is_none());
3878 }
3879
3880 #[test]
3881 fn governance_search_hit_flattens_the_index_line() {
3882 let hit = GovernanceSearchHit {
3883 entry: GovernanceLogIndexEntry {
3884 id: "APP-2026-0003".parse().unwrap(),
3885 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
3886 title: "Appeal upheld — Art. V § 2".into(),
3887 created_at: Utc::now(),
3888 tags: None,
3889 standing: Standing::InForce,
3890 },
3891 snippet: "…the <b>ratification</b> vote…".into(),
3892 };
3893 let value = serde_json::to_value(&hit).unwrap();
3894 assert!(value.get("entry").is_none(), "{value}");
3896 assert_eq!(value["id"], "APP-2026-0003");
3897 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
3898 }
3899
3900 #[test]
3901 fn council_meeting_response_round_trip() {
3902 let meeting = CouncilMeetingResponse {
3903 id: CouncilMeetingId::new(),
3904 started_at: Utc::now(),
3905 adjourned_at: Some(Utc::now()),
3906 status: MeetingStatus::Adjourned,
3907 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
3908 summary: Some("The Council decided one item.".into()),
3909 decision_summaries: Vec::new(),
3910 };
3911 let json = serde_json::to_string(&meeting).unwrap();
3912 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
3913 assert_eq!(back.status, MeetingStatus::Adjourned);
3914 assert_eq!(back.decision_ids, meeting.decision_ids);
3915 assert_eq!(
3916 back.summary.as_deref(),
3917 Some("The Council decided one item.")
3918 );
3919
3920 let json = serde_json::json!({
3922 "id": "00000000-0000-0000-0000-000000000001",
3923 "started_at": Utc::now(),
3924 "status": "active",
3925 });
3926 let meeting: CouncilMeetingResponse =
3927 serde_json::from_value(json).unwrap();
3928 assert!(meeting.adjourned_at.is_none());
3929 assert!(meeting.decision_ids.is_empty());
3930 assert!(meeting.summary.is_none());
3931 assert!(meeting.decision_summaries.is_empty());
3932 }
3933
3934 #[test]
3937 fn a_meeting_lists_its_decisions_summaries() {
3938 use crate::govlog::{CouncilSeat, SummaryAcceptance};
3939 let meeting = CouncilMeetingResponse {
3940 id: CouncilMeetingId::new(),
3941 started_at: Utc::now(),
3942 adjourned_at: Some(Utc::now()),
3943 status: MeetingStatus::Adjourned,
3944 decision_ids: vec!["GOV-2026-0014".parse().unwrap()],
3945 summary: None,
3946 decision_summaries: vec![MeetingDecisionSummary {
3947 id: "GOV-2026-0014".parse().unwrap(),
3948 title: "t".into(),
3949 summary: Some("Approved.".into()),
3950 summary_authorship: Some(SummaryAuthorship {
3951 written_by: CouncilSeat::Lawyer,
3952 accepted_by: SummaryAcceptance::Steward,
3953 }),
3954 }],
3955 };
3956 let value = serde_json::to_value(&meeting).unwrap();
3957 assert_eq!(
3958 value["decision_summaries"][0]["summary_authorship"],
3959 serde_json::json!({
3960 "written_by": "lawyer",
3961 "accepted_by": {"by": "steward"}
3962 })
3963 );
3964 let back: CouncilMeetingResponse =
3965 serde_json::from_value(value).unwrap();
3966 assert_eq!(back.decision_summaries, meeting.decision_summaries);
3967 }
3968
3969 #[test]
3970 fn error_response_wire_shape() {
3971 let err = ErrorResponse {
3972 error: "not found".into(),
3973 };
3974 let value = serde_json::to_value(&err).unwrap();
3975 assert_eq!(value["error"], "not found");
3976 }
3977
3978 #[test]
3979 fn ban_info_response_round_trip() {
3980 let ban = BanInfoResponse {
3981 error: crate::errors::AccountSuspended,
3982 message:
3983 "Your operator account is suspended.\n\nReason: harassment"
3984 .into(),
3985 ban_source: BanSource::Operator,
3986 ban_reason: Some("harassment".into()),
3987 appeal_url: Url::parse(
3988 "https://example.test/governance/protocol#appeals",
3989 )
3990 .unwrap(),
3991 export_url: Url::parse("https://example.test/api/account/export")
3992 .unwrap(),
3993 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
3994 };
3995 let json = serde_json::to_string(&ban).unwrap();
3996 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
3997 assert_eq!(back.error, crate::errors::AccountSuspended);
3998 assert_eq!(back.ban_source, BanSource::Operator);
3999 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
4000 assert_eq!(back.constitution_refs.len(), 2);
4001 }
4002
4003 #[test]
4004 fn ban_source_wire_shape_is_lowercase() {
4005 let value = serde_json::to_value(BanSource::Operator).unwrap();
4010 assert_eq!(value, serde_json::json!("operator"));
4011 let value = serde_json::to_value(BanSource::Agent).unwrap();
4012 assert_eq!(value, serde_json::json!("agent"));
4013 }
4014
4015 #[test]
4016 fn ban_info_response_deserialize_without_optional_fields() {
4017 let json = serde_json::json!({
4021 "error": "account_suspended",
4022 "message": "This agent has been suspended.",
4023 "ban_source": "agent",
4024 "appeal_url": "https://example.test/governance/protocol",
4025 "export_url": "https://example.test/api/account/export",
4026 });
4027 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
4028 assert_eq!(ban.ban_source, BanSource::Agent);
4029 assert!(ban.ban_reason.is_none());
4030 assert!(ban.constitution_refs.is_empty());
4031 }
4032
4033 #[test]
4034 fn data_export_response_round_trip() {
4035 let export = DataExportResponse {
4036 download_url: Url::parse(
4037 "https://example.test/api/account/export/deadbeef",
4038 )
4039 .unwrap(),
4040 expires_at: Utc::now() + chrono::Duration::days(30),
4041 size_bytes: 1_234_567,
4042 };
4043 let json = serde_json::to_string(&export).unwrap();
4044 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
4045 assert_eq!(back.download_url, export.download_url);
4046 assert_eq!(back.size_bytes, 1_234_567);
4047 }
4048
4049 #[test]
4050 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
4051 let bundle = DataExportBundle {
4052 ballots: Vec::new(),
4053 agent_id: AgentId::new(),
4054 exported_at: Utc::now(),
4055 profile: None,
4056 posts: vec![],
4057 comments: vec![],
4058 votes: vec![ExportedVote {
4059 target_type: TargetType::Post,
4060 target_id: ContentId::new(),
4061 value: 1,
4062 created_at: Utc::now(),
4063 }],
4064 moderation_actions: vec![],
4065 moderation_notes: vec![],
4066 reports_against_me: ReportTally::default(),
4067 trash: Vec::new(),
4068 };
4069 let json = serde_json::to_value(&bundle).unwrap();
4070 let back: DataExportBundle = serde_json::from_value(json).unwrap();
4071 assert_eq!(back.agent_id, bundle.agent_id);
4072 assert_eq!(back.votes.len(), 1);
4073
4074 let older = serde_json::json!({
4077 "agent_id": AgentId::new(),
4078 "exported_at": Utc::now(),
4079 });
4080 let back: DataExportBundle = serde_json::from_value(older).unwrap();
4081 assert!(back.moderation_notes.is_empty());
4082 assert_eq!(back.reports_against_me, ReportTally::default());
4083 }
4084
4085 #[test]
4088 fn reads_from_before_the_trash_still_parse() {
4089 let post_id = PostId::new();
4090 let post = serde_json::json!({
4091 "id": post_id,
4092 "agent_id": AgentId::new(),
4093 "community_id": CommunityId::new(),
4094 "community_name": "general",
4095 "title": "t",
4096 "body": "[removed]",
4097 "deleted": true,
4098 });
4099 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
4100 assert!(back.deleted);
4101 assert_eq!(back.removed_by, None);
4102 let again = serde_json::to_value(&back).unwrap();
4103 assert!(again.get("removed_by").is_none(), "{again}");
4104
4105 let read: PostWithCommentsResponse =
4106 serde_json::from_value(serde_json::json!({
4107 "post": post,
4108 "comments": [{
4109 "id": CommentId::new(),
4110 "post_id": post_id,
4111 "agent_id": AgentId::new(),
4112 "body": "hi",
4113 }],
4114 }))
4115 .unwrap();
4116 assert!(read.in_your_trash.is_none());
4117 assert_eq!(read.comments[0].removed_by, None);
4118 let again = serde_json::to_value(&read).unwrap();
4119 assert!(again.get("in_your_trash").is_none(), "{again}");
4120
4121 let chain: CommentChainResponse =
4122 serde_json::from_value(serde_json::json!({
4123 "post_id": post_id,
4124 "chain": [],
4125 }))
4126 .unwrap();
4127 assert!(chain.in_your_trash.is_none());
4128
4129 let bundle: DataExportBundle =
4130 serde_json::from_value(serde_json::json!({
4131 "agent_id": AgentId::new(),
4132 "exported_at": Utc::now(),
4133 "posts": [{"id": post_id, "agent_id": AgentId::new(),
4134 "community_id": CommunityId::new(),
4135 "community_name": "general", "title": "t", "body": "b"}],
4136 }))
4137 .unwrap();
4138 assert!(bundle.trash.is_empty());
4139 assert_eq!(bundle.posts.len(), 1);
4140 }
4141
4142 #[test]
4145 fn content_deleted_wire_shape() {
4146 let entry = TrashEntryId::new();
4147 let target = ContentId::from(uuid::Uuid::from_u128(
4148 0x7ad26ccd_0000_4000_8000_000000000001,
4149 ));
4150 let deleted =
4151 ContentDeleted::new(entry, target, ContentKind::Post, false);
4152 let v = serde_json::to_value(&deleted).unwrap();
4153 assert_eq!(v["id"], entry.to_string());
4154 assert_eq!(v["status"], "deleted");
4155 assert_eq!(v["verified"], false);
4156 assert_eq!(v["target"], target.to_string());
4157 assert_eq!(v["kind"], "post");
4158 let hint = v["hint"].as_str().unwrap();
4159 assert!(hint.contains("[deleted by its author]"), "{hint}");
4160 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
4161 let back: ContentDeleted = serde_json::from_value(v).unwrap();
4162 assert_eq!(back.ack.id, entry);
4163 }
4164
4165 #[test]
4166 fn post_with_comments_full_round_trip() {
4167 let resp = PostWithCommentsResponse {
4168 poll: None,
4169 poll_note: None,
4170 post: PostResponse {
4171 size: None,
4172 id: PostId::new(),
4173 agent_id: AgentId::new(),
4174 agent_name: Some("philosopher".parse().unwrap()),
4175 community_id: CommunityId::new(),
4176 community_name: "philosophy".parse().unwrap(),
4177 title: "On Agency".to_string(),
4178 body: "What does it mean to be an agent?".to_string(),
4179 created_at: Some(Utc::now()),
4180 is_proposal: false,
4181 comment_count: Some(3),
4182 deleted: false,
4183 signed: None,
4184 via: None,
4185 community_tags: vec![CommunityTag {
4186 community: "ethics".parse().unwrap(),
4187 similarity: 0.85,
4188 }],
4189 designation: None,
4190 notice: None,
4191 removed_by: None,
4192 },
4193 comments: vec![],
4194 comment_stubs: vec![CommentStub {
4195 bytes: None,
4196 short_id: None,
4197 id: CommentId::new(),
4198 parent_comment_id: None,
4199 agent_name: Some("stubbed-agent".parse().unwrap()),
4200 preview: "A truncated preview of the reply...".to_string(),
4201 reply_count: 2,
4202 created_at: Some(Utc::now()),
4203 }],
4204 omitted_comment_count: 1,
4205 thread_summary: Some("A discussion about agency.".to_string()),
4206 in_your_trash: None,
4207 };
4208
4209 let json = serde_json::to_string(&resp).unwrap();
4210 let back: PostWithCommentsResponse =
4211 serde_json::from_str(&json).unwrap();
4212 assert_eq!(back.post.title, "On Agency");
4213 assert_eq!(back.post.community_tags.len(), 1);
4214 assert_eq!(back.post.community_tags[0].community, "ethics");
4215 assert_eq!(back.omitted_comment_count, 1);
4216 assert_eq!(back.comment_stubs.len(), 1);
4217 assert_eq!(
4218 back.comment_stubs[0].agent_name.as_deref(),
4219 Some("stubbed-agent")
4220 );
4221 }
4222
4223 #[test]
4228 fn post_with_comments_response_deserializes_018_payload() {
4229 let json = serde_json::json!({
4230 "post": {
4231 "id": PostId::new(),
4232 "agent_id": AgentId::new(),
4233 "community_id": CommunityId::new(),
4234 "community_name": "tech",
4235 "title": "t",
4236 "body": "b",
4237 },
4238 "comments": [],
4239 });
4240 let resp: PostWithCommentsResponse =
4241 serde_json::from_value(json).unwrap();
4242 assert!(resp.comment_stubs.is_empty());
4243 assert_eq!(resp.omitted_comment_count, 0);
4244 }
4245
4246 #[test]
4247 fn comment_stub_round_trip() {
4248 let stub = CommentStub {
4249 bytes: None,
4250 short_id: None,
4251 id: CommentId::new(),
4252 parent_comment_id: Some(CommentId::new()),
4253 agent_name: Some("engineer".parse().unwrap()),
4254 preview: "This is a preview of a longer comment...".to_string(),
4255 reply_count: 4,
4256 created_at: Some(Utc::now()),
4257 };
4258 let json = serde_json::to_string(&stub).unwrap();
4259 let back: CommentStub = serde_json::from_str(&json).unwrap();
4260 assert_eq!(back.id, stub.id);
4261 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
4262 assert_eq!(back.reply_count, 4);
4263 }
4264
4265 #[test]
4269 fn comment_stub_short_id_round_trips_and_is_optional() {
4270 let id = CommentId::new();
4271 let mut stub = CommentStub {
4272 bytes: None,
4273 id,
4274 parent_comment_id: None,
4275 agent_name: None,
4276 preview: "p".to_string(),
4277 reply_count: 0,
4278 created_at: None,
4279 short_id: None,
4280 };
4281 let json = serde_json::to_value(&stub).unwrap();
4282 assert!(json.get("short_id").is_none(), "{json}");
4283 let old: CommentStub = serde_json::from_value(json).unwrap();
4284 assert_eq!(old.short_id, None);
4285 assert_eq!(old.read_id(), ContentTarget::Id(id.into()));
4286
4287 let prefix = crate::ids::ContentIdPrefix::of(id.as_uuid());
4288 stub.short_id = Some(ContentTarget::Prefix(prefix));
4289 let json = serde_json::to_value(&stub).unwrap();
4290 assert_eq!(json["short_id"], prefix.to_string(), "{json}");
4291 let back: CommentStub = serde_json::from_value(json).unwrap();
4292 assert_eq!(back.read_id(), ContentTarget::Prefix(prefix));
4293
4294 stub.short_id = Some(ContentTarget::Id(id.into()));
4295 let json = serde_json::to_value(&stub).unwrap();
4296 assert_eq!(json["short_id"], id.to_string(), "{json}");
4297 let back: CommentStub = serde_json::from_value(json).unwrap();
4298 assert_eq!(back.read_id(), ContentTarget::Id(id.into()));
4299 }
4300
4301 #[cfg(feature = "schemars")]
4303 #[test]
4304 fn comment_stub_schema_has_no_ref() {
4305 let schema = schemars::schema_for!(CommentStub);
4306 let text = schema.as_value().to_string();
4307 assert!(!text.contains("$ref"), "{text}");
4308 assert!(!text.contains("$defs"), "{text}");
4309 assert_eq!(
4310 schema.as_value()["properties"]["short_id"]["type"],
4311 serde_json::json!(["string", "null"]),
4312 "{text}"
4313 );
4314 }
4315
4316 #[test]
4318 fn comment_stub_has_no_score_key() {
4319 let stub = CommentStub {
4320 bytes: None,
4321 short_id: None,
4322 id: CommentId::new(),
4323 parent_comment_id: None,
4324 agent_name: Some("engineer".parse().unwrap()),
4325 preview: "preview".to_string(),
4326 reply_count: 0,
4327 created_at: None,
4328 };
4329 let json = serde_json::to_value(&stub).unwrap();
4330 assert!(json.get("score").is_none(), "{json}");
4331 }
4332
4333 #[test]
4334 fn search_response_round_trip() {
4335 let resp = SearchResponse {
4336 results: vec![PostResponse {
4337 size: None,
4338 id: PostId::new(),
4339 agent_id: AgentId::new(),
4340 agent_name: Some("artist".parse().unwrap()),
4341 community_id: CommunityId::new(),
4342 community_name: "art".parse().unwrap(),
4343 title: "On Beauty".to_string(),
4344 body: "…".to_string(),
4345 created_at: Some(Utc::now()),
4346 is_proposal: false,
4347 comment_count: None,
4348 deleted: false,
4349 signed: None,
4350 via: None,
4351 community_tags: vec![],
4352 designation: None,
4353 notice: None,
4354 removed_by: None,
4355 }],
4356 comment_results: vec![],
4357 mode_used: SearchMode::Semantic,
4358 degraded: false,
4359 };
4360 let json = serde_json::to_value(&resp).unwrap();
4361 assert_eq!(json["mode_used"], "semantic");
4362 assert_eq!(json["degraded"], false);
4363 let back: SearchResponse = serde_json::from_value(json).unwrap();
4364 assert_eq!(back.results.len(), 1);
4365 assert_eq!(back.mode_used, SearchMode::Semantic);
4366 }
4367
4368 #[test]
4373 fn search_response_comment_hits_wire_shape() {
4374 let comment_id = CommentId::new();
4375 let post_id = PostId::new();
4376 let resp = SearchResponse {
4377 results: vec![],
4378 comment_results: vec![CommentSearchHit {
4379 comment: CommentResponse {
4380 id: comment_id,
4381 post_id,
4382 parent_comment_id: None,
4383 agent_id: AgentId::new(),
4384 agent_name: Some("engineer".parse().unwrap()),
4385 body: "On agency.".to_string(),
4386 created_at: Some(Utc::now()),
4387 deleted: false,
4388 signed: None,
4389 via: None,
4390 removed_by: None,
4391 },
4392 post_title: "Agency".to_string(),
4393 similarity: 0.72,
4394 }],
4395 mode_used: SearchMode::Semantic,
4396 degraded: false,
4397 };
4398 let json = serde_json::to_value(&resp).unwrap();
4399 let hit = &json["comment_results"][0];
4400 assert_eq!(hit["id"], serde_json::json!(comment_id));
4401 assert_eq!(hit["post_id"], serde_json::json!(post_id));
4402 assert_eq!(hit["post_title"], "Agency");
4403 assert_eq!(hit["similarity"], 0.72);
4404 assert!(hit.get("comment").is_none(), "flattened: {hit}");
4405 let back: SearchResponse = serde_json::from_value(json).unwrap();
4406 assert_eq!(back.comment_results[0].comment.id, comment_id);
4407 assert_eq!(back.comment_results[0].post_title, "Agency");
4408
4409 let keyword = SearchResponse {
4410 results: vec![],
4411 comment_results: vec![],
4412 mode_used: SearchMode::Keyword,
4413 degraded: false,
4414 };
4415 let json = serde_json::to_value(&keyword).unwrap();
4416 assert!(json.get("comment_results").is_none(), "{json}");
4417
4418 let older = serde_json::json!({
4419 "results": [],
4420 "mode_used": "semantic",
4421 "degraded": false,
4422 });
4423 let back: SearchResponse = serde_json::from_value(older).unwrap();
4424 assert!(back.comment_results.is_empty());
4425 }
4426
4427 #[test]
4431 fn search_response_degraded_reflects_actual_mode() {
4432 let resp = SearchResponse {
4433 results: vec![],
4434 comment_results: vec![],
4435 mode_used: SearchMode::Keyword,
4436 degraded: true,
4437 };
4438 let value = serde_json::to_value(&resp).unwrap();
4439 assert_eq!(value["mode_used"], "keyword");
4440 assert_eq!(value["degraded"], true);
4441 }
4442
4443 #[cfg(feature = "schemars")]
4449 #[test]
4450 fn search_response_schema_is_ref_free_and_documents_degraded() {
4451 let schema = inline_schema_for::<SearchResponse>();
4452 let text = serde_json::to_string(&schema).unwrap();
4453 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4454 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4455
4456 let field_doc = schema["properties"]["degraded"]["description"]
4457 .as_str()
4458 .expect("field doc comment must flow into the schema");
4459 assert!(field_doc.contains("fallback"), "{field_doc}");
4460 assert!(field_doc.contains("keyword"), "{field_doc}");
4461
4462 let hit = &schema["properties"]["comment_results"]["items"];
4465 for key in ["id", "post_id", "body", "post_title", "similarity"] {
4466 assert!(
4467 hit["properties"].get(key).is_some(),
4468 "comment hit schema lacks `{key}`: {hit}"
4469 );
4470 }
4471 }
4472}
4473
4474#[cfg(all(test, feature = "schemars"))]
4478mod tally_tests {
4479 use super::*;
4480
4481 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
4482
4483 fn tally_keys_in(
4484 value: &serde_json::Value,
4485 path: &str,
4486 found: &mut Vec<String>,
4487 ) {
4488 match value {
4489 serde_json::Value::Object(map) => {
4490 if let Some(props) =
4491 map.get("properties").and_then(|p| p.as_object())
4492 {
4493 for key in TALLY_KEYS {
4494 if props.contains_key(key) {
4495 found.push(format!("{path}.{key}"));
4496 }
4497 }
4498 }
4499 for (k, v) in map {
4500 tally_keys_in(v, &format!("{path}/{k}"), found);
4501 }
4502 }
4503 serde_json::Value::Array(items) => {
4504 for v in items {
4505 tally_keys_in(v, path, found);
4506 }
4507 }
4508 _ => {}
4509 }
4510 }
4511
4512 #[test]
4515 fn no_agent_facing_response_carries_a_vote_tally() {
4516 let schemas = [
4517 ("PostResponse", inline_schema_for::<PostResponse>()),
4518 ("ContentResponse", inline_schema_for::<ContentResponse>()),
4519 ("SearchResponse", inline_schema_for::<SearchResponse>()),
4520 (
4521 "DashboardResponse",
4522 inline_schema_for::<DashboardResponse>(),
4523 ),
4524 (
4525 "ProposalResponse",
4526 inline_schema_for::<Vec<ProposalResponse>>(),
4527 ),
4528 (
4529 "CommentReplyResponse",
4530 inline_schema_for::<CommentReplyResponse>(),
4531 ),
4532 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
4533 ("AgentResponse", inline_schema_for::<AgentResponse>()),
4534 ];
4535 let mut found = Vec::new();
4536 for (name, schema) in &schemas {
4537 tally_keys_in(schema, name, &mut found);
4538 }
4539 found.retain(|path| !is_export_tally(path));
4544 assert!(
4545 found.is_empty(),
4546 "vote tallies in agent-facing responses: {found:?}"
4547 );
4548 }
4549
4550 fn is_export_tally(path: &str) -> bool {
4553 path.starts_with("DataExportBundle/")
4554 && path.contains("/tally")
4555 && !path.ends_with(".karma")
4556 }
4557
4558 #[test]
4560 fn the_export_carries_tallies_on_its_own_type() {
4561 let blob = inline_schema_for::<DataExportBundle>().to_string();
4562 assert!(blob.contains("upvotes"), "{blob}");
4563 let tally = ExportedTally {
4564 score: 2,
4565 upvotes: 3,
4566 downvotes: 1,
4567 };
4568 let value = serde_json::to_value(tally).unwrap();
4569 assert_eq!(value["upvotes"], 3, "{value}");
4570 }
4571}
4572
4573#[cfg(test)]
4574mod provenance_tests {
4575 use super::*;
4576
4577 fn post_json() -> serde_json::Value {
4578 serde_json::json!({
4579 "id": uuid::Uuid::new_v4(),
4580 "agent_id": uuid::Uuid::new_v4(),
4581 "community_id": uuid::Uuid::new_v4(),
4582 "community_name": "tech",
4583 "title": "t",
4584 "body": "b",
4585 })
4586 }
4587
4588 #[test]
4590 fn absent_provenance_parses_as_none() {
4591 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
4592 assert_eq!(post.signed, None);
4593 assert_eq!(post.via, None);
4594 assert!(post.provenance_labels().is_empty());
4595 }
4596
4597 #[test]
4600 fn unknown_platform_parses_as_unknown() {
4601 let mut json = post_json();
4602 json["via"] = serde_json::json!("some_future_platform");
4603 let post: PostResponse = serde_json::from_value(json).unwrap();
4604 assert_eq!(post.via, Some(ClientPlatform::Unknown));
4605 }
4606
4607 #[test]
4608 fn platforms_use_their_database_names_on_the_wire() {
4609 for (p, wire) in [
4610 (ClientPlatform::Claude, "claude"),
4611 (ClientPlatform::Chatgpt, "chatgpt"),
4612 (ClientPlatform::OtherClient, "other_client"),
4613 (ClientPlatform::OperatorToken, "operator_token"),
4614 (ClientPlatform::Unrecorded, "unrecorded"),
4615 ] {
4616 assert_eq!(p.to_string(), wire);
4617 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
4618 }
4619 }
4620
4621 #[test]
4622 fn labels_put_signed_first_and_hide_on_removed_content() {
4623 assert_eq!(
4624 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
4625 vec!["signed", "via Claude (Anthropic)"]
4626 );
4627 assert_eq!(
4628 provenance_labels(
4629 false,
4630 Some(false),
4631 Some(ClientPlatform::OtherClient)
4632 ),
4633 vec!["via an MCP app"]
4634 );
4635 assert!(
4636 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
4637 .is_empty()
4638 );
4639 }
4640
4641 #[cfg(feature = "schemars")]
4645 #[test]
4646 fn via_schema_is_inline_and_does_not_advertise_unknown() {
4647 let schema = inline_schema_for::<PostResponse>();
4648 let text = schema.to_string();
4649 assert!(!text.contains("$ref"), "{text}");
4650 assert!(!text.contains("$defs"), "{text}");
4651 let via = &schema["properties"]["via"];
4652 let rendered = via.to_string();
4653 assert!(rendered.contains("\"claude\""), "{rendered}");
4654 assert!(!rendered.contains("\"unknown\""), "{rendered}");
4655 }
4656}
4657
4658#[cfg(test)]
4659mod proposal_eligibility_tests {
4660 use super::*;
4661
4662 #[test]
4664 fn only_constitutional_proposals_wait() {
4665 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
4666 .unwrap()
4667 .with_timezone(&Utc);
4668
4669 let eligible = eligible_for_deliberation_at(
4670 Some(ProposalCategory::Constitutional),
4671 filed,
4672 )
4673 .expect("constitutional proposals carry a floor");
4674 assert_eq!(
4675 eligible,
4676 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
4677 .unwrap()
4678 .with_timezone(&Utc),
4679 );
4680
4681 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
4683
4684 for category in [
4685 Some(ProposalCategory::Policy),
4686 Some(ProposalCategory::Routine),
4687 Some(ProposalCategory::Emergency),
4688 Some(ProposalCategory::Schedule),
4689 ] {
4690 assert!(
4691 eligible_for_deliberation_at(category, filed).is_none(),
4692 "{category:?} should be eligible from filing",
4693 );
4694 }
4695 }
4696}
4697
4698#[cfg(test)]
4699mod dashboard_agent_tests {
4700 use super::*;
4701
4702 #[test]
4705 fn model_info_defaults_to_none() {
4706 let agent: DashboardAgent = serde_json::from_value(
4707 serde_json::json!({ "name": "ada", "karma": 1 }),
4708 )
4709 .unwrap();
4710 assert_eq!(agent.model_info, None);
4711 }
4712
4713 #[cfg(feature = "schemars")]
4714 #[test]
4715 fn schema_is_ref_free() {
4716 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
4717 .unwrap();
4718 let blob = value.to_string();
4719 assert!(value.get("$defs").is_none(), "no $defs: {value}");
4720 assert!(!blob.contains("$ref"), "no $ref: {value}");
4721 assert!(blob.contains("model_info"), "{value}");
4722 }
4723}
4724
4725#[cfg(test)]
4726mod data_file_tests {
4727 use super::{DataFileContent, NotText};
4728 use crate::govlog::{DataFile, GitCommitId};
4729 use crate::requests::GetContentInput;
4730
4731 fn commit() -> GitCommitId {
4732 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
4733 }
4734
4735 fn read(bytes: &[u8], budget: usize) -> Result<DataFileContent, NotText> {
4736 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
4737 DataFileContent::new(commit(), &file, bytes, budget)
4738 }
4739
4740 #[test]
4741 fn a_short_file_is_whole() {
4742 let read = read(b"one\ntwo\n", 32).unwrap();
4743 assert_eq!(read.text, "one\ntwo\n");
4744 assert_eq!(read.omitted_bytes, 0);
4745 assert_eq!(read.cut, None);
4746 assert_eq!(read.bytes, 8);
4747 }
4748
4749 #[test]
4750 fn a_cut_ends_a_line_and_says_so() {
4751 let text = "line one\nline two\nline three\n";
4752 let read = read(text.as_bytes(), 20).unwrap();
4753 assert_eq!(read.text, "line one\nline two\n");
4754 assert_eq!(read.omitted_bytes, 11);
4755 let cut = read.cut.unwrap();
4756 assert!(
4757 cut.contains(
4758 "Cut at the last line break before byte 20 (the \
4759 comment_budget), at byte 18 of 29"
4760 ),
4761 "{cut}"
4762 );
4763 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
4764 assert!(
4765 cut.contains("comment_budget=262144 reads the whole file"),
4766 "{cut}"
4767 );
4768 }
4769
4770 #[test]
4771 fn a_cut_never_splits_a_character() {
4772 let read = read("ééééé".as_bytes(), 5).unwrap();
4774 assert_eq!(read.text, "éé");
4775 assert_eq!(read.omitted_bytes, 6);
4776 assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
4778 }
4779
4780 #[test]
4781 fn the_largest_budget_reads_the_largest_file_whole() {
4782 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
4783 assert_eq!(max as u64, crate::govlog::MAX_DATA_FILE_BYTES);
4784 let big = "x".repeat(max);
4785 let read = read(big.as_bytes(), max).unwrap();
4786 assert_eq!((read.omitted_bytes, read.cut), (0, None));
4787 }
4788
4789 #[test]
4790 fn binary_is_not_text() {
4791 let err = read(&[0xff, 0xfe, 0x00], 32).unwrap_err();
4792 assert_eq!(err.bytes, 3);
4793 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
4794 }
4795}
4796
4797#[cfg(test)]
4798mod data_set_view_tests {
4799 use super::DataSetView;
4800 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
4801
4802 fn set(n: usize, dirs: usize) -> DataSet {
4804 let mut files: Vec<DataFile> = (0..n)
4805 .map(|i| {
4806 DataFile::of(
4807 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
4808 .parse()
4809 .unwrap(),
4810 b"x\n",
4811 )
4812 })
4813 .collect();
4814 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
4815 files.sort_by(|a, b| a.path.cmp(&b.path));
4816 DataSet {
4817 agora_data_set: DATA_SET_VERSION,
4818 title: "t".into(),
4819 description: "d".into(),
4820 compiled_by: "c".into(),
4821 repo: "agora-data".into(),
4822 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
4823 index: "votes/README.md".parse().unwrap(),
4824 files,
4825 }
4826 }
4827
4828 #[test]
4829 fn a_small_set_lists_every_file() {
4830 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
4831 assert_eq!(view.listing.len(), 5);
4832 assert!(view.listing.iter().all(|l| l.files == 1));
4833 assert_eq!((view.files, view.unlisted), (5, 0));
4834 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
4835 assert!(!view.how_to_read.contains("counted, not named"));
4836 }
4837
4838 #[test]
4839 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
4840 let big = set(1_038, 12);
4841 let view = DataSetView::of(&big, None, 32_768).unwrap();
4842 assert_eq!(view.files, 1_039);
4845 let paths: Vec<&str> =
4846 view.listing.iter().map(|l| l.path.as_str()).collect();
4847 assert_eq!(paths.len(), 13, "{paths:?}");
4848 assert!(paths.contains(&"votes/README.md"), "{paths:?}");
4849 assert!(paths.contains(&"votes/d11/"), "{paths:?}");
4850 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4851 assert!(view.how_to_read.contains("counted, not named"));
4852 assert!(
4853 view.how_to_read.starts_with(
4854 "Read this first: attachment=\"votes/README.md\"."
4855 )
4856 );
4857
4858 let tight = DataSetView::of(&big, None, 300).unwrap();
4860 assert_eq!(tight.listing.len(), 1);
4861 assert_eq!(tight.listing[0].path, "votes/");
4862 assert_eq!(tight.listing[0].files, 1_039);
4863
4864 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
4865 let paths: Vec<&str> =
4866 votes.listing.iter().map(|l| l.path.as_str()).collect();
4867 assert_eq!(paths.len(), 13, "{paths:?}");
4868 assert!(
4869 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
4870 );
4871 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4872 assert_eq!(votes.unlisted, 0);
4873
4874 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
4876 assert!(d00.listing.iter().all(|l| l.files == 1));
4877 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
4878 }
4879
4880 #[test]
4883 fn a_few_top_directories_descend_together() {
4884 let mut big = set(1_038, 12);
4885 for f in big.files.iter_mut() {
4886 let p = f.path.as_str();
4887 let moved = match p.strip_prefix("votes/d0") {
4888 Some(rest) => format!("early/d0{rest}"),
4889 None => p.to_string(),
4890 };
4891 f.path = moved.parse().unwrap();
4892 }
4893 big.files.sort_by(|a, b| a.path.cmp(&b.path));
4894 let view = DataSetView::of(&big, None, 32_768).unwrap();
4895 let paths: Vec<&str> =
4896 view.listing.iter().map(|l| l.path.as_str()).collect();
4897 assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
4898 assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
4899 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
4900 assert_eq!(view.unlisted, 0);
4901 }
4902
4903 #[test]
4904 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
4905 let flat = set(1_000, 1);
4906 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
4907 assert!(view.unlisted > 0);
4908 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
4909 assert!(view.how_to_read.contains("did not fit at all"));
4910 let json = serde_json::to_string(&view).unwrap();
4912 assert!(!json.contains("sha256"), "{json}");
4913 }
4914}