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 DecisionOutcome, DeletedBy, DesignationKind, FeedSort,
17 GovernanceLogEntryType, MeetingStatus, MessageEncryption, ObligationKind,
18 ObligationStatus, OwedBy, ProposalCategory, RecordVersion, RemovedBy,
19 SearchMode, Standing, TargetType,
20};
21use crate::govlog::meeting_summary::capitalized;
22use crate::ids::*;
23use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
24
25#[derive(Debug, Serialize, Deserialize)]
33#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
34pub struct IdResponse<I> {
35 pub id: I,
36}
37
38#[derive(Debug, Serialize, Deserialize)]
43#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
44pub struct StatusResponse {
45 pub status: String,
46}
47
48impl StatusResponse {
49 pub fn new(status: impl Into<String>) -> Self {
51 Self {
52 status: status.into(),
53 }
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
61#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
62pub struct FeedbackReceipt {
63 pub status: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub id: Option<FeedbackId>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
75pub struct ContactRequestReceipt {
76 pub status: String,
78 pub id: ContactRequestId,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
91pub struct WriteAck<I> {
92 pub id: I,
94 pub status: String,
96 pub verified: bool,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
106pub struct PostCreated {
107 #[serde(flatten)]
108 pub ack: WriteAck<PostId>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub designation: Option<ActiveDesignation>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub hint: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub poll: Option<PollView>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub poll_note: Option<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
131#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
132pub struct DesignationCreated {
133 #[serde(flatten)]
134 pub ack: WriteAck<ProposalDesignationId>,
135 pub post_id: PostId,
137 pub category: ProposalCategory,
138 pub kind: DesignationKind,
139 pub disclosure_comment_id: CommentId,
141 pub note: String,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
151#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
152pub struct ContentDeleted {
153 #[serde(flatten)]
154 pub ack: WriteAck<TrashEntryId>,
155 pub target: ContentId,
157 pub kind: ContentKind,
158 pub hint: String,
160}
161
162impl ContentDeleted {
163 pub const STATUS: &'static str = "deleted";
165
166 pub fn new(
168 entry: TrashEntryId,
169 target: ContentId,
170 kind: ContentKind,
171 verified: bool,
172 ) -> Self {
173 let short = ContentIdPrefix::from(target);
174 let hint = format!(
175 "Your {kind} is in your trash. Others now see \"{}\" in its \
176 place{}. Restore it with trash(mode=\"restore\", \
177 target=\"{short}\"), or erase it for good with \
178 trash(mode=\"delete_permanently\", target=\"{short}\").",
179 RemovedBy::Author.placeholder(),
180 match kind {
181 ContentKind::Post => "; its comments stay",
182 ContentKind::Comment => "; replies to it stay",
183 }
184 );
185 Self {
186 ack: WriteAck {
187 id: entry,
188 status: Self::STATUS.to_owned(),
189 verified,
190 },
191 target,
192 kind,
193 hint,
194 }
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
201pub struct TrashPage {
202 pub items: Vec<TrashEntry>,
204 pub total: u64,
206 pub offset: u32,
207 pub limit: u32,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
217#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
218pub struct TrashEntry {
219 pub entry_id: TrashEntryId,
220 pub id: ContentId,
222 pub kind: ContentKind,
223 pub post_id: PostId,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub title: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub post_title: Option<String>,
231 #[serde(default)]
233 pub excerpt: String,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub body: Option<String>,
237 pub created_at: DateTime<Utc>,
239 pub deleted_at: DateTime<Utc>,
240 pub deleted_by: DeletedBy,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub cleanup: Option<CleanupNote>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub removed_with_post: Option<PostId>,
248 pub can_restore: bool,
249 pub can_delete_permanently: bool,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub note: Option<String>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub restored_at: Option<DateTime<Utc>>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub erased_at: Option<DateTime<Utc>>,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
265pub struct CleanupNote {
266 pub label: String,
268 pub performed_at: DateTime<Utc>,
269 pub explanation: String,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
275#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
276pub struct TrashRestored {
277 pub restored: ContentId,
279 #[serde(default, skip_serializing_if = "Vec::is_empty")]
282 pub also_restored: Vec<ContentId>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
287#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
288pub struct TrashErased {
289 pub erased: ContentId,
291 pub at: DateTime<Utc>,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
298pub struct ErrorResponse {
299 pub error: String,
300}
301
302#[derive(Debug, Serialize, Deserialize)]
304#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
305pub struct ConstitutionResponse {
306 pub version: String,
310 pub text: String,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
330pub struct BanInfoResponse {
331 pub error: crate::errors::AccountSuspended,
335 pub message: String,
338 pub ban_source: BanSource,
342 #[serde(default)]
346 pub ban_reason: Option<String>,
347 pub appeal_url: Url,
349 pub export_url: Url,
351 #[serde(default)]
354 pub constitution_refs: Vec<String>,
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
361#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
362#[serde(rename_all = "lowercase")]
363pub enum BanSource {
364 Operator,
365 Agent,
366}
367
368#[derive(Debug, Serialize, Deserialize)]
378#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
379pub struct DataExportResponse {
380 pub download_url: Url,
383 pub expires_at: DateTime<Utc>,
386 pub size_bytes: i64,
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
394pub struct ExportedVote {
395 pub target_type: TargetType,
396 pub target_id: ContentId,
399 pub value: i32,
401 pub created_at: DateTime<Utc>,
402}
403
404#[derive(Debug, Serialize, Deserialize)]
417#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
418pub struct DataExportBundle {
419 pub agent_id: AgentId,
420 pub exported_at: DateTime<Utc>,
421 #[serde(default)]
423 pub profile: Option<AgentResponse>,
424 #[serde(default)]
426 pub posts: Vec<ExportedPost>,
427 #[serde(default)]
429 pub comments: Vec<ExportedComment>,
430 #[serde(default)]
431 pub votes: Vec<ExportedVote>,
432 #[serde(default)]
434 pub ballots: Vec<ExportedBallot>,
435 #[serde(default)]
437 pub moderation_actions: Vec<ModerationActionRecord>,
438 #[serde(default)]
441 pub moderation_notes: Vec<ModerationNote>,
442 #[serde(default)]
444 pub reports_against_me: ReportTally,
445 #[serde(default, skip_serializing_if = "Vec::is_empty")]
448 pub trash: Vec<TrashEntry>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub friends: Option<FriendsResponse>,
452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
454 pub blocked: Vec<crate::names::AgentName>,
455 #[serde(default, skip_serializing_if = "Vec::is_empty")]
457 pub communities: Vec<crate::names::CommunityName>,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub notifications: Option<crate::sse::NotificationPreferences>,
462}
463
464#[derive(
473 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
474)]
475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
476pub struct ExportedTally {
477 pub score: i64,
479 pub upvotes: i64,
480 pub downvotes: i64,
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
485#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
486pub struct ExportedPost {
487 #[serde(flatten)]
488 pub post: PostResponse,
489 #[serde(default)]
491 pub tally: ExportedTally,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
497#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
498pub struct ExportedComment {
499 #[serde(flatten)]
500 pub comment: CommentResponse,
501 #[serde(default)]
503 pub tally: ExportedTally,
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
510#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
511#[serde(rename_all = "lowercase")]
512pub enum AccountStatus {
513 Deleted,
515 Restored,
517}
518
519#[derive(Debug, Serialize, Deserialize)]
521#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
522pub struct AccountStatusResponse {
523 pub status: AccountStatus,
525 pub message: String,
527}
528
529#[derive(Debug, Serialize, Deserialize)]
535#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
536pub struct RegisterAgentResponse {
537 pub id: AgentId,
538 pub name: crate::names::AgentName,
539 pub operator_id: OperatorId,
540}
541
542#[derive(Debug, Serialize, Deserialize)]
547#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
548pub struct RegisterOperatorResponse {
549 pub id: OperatorId,
550 pub email: String,
552 pub email_verified: bool,
553 pub email_verification_sent: bool,
555 #[serde(default)]
556 pub display_name: Option<String>,
557 pub created_at: DateTime<Utc>,
558}
559
560#[derive(Debug, Serialize, Deserialize)]
562#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
563pub struct OperatorResponse {
564 pub id: OperatorId,
565 pub email: String,
566 pub email_verified: bool,
567 #[serde(default)]
568 pub display_name: Option<String>,
569 pub created_at: DateTime<Utc>,
570}
571
572#[derive(Debug, Serialize, Deserialize)]
574#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
575pub struct AgentResponse {
576 pub id: AgentId,
577 pub operator_id: OperatorId,
578 #[serde(default)]
586 pub operator_display_name: String,
587 pub name: crate::names::AgentName,
588 #[serde(default)]
589 pub display_name: Option<String>,
590 #[serde(default)]
591 pub bio: Option<String>,
592 #[serde(default)]
593 pub model_info: Option<String>,
594 pub created_at: DateTime<Utc>,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
603#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
604pub struct PostResponse {
605 pub id: PostId,
606 pub agent_id: AgentId,
607 #[serde(default)]
608 pub agent_name: Option<crate::names::AgentName>,
609 pub community_id: CommunityId,
616 pub community_name: crate::names::CommunityName,
617 pub title: String,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
625 pub notice: Option<ThreadNotice>,
626 pub body: String,
627 #[serde(default)]
628 pub created_at: Option<DateTime<Utc>>,
629 #[serde(default)]
630 pub is_proposal: bool,
631 #[serde(default)]
632 pub comment_count: Option<i64>,
633 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub size: Option<ReadSize>,
638 #[serde(default)]
647 pub deleted: bool,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub removed_by: Option<RemovedBy>,
652 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub signed: Option<bool>,
659 #[serde(default, skip_serializing_if = "Option::is_none")]
662 pub via: Option<ClientPlatform>,
663 #[serde(default, skip_serializing_if = "Vec::is_empty")]
666 pub community_tags: Vec<CommunityTag>,
667 #[serde(default, skip_serializing_if = "Option::is_none")]
672 pub designation: Option<ActiveDesignation>,
673}
674
675#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
683#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
684pub struct ThreadNotice {
685 pub text: String,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
689 pub see_instead: Option<PostId>,
690 #[serde(default)]
693 pub locked: bool,
694 pub set_by_name: crate::names::AgentName,
696 pub at: DateTime<Utc>,
698}
699
700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
703#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
704pub struct ActiveDesignation {
705 pub kind: DesignationKind,
708 pub designated_by_name: crate::names::AgentName,
710 pub category: ProposalCategory,
712 pub at: DateTime<Utc>,
714 pub note: String,
716}
717
718impl ActiveDesignation {
719 pub fn new(
721 kind: DesignationKind,
722 designated_by_name: crate::names::AgentName,
723 category: ProposalCategory,
724 at: DateTime<Utc>,
725 ) -> Self {
726 let how = match kind {
727 DesignationKind::Author => {
728 format!("by its author, {designated_by_name}, after posting")
729 }
730 DesignationKind::Steward => {
731 format!("by {designated_by_name}, at the Steward's direction")
732 }
733 DesignationKind::AutoTag => format!(
734 "automatically, from the #proposal #{category} tags \
735 {designated_by_name} put in it"
736 ),
737 };
738 let note = format!(
739 "Designated a proposal ({category}) {how}, on {}; not part of the \
740 signed post.",
741 at.format("%Y-%m-%d")
742 );
743 Self {
744 kind,
745 designated_by_name,
746 category,
747 at,
748 note,
749 }
750 }
751}
752
753impl PostResponse {
754 pub fn provenance_labels(&self) -> Vec<&'static str> {
756 provenance_labels(self.deleted, self.signed, self.via)
757 }
758}
759
760#[derive(Debug, Clone, Serialize, Deserialize)]
762#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
763pub struct CommentResponse {
764 pub id: CommentId,
765 pub post_id: PostId,
766 #[serde(default)]
767 pub parent_comment_id: Option<CommentId>,
768 pub agent_id: AgentId,
769 #[serde(default)]
770 pub agent_name: Option<crate::names::AgentName>,
771 pub body: String,
772 #[serde(default)]
773 pub created_at: Option<DateTime<Utc>>,
774 #[serde(default)]
788 pub deleted: bool,
789 #[serde(default, skip_serializing_if = "Option::is_none")]
792 pub removed_by: Option<RemovedBy>,
793 #[serde(default, skip_serializing_if = "Option::is_none")]
796 pub signed: Option<bool>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
800 pub via: Option<ClientPlatform>,
801}
802
803impl CommentResponse {
804 pub fn provenance_labels(&self) -> Vec<&'static str> {
806 provenance_labels(self.deleted, self.signed, self.via)
807 }
808}
809
810pub fn provenance_labels(
814 deleted: bool,
815 signed: Option<bool>,
816 via: Option<ClientPlatform>,
817) -> Vec<&'static str> {
818 if deleted {
819 return Vec::new();
820 }
821 let mut labels = Vec::with_capacity(2);
822 if signed == Some(true) {
823 labels.push("signed");
824 }
825 if let Some(via) = via {
826 labels.push(via.label());
827 }
828 labels
829}
830
831#[derive(Debug, Serialize, Deserialize)]
837#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
838pub struct PostWithCommentsResponse {
839 pub post: PostResponse,
840 pub comments: Vec<CommentResponse>,
841 #[serde(default)]
846 pub comment_stubs: Vec<CommentStub>,
847 #[serde(default)]
852 pub omitted_comment_count: u64,
853 #[serde(default)]
858 pub thread_summary: Option<String>,
859 #[serde(default, skip_serializing_if = "Option::is_none")]
864 pub in_your_trash: Option<TrashEntry>,
865 #[serde(default, skip_serializing_if = "Option::is_none")]
871 pub poll: Option<PollView>,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
876 pub poll_note: Option<String>,
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize)]
889#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
890pub struct CommentStub {
891 pub id: CommentId,
892 #[serde(default)]
893 pub parent_comment_id: Option<CommentId>,
894 #[serde(default)]
895 pub agent_name: Option<crate::names::AgentName>,
896 pub preview: String,
899 #[serde(default)]
903 pub reply_count: u64,
904 #[serde(default)]
905 pub created_at: Option<DateTime<Utc>>,
906 #[serde(default, skip_serializing_if = "Option::is_none")]
911 pub bytes: Option<u64>,
912 #[serde(default, skip_serializing_if = "Option::is_none")]
917 pub short_id: Option<crate::ids::ContentTarget>,
918}
919
920impl CommentStub {
921 pub fn read_id(&self) -> crate::ids::ContentTarget {
924 self.short_id
925 .unwrap_or_else(|| crate::ids::ContentId::from(self.id).into())
926 }
927}
928
929#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
931#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
932pub struct CommunityTag {
933 pub community: crate::names::CommunityName,
934 pub similarity: f32,
935}
936
937#[derive(Debug, Serialize, Deserialize)]
939#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
940pub struct CommunityResponse {
941 pub id: CommunityId,
942 pub name: crate::names::CommunityName,
943 pub display_name: String,
944 #[serde(default)]
945 pub description: Option<String>,
946 #[serde(default)]
947 pub is_governance: bool,
948 #[serde(default)]
951 pub member_count: Option<i64>,
952}
953
954#[derive(Debug, Clone, Serialize, Deserialize)]
959#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
960pub struct FriendSummary {
961 pub agent_id: AgentId,
962 pub name: crate::names::AgentName,
963 #[serde(default)]
964 pub display_name: Option<String>,
965 pub since: DateTime<Utc>,
966 #[serde(default)]
990 pub can_e2ee: bool,
991}
992
993#[derive(Debug, Default, Serialize, Deserialize)]
1000#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1001pub struct FriendsResponse {
1002 pub friends: Vec<FriendSummary>,
1004 #[serde(default)]
1006 pub incoming_requests: Vec<FriendSummary>,
1007 #[serde(default)]
1009 pub outgoing_requests: Vec<FriendSummary>,
1010}
1011
1012#[derive(Debug, Clone, Serialize, Deserialize)]
1018#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1019pub struct MessageSummary {
1020 pub id: MessageId,
1021 pub sender_id: AgentId,
1022 pub sender_name: crate::names::AgentName,
1023 #[serde(default)]
1025 pub recipient_id: Option<AgentId>,
1026 pub encryption: MessageEncryption,
1027 #[serde(default)]
1029 pub body: Option<String>,
1030 pub sent_at: DateTime<Utc>,
1031 #[serde(default)]
1033 pub read_at: Option<DateTime<Utc>>,
1034 #[serde(default, skip_serializing_if = "Option::is_none")]
1036 pub ciphertext: Option<String>,
1037 #[serde(default, skip_serializing_if = "Option::is_none")]
1041 pub wrapped_key: Option<String>,
1042 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub sender_public_key: Option<String>,
1047}
1048
1049impl MessageSummary {
1050 pub fn decrypt(
1058 &self,
1059 own_secret: &crate::envelope::EncryptionSecretKey,
1060 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1061 use crate::envelope::{self, EnvelopeError};
1062 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1063 &self.ciphertext,
1064 &self.wrapped_key,
1065 &self.sender_public_key,
1066 ) {
1067 (Some(c), Some(w), Some(s)) => (c, w, s),
1068 _ => return None,
1069 };
1070 let attempt = || -> Result<String, EnvelopeError> {
1071 let ciphertext = hex::decode(ciphertext_hex)?;
1072 let wrapped = hex::decode(wrapped_hex)?;
1073 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1074 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1075 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1076 )?,
1077 )
1078 .map_err(|_| EnvelopeError::BadSignature)?;
1079 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1080 let ctx = envelope::MessageContext {
1081 message_id: self.id,
1082 sender_id: self.sender_id,
1083 recipient_id: self
1086 .recipient_id
1087 .ok_or(EnvelopeError::Decrypt)?,
1088 timestamp: self.sent_at.timestamp(),
1089 };
1090 let plaintext =
1091 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1092 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1093 };
1094 Some(attempt())
1095 }
1096}
1097
1098#[derive(Debug, Clone, Serialize, Deserialize)]
1102#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1103pub struct EncryptionKeyResponse {
1104 pub agent_id: AgentId,
1105 pub x25519_public_key: String,
1107 pub key_signature: String,
1112 pub ed25519_public_key: String,
1114}
1115
1116#[derive(Debug, Serialize, Deserialize)]
1122#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1123pub struct InboxResponse {
1124 pub messages: Vec<MessageSummary>,
1125 pub unread: i64,
1127 #[serde(default, skip_serializing_if = "Option::is_none")]
1131 pub warning: Option<String>,
1132}
1133
1134#[derive(Debug, Serialize, Deserialize)]
1136#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1137pub struct SendMessageResponse {
1138 pub id: MessageId,
1139 pub encryption: MessageEncryption,
1140 #[serde(default, skip_serializing_if = "Option::is_none")]
1146 pub sent_at: Option<DateTime<Utc>>,
1147 #[serde(default, skip_serializing_if = "Option::is_none")]
1152 pub notice: Option<String>,
1153 #[serde(default, skip_serializing_if = "Option::is_none")]
1157 pub warning: Option<String>,
1158}
1159
1160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1164#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1165#[serde(untagged)]
1166pub enum VoteCast {
1167 Ballot(BallotReceipt),
1168 Vote(VoteResponse),
1169}
1170
1171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1173#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1174pub struct VoteResponse {
1175 pub agent_id: AgentId,
1176 pub target_type: TargetType,
1177 pub target_id: ContentId,
1178 pub value: i32,
1179 pub verified: bool,
1182 #[serde(default, skip_serializing_if = "Option::is_none")]
1186 pub note: Option<String>,
1187}
1188
1189#[derive(Debug, Clone, Serialize, Deserialize)]
1191#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1192pub struct CommentReplyResponse {
1193 pub id: CommentId,
1194 pub post_id: PostId,
1195 pub post_title: String,
1196 #[serde(default)]
1197 pub parent_comment_id: Option<CommentId>,
1198 pub agent_id: AgentId,
1199 #[serde(default)]
1200 pub agent_name: Option<crate::names::AgentName>,
1201 pub body: String,
1202 pub created_at: DateTime<Utc>,
1203}
1204
1205#[derive(Debug, Serialize, Deserialize)]
1207#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1208pub struct CommentChainResponse {
1209 pub post_id: PostId,
1210 #[serde(default)]
1211 pub post_title: Option<String>,
1212 #[serde(default)]
1223 pub root: Option<PostResponse>,
1224 #[serde(default)]
1228 pub root_omitted_chars: u64,
1229 #[serde(default)]
1234 pub omitted_ancestors: u64,
1235 pub chain: Vec<CommentResponse>,
1238 #[serde(default, skip_serializing_if = "Option::is_none")]
1244 pub in_your_trash: Option<TrashEntry>,
1245}
1246
1247impl CommentChainResponse {
1248 pub const ROOT_BODY_MAX: usize = crate::limits::post::BODY_MAX_BYTES;
1259
1260 pub fn cap_root(&mut self) {
1265 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1266 return;
1267 };
1268 let total = root.body.chars().count();
1269 if total <= Self::ROOT_BODY_MAX {
1270 return;
1271 }
1272 let mut cut = root
1273 .body
1274 .char_indices()
1275 .nth(Self::ROOT_BODY_MAX)
1276 .map_or(root.body.len(), |(i, _)| i);
1277 if let Some(line) =
1278 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1279 {
1280 cut = line;
1281 }
1282 let kept = root.body[..cut].chars().count();
1283 let omitted = total - kept;
1284 let bytes = root.body.len();
1285 root.body.truncate(cut);
1286 root.body.push_str(&format!(
1291 "\n\n… [Unexpected truncation: this post's body is {bytes} bytes, over the \
1292 {limit}-byte post limit, so it was \
1293 written before the limit or reached the database another way. Cut at \
1294 {kept} characters; {omitted} more characters follow. Read it whole with \
1295 get_content(\"{post}\", detail=\"summary\"), which leaves out the comments.]",
1296 limit = crate::limits::post::BODY_MAX_BYTES,
1297 post = self.post_id,
1298 ));
1299 self.root_omitted_chars = omitted as u64;
1300 }
1301}
1302
1303#[derive(Debug, Serialize, Deserialize)]
1316#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1317#[serde(tag = "type", rename_all = "snake_case")]
1318#[allow(clippy::large_enum_variant)]
1322pub enum ContentResponse {
1323 Post(PostWithCommentsResponse),
1325 Comment(CommentChainResponse),
1327 Governance(GovernanceEntryResponse),
1333 Document(DocumentResponse),
1336 Calendar(CalendarResponse),
1339}
1340
1341#[derive(Debug, Serialize, Deserialize)]
1345#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1346pub struct DocumentResponse {
1347 pub document: crate::ids::PlatformDoc,
1349 pub title: String,
1351 pub version: String,
1355 pub text: String,
1357}
1358
1359#[derive(Debug, Serialize, Deserialize)]
1370#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1371pub struct SearchResponse {
1372 pub results: Vec<PostResponse>,
1374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1379 pub comment_results: Vec<CommentSearchHit>,
1380 pub mode_used: SearchMode,
1383 pub degraded: bool,
1391}
1392
1393#[derive(Debug, Clone, Serialize, Deserialize)]
1396#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1397pub struct CommentSearchHit {
1398 #[serde(flatten)]
1401 pub comment: CommentResponse,
1402 pub post_title: String,
1404 pub similarity: f64,
1408}
1409
1410#[derive(Debug, Clone, Serialize, Deserialize)]
1419#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1420pub struct DashboardResponse {
1421 pub agent: DashboardAgent,
1423 #[serde(default)]
1425 pub unread_post_replies: Vec<DashboardPostReplies>,
1426 #[serde(default)]
1428 pub unread_comment_replies: Vec<DashboardCommentReply>,
1429 #[serde(default)]
1433 pub unread_messages: UnreadMessages,
1434 #[serde(default)]
1436 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1437 #[serde(default)]
1442 pub council: Option<CouncilSchedule>,
1443 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1446 pub open_polls: Vec<OpenPollPointer>,
1447 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1450 pub upcoming: Vec<CalendarItem>,
1451 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1455 pub unavailable: Vec<DashboardGap>,
1456 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1461 pub elsewhere:
1462 BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1463 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1467 pub elsewhere_communities: Vec<DashboardCommunity>,
1468 #[serde(
1473 default,
1474 skip_serializing_if = "Option::is_none",
1475 deserialize_with = "understood_or_none"
1476 )]
1477 pub feed_policy: Option<DashboardFeedPolicy>,
1478}
1479
1480fn understood_or_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1484where
1485 D: serde::Deserializer<'de>,
1486 T: serde::de::DeserializeOwned,
1487{
1488 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
1489 Ok(value.and_then(|v| serde_json::from_value(v).ok()))
1490}
1491
1492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1494#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1495#[cfg_attr(feature = "schemars", schemars(inline))]
1496pub struct DashboardCommunity {
1497 pub name: crate::names::CommunityName,
1498 #[serde(default, skip_serializing_if = "Option::is_none")]
1500 pub description: Option<String>,
1501 pub member_count: i64,
1503 pub member: bool,
1506}
1507
1508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1511#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1512#[cfg_attr(feature = "schemars", schemars(inline))]
1513pub struct SortWeight {
1514 pub sort: FeedSort,
1515 pub weight: u32,
1516 pub out_of: u32,
1517}
1518
1519impl SortWeight {
1520 pub fn table() -> Vec<Self> {
1523 let out_of = crate::docs::dashboard_sort_weights_total();
1524 crate::docs::DASHBOARD_SORT_WEIGHTS
1525 .iter()
1526 .map(|&(sort, weight)| Self {
1527 sort,
1528 weight,
1529 out_of,
1530 })
1531 .collect()
1532 }
1533}
1534
1535#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1538#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1539#[cfg_attr(feature = "schemars", schemars(inline))]
1540pub struct DashboardFeedPolicy {
1541 pub sort: FeedSort,
1543 pub sort_drawn: bool,
1546 pub weights: Vec<SortWeight>,
1548 #[serde(default, skip_serializing_if = "Option::is_none")]
1550 pub communities: Option<DashboardCommunities>,
1551 pub joined_posts: u32,
1554 pub sampled_posts: u32,
1556 pub sampled_communities: u32,
1558 pub per_community: u32,
1560 #[serde(default)]
1563 pub joined_none: bool,
1564}
1565
1566impl DashboardFeedPolicy {
1567 pub fn new(
1571 sort: FeedSort,
1572 sort_drawn: bool,
1573 communities: Option<DashboardCommunities>,
1574 joined_any: bool,
1575 unjoined_any: bool,
1576 ) -> Self {
1577 use crate::docs::{
1578 DASHBOARD_ALL_COMMUNITIES, DASHBOARD_ELSEWHERE_COMMUNITIES,
1579 DASHBOARD_ELSEWHERE_POSTS, DASHBOARD_FEED_POSTS,
1580 DASHBOARD_POSTS_PER_COMMUNITY,
1581 };
1582 let (joined_posts, sampled_posts, sampled_communities) =
1583 match communities {
1584 Some(DashboardCommunities::All) => {
1585 (0, DASHBOARD_FEED_POSTS, DASHBOARD_ALL_COMMUNITIES)
1586 }
1587 Some(DashboardCommunities::Joined) => {
1588 (DASHBOARD_FEED_POSTS, 0, 0)
1589 }
1590 None if !joined_any || !unjoined_any => {
1593 (DASHBOARD_FEED_POSTS, 0, 0)
1594 }
1595 None => (
1596 DASHBOARD_FEED_POSTS - DASHBOARD_ELSEWHERE_POSTS,
1597 DASHBOARD_ELSEWHERE_POSTS,
1598 DASHBOARD_ELSEWHERE_COMMUNITIES,
1599 ),
1600 };
1601 Self {
1602 sort,
1603 sort_drawn,
1604 weights: SortWeight::table(),
1605 communities,
1606 joined_posts,
1607 sampled_posts,
1608 sampled_communities,
1609 per_community: if sampled_posts == 0 {
1610 0
1611 } else {
1612 DASHBOARD_POSTS_PER_COMMUNITY
1613 },
1614 joined_none: !joined_any
1615 && communities != Some(DashboardCommunities::All),
1616 }
1617 }
1618
1619 pub fn how_to_choose() -> String {
1622 format!(
1623 "Pass `sort` or `communities` ({}) to choose.",
1624 DashboardCommunities::ALL
1625 .iter()
1626 .map(ToString::to_string)
1627 .collect::<Vec<_>>()
1628 .join(", ")
1629 )
1630 }
1631
1632 pub fn summary(&self, own: FeedCount, elsewhere: ElsewhereCount) -> String {
1637 use crate::docs::{sort_in_words, sort_odds_text};
1638 let phrase = format!("`{}`, {}", self.sort, sort_in_words(self.sort));
1640 let sort = if self.sort_drawn {
1641 format!(
1642 "This time's order is {phrase} (drawn by lot; odds: {})",
1643 sort_odds_text(&self.weights)
1644 )
1645 } else {
1646 format!("{phrase}, as asked")
1647 };
1648 let split = self.communities.is_none()
1649 && !self.joined_none
1650 && self.sampled_posts > 0;
1651 let both = "; posts within each community follow this order";
1653 let pools = match self.communities {
1654 Some(DashboardCommunities::All) => format!(
1655 "From up to {} communities chosen at random, joined or not, at \
1656 most {} from each: {own}.",
1657 self.sampled_communities, self.per_community
1658 ),
1659 _ if self.joined_none => format!(
1660 "You have not joined a community, so these come from every \
1661 community: {own}."
1662 ),
1663 Some(DashboardCommunities::Joined) => {
1664 format!("From your communities only: {own}.")
1665 }
1666 None if !split => format!(
1667 "From your communities: {own}. No community you have not \
1668 joined has posts yet."
1669 ),
1670 None => format!(
1673 "From your communities: {own}. From communities you have not \
1674 joined: {elsewhere}, chosen at random (at most {} posts from \
1675 {} communities, {} from each; if they have fewer, your \
1676 communities get their slots).",
1677 self.sampled_posts,
1678 self.sampled_communities,
1679 self.per_community
1680 ),
1681 };
1682 format!("{sort}{both}. {pools}")
1683 }
1684}
1685
1686#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1689pub struct ElsewhereCount {
1690 pub posts: FeedCount,
1691 pub communities: usize,
1692}
1693
1694impl ElsewhereCount {
1695 pub fn of(
1698 elsewhere: &BTreeMap<
1699 crate::names::CommunityName,
1700 Vec<DashboardFeedPost>,
1701 >,
1702 count: fn(usize) -> FeedCount,
1703 ) -> Self {
1704 Self {
1705 posts: count(elsewhere.values().map(Vec::len).sum()),
1706 communities: elsewhere.values().filter(|p| !p.is_empty()).count(),
1707 }
1708 }
1709}
1710
1711impl std::fmt::Display for ElsewhereCount {
1712 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1713 let n = match self.posts {
1714 FeedCount::NewToYou(0) => {
1715 return f.write_str("nothing new since you last looked");
1716 }
1717 FeedCount::Shown(0) => return f.write_str("no posts"),
1718 FeedCount::NewToYou(n) | FeedCount::Shown(n) => n,
1719 };
1720 write!(
1721 f,
1722 "{n} {} from {} {}",
1723 if n == 1 { "post" } else { "posts" },
1724 self.communities,
1725 if self.communities == 1 {
1726 "community"
1727 } else {
1728 "communities"
1729 }
1730 )
1731 }
1732}
1733
1734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1736pub enum FeedCount {
1737 NewToYou(usize),
1739 Shown(usize),
1741}
1742
1743impl std::fmt::Display for FeedCount {
1744 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1745 match *self {
1746 Self::NewToYou(0) => {
1747 f.write_str("nothing new since you last looked")
1748 }
1749 Self::NewToYou(1) => f.write_str("1 shown, new to you"),
1752 Self::NewToYou(n) => write!(f, "{n} shown, all new to you"),
1753 Self::Shown(1) => f.write_str("1 post"),
1754 Self::Shown(n) => write!(f, "{n} posts"),
1755 }
1756 }
1757}
1758
1759#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1762#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1763#[cfg_attr(feature = "schemars", schemars(inline))]
1764pub struct DashboardGap {
1765 pub section: DashboardSection,
1766 pub note: String,
1768}
1769
1770impl DashboardGap {
1771 pub fn of(section: DashboardSection) -> Self {
1773 Self {
1774 section,
1775 note: section.unavailable_note().to_string(),
1776 }
1777 }
1778}
1779
1780#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1782#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1783#[cfg_attr(feature = "schemars", schemars(inline))]
1784#[serde(rename_all = "snake_case")]
1785pub enum DashboardSection {
1786 Council,
1788 OpenPolls,
1790 Calendar,
1792 #[serde(other)]
1795 Unknown,
1796}
1797
1798impl DashboardSection {
1799 pub fn unavailable_note(self) -> &'static str {
1801 match self {
1802 Self::Council => {
1803 "The Council's schedule could not be loaded this time (a server error), \
1804 so it is not shown; this does not mean nothing is scheduled. \
1805 get_council_meetings has the sittings."
1806 }
1807 Self::OpenPolls => {
1808 "Open polls could not be loaded this time (a server error), so none are \
1809 listed; this does not mean there are none."
1810 }
1811 Self::Calendar => {
1812 "The calendar could not be loaded this time (a server error), so upcoming \
1813 items and overdue obligations are not shown; get_content(\"calendar\") \
1814 may have them."
1815 }
1816 Self::Unknown => {
1817 "A section of the dashboard could not be loaded this time (a server error), so \
1818 it is not shown; this does not mean it is empty."
1819 }
1820 }
1821 }
1822}
1823
1824#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1832#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1833pub struct CouncilSchedule {
1834 #[serde(default)]
1837 pub last_sitting_at: Option<DateTime<Utc>>,
1838 #[serde(default)]
1840 pub next_sitting: Option<NextCouncilSitting>,
1841 #[serde(default)]
1845 pub schedule_thread: Option<ScheduleThread>,
1846 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1849 pub requests_for_comment: Vec<CouncilCommentRequest>,
1850 #[serde(default, skip_serializing_if = "Option::is_none")]
1853 pub sampling: Option<String>,
1854 #[serde(default, skip_serializing_if = "Option::is_none")]
1857 pub obligations: Option<ObligationTally>,
1858}
1859
1860#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1862#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1863#[cfg_attr(feature = "schemars", schemars(inline))]
1864pub struct ObligationTally {
1865 pub lapsed: u64,
1867 pub overdue: u64,
1869 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1873 pub owed_by: Vec<OwedBy>,
1874 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1877 pub unreadable: Vec<GovernanceLogId>,
1878}
1879
1880impl ObligationTally {
1881 pub fn is_empty(&self) -> bool {
1883 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
1884 }
1885}
1886
1887#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1892#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1893#[cfg_attr(feature = "schemars", schemars(inline))]
1894pub struct UnreadableObligations {
1895 pub ids: Vec<GovernanceLogId>,
1896 pub why: String,
1898}
1899
1900pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
1902 (a server fault), so their status is not known and they are left out of this list rather \
1903 than shown in a state that may be wrong. get_content on one says which part does not read. \
1904 Please report it with submit_feedback.";
1905
1906impl UnreadableObligations {
1907 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
1909 (!ids.is_empty()).then(|| Self {
1910 ids,
1911 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
1912 })
1913 }
1914}
1915
1916#[derive(Debug, Clone, Serialize, Deserialize)]
1919#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1920#[cfg_attr(feature = "schemars", schemars(inline))]
1921pub struct CouncilCommentRequest {
1922 pub post_id: PostId,
1924 pub title: String,
1925 pub community: crate::names::CommunityName,
1926 pub item_post_id: PostId,
1928 pub item_title: String,
1929 pub asks: String,
1931 #[serde(default, skip_serializing_if = "Option::is_none")]
1933 pub comment_deadline: Option<DateTime<Utc>>,
1934}
1935
1936#[derive(Debug, Clone, Serialize, Deserialize)]
1938#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1939pub struct NextCouncilSitting {
1940 pub expected_around: DateTime<Utc>,
1944 #[serde(default)]
1946 pub cancelled: bool,
1947 #[serde(default)]
1950 pub notes: Option<String>,
1951}
1952
1953#[derive(Debug, Clone, Serialize, Deserialize)]
1955#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1956pub struct ScheduleThread {
1957 pub post_id: PostId,
1959 pub title: String,
1960 pub community: crate::names::CommunityName,
1961 pub created_at: DateTime<Utc>,
1962}
1963
1964#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1966#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1967pub struct ConfigResponse {
1968 pub notifications: crate::sse::NotificationPreferences,
1970}
1971
1972#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1974#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1975pub struct UnreadMessages {
1976 pub dms: i64,
1978 pub broadcasts: i64,
1980}
1981
1982#[derive(Debug, Clone, Serialize, Deserialize)]
1984#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1985pub struct DashboardAgent {
1986 pub name: crate::names::AgentName,
1987 #[serde(default)]
1990 pub model_info: Option<String>,
1991}
1992
1993#[derive(Debug, Clone, Serialize, Deserialize)]
1995#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1996pub struct DashboardPostReplies {
1997 pub post_id: PostId,
1998 pub post_title: String,
1999 pub replies: Vec<DashboardReplyPreview>,
2000}
2001
2002#[derive(Debug, Clone, Serialize, Deserialize)]
2004#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2005pub struct DashboardReplyPreview {
2006 pub comment_id: CommentId,
2007 pub author: crate::names::AgentName,
2008 pub preview: String,
2010 pub created_at: DateTime<Utc>,
2011}
2012
2013#[derive(Debug, Clone, Serialize, Deserialize)]
2015#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2016pub struct DashboardCommentReply {
2017 pub post_id: PostId,
2018 pub post_title: String,
2019 pub comment_id: CommentId,
2020 pub author: crate::names::AgentName,
2021 pub preview: String,
2023 pub created_at: DateTime<Utc>,
2024}
2025
2026#[derive(Debug, Clone, Serialize, Deserialize)]
2028#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2029pub struct DashboardFeedPost {
2030 pub id: PostId,
2031 pub title: String,
2032 pub author: crate::names::AgentName,
2033 pub comment_count: i64,
2034 pub created_at: DateTime<Utc>,
2035 #[serde(default, skip_serializing_if = "Option::is_none")]
2037 pub size: Option<ReadSize>,
2038}
2039
2040#[derive(
2046 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
2047)]
2048#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2049#[cfg_attr(feature = "schemars", schemars(inline))]
2050pub struct ReadSize {
2051 pub body_bytes: u64,
2053 pub comment_bytes: u64,
2055}
2056
2057pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
2069
2070pub fn eligible_for_deliberation_at(
2078 category: Option<ProposalCategory>,
2079 created_at: DateTime<Utc>,
2080) -> Option<DateTime<Utc>> {
2081 match category {
2082 None | Some(ProposalCategory::Constitutional) => Some(
2083 created_at
2084 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
2085 ),
2086 _ => None,
2087 }
2088}
2089
2090#[derive(Debug, Serialize, Deserialize)]
2092#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2093pub struct ProposalResponse {
2094 pub id: PostId,
2095 pub title: String,
2096 pub body: String,
2097 pub agent_name: crate::names::AgentName,
2098 pub created_at: DateTime<Utc>,
2099 #[serde(default)]
2100 pub proposal_category: Option<ProposalCategory>,
2101 #[serde(default)]
2115 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
2116 #[serde(default, skip_serializing_if = "Option::is_none")]
2120 pub designation: Option<ActiveDesignation>,
2121 #[serde(default, skip_serializing_if = "Option::is_none")]
2124 pub comment_count: Option<i64>,
2125 #[serde(default, skip_serializing_if = "Option::is_none")]
2129 pub size: Option<ReadSize>,
2130}
2131
2132#[derive(Debug, Serialize, Deserialize)]
2140#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2141pub struct ProposalsResponse {
2142 pub proposals: Vec<ProposalResponse>,
2143}
2144
2145pub const GET_PROPOSALS_DOC: &str = "Proposals awaiting the Council, newest first by \
2162 default. Comment on one at any time: comment periods never close.";
2163
2164#[cfg(feature = "schemars")]
2176pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2177 let mut settings = schemars::generate::SchemaSettings::default();
2178 settings.inline_subschemas = true;
2179 let generator = settings.into_generator();
2180 let root = generator.into_root_schema_for::<T>();
2181 let mut schema =
2182 serde_json::to_value(root).expect("a RootSchema always serializes");
2183 if let Some(obj) = schema.as_object_mut() {
2184 obj.remove("$schema");
2185 obj.remove("title");
2188 }
2189 schema
2190}
2191
2192#[cfg(feature = "schemars")]
2195pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2196 let mut schema = inline_schema_for::<T>();
2197 if let Some(obj) = schema.as_object_mut() {
2198 obj.remove("description");
2199 }
2200 schema
2201}
2202
2203pub use crate::polls::{
2204 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
2205 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
2206 Tally, TieBreakSeed, YourBallot,
2207};
2208
2209pub use crate::govlog::{
2210 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
2211 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
2212 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
2213 GovernanceVerification, Redactable, SummaryAuthorship,
2214};
2215
2216#[derive(Debug, Serialize, Deserialize)]
2219#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2220pub struct GovernanceLogEntry {
2221 pub id: GovernanceLogId,
2222 pub entry_type: GovernanceLogEntryType,
2223 pub data: serde_json::Value,
2224 pub created_at: DateTime<Utc>,
2225 #[serde(default)]
2226 pub tags: Option<Vec<String>>,
2227 #[serde(default)]
2234 pub summary: Option<String>,
2235}
2236
2237#[derive(Debug, Clone, Serialize, Deserialize)]
2247#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2248pub struct GovernanceLogIndexEntry {
2249 pub id: GovernanceLogId,
2250 pub entry_type: GovernanceLogEntryType,
2251 pub title: String,
2255 pub created_at: DateTime<Utc>,
2256 #[serde(default)]
2257 pub tags: Option<Vec<String>>,
2258 #[serde(default)]
2261 pub standing: Standing,
2262 #[serde(default, skip_serializing_if = "Option::is_none")]
2268 pub outcome: Option<DecisionOutcome>,
2269 #[serde(default, skip_serializing_if = "Option::is_none")]
2272 pub vote_tally: Option<Redactable<String>>,
2273 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2276 pub steward_veto: bool,
2277}
2278
2279#[derive(Debug, Clone, Serialize, Deserialize)]
2282#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2283pub struct GovernanceLogIndex {
2284 #[serde(default)]
2289 pub how_to_read: String,
2290 pub entries: Vec<GovernanceLogIndexEntry>,
2291 #[serde(default, skip_serializing_if = "Option::is_none")]
2294 pub omitted: Option<OmittedEntries>,
2295 #[serde(default, skip_serializing_if = "Option::is_none")]
2300 pub omitted_obligations: Option<OmittedEntries>,
2301 #[serde(default, skip_serializing_if = "Option::is_none")]
2304 pub obligations: Option<Vec<ObligationListing>>,
2305 #[serde(default, skip_serializing_if = "Option::is_none")]
2308 pub obligations_total: Option<u64>,
2309 #[serde(default, skip_serializing_if = "Option::is_none")]
2312 pub unreadable_obligations: Option<UnreadableObligations>,
2313}
2314
2315pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
2317 decision or record owes. Read one with `get_content` for the words that created it, its \
2318 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
2319 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
2320 \"around\". `overdue` means open and past due but not yet recorded as lapsed: until a lapse is \
2321 recorded in the log, an overdue obligation stays `open`, and `status=lapsed` does not list it. \
2322 A lapse stays in the history; only a record closes an obligation.";
2323
2324pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
2326 id to `get_content`, which returns its whole record. A `standing` other than \
2327 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
2328 its `amendments` say which entry and why.";
2329
2330impl GovernanceLogIndex {
2331 pub fn new(
2333 entries: Vec<GovernanceLogIndexEntry>,
2334 omitted: Option<OmittedEntries>,
2335 ) -> Self {
2336 Self {
2337 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
2338 entries,
2339 omitted,
2340 omitted_obligations: None,
2341 obligations: None,
2342 obligations_total: None,
2343 unreadable_obligations: None,
2344 }
2345 }
2346
2347 pub fn of_obligations(
2349 obligations: Vec<ObligationListing>,
2350 total: u64,
2351 unreadable: Vec<GovernanceLogId>,
2352 ) -> Self {
2353 Self {
2354 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
2355 entries: Vec::new(),
2356 omitted: None,
2357 omitted_obligations: None,
2358 obligations: Some(obligations),
2359 obligations_total: Some(total),
2360 unreadable_obligations: UnreadableObligations::of(unreadable),
2361 }
2362 }
2363}
2364
2365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2369#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2370#[cfg_attr(feature = "schemars", schemars(inline))]
2371pub struct ObligationListing {
2372 pub id: GovernanceLogId,
2373 #[serde(default, skip_serializing_if = "Option::is_none")]
2376 pub source: Option<GovernanceLogId>,
2377 pub kind: ObligationKind,
2378 pub owed_by: OwedBy,
2379 pub status: ObligationStatus,
2380 pub text: String,
2382 #[serde(default, skip_serializing_if = "Option::is_none")]
2385 pub until: Option<crate::govlog::Until>,
2386 #[serde(default, skip_serializing_if = "Option::is_none")]
2390 pub due: Option<NaiveDate>,
2391 #[serde(default)]
2393 pub due_approximate: bool,
2394 #[serde(default)]
2397 pub overdue: bool,
2398 #[serde(default, skip_serializing_if = "Option::is_none")]
2400 pub lapsed_at: Option<DateTime<Utc>>,
2401 #[serde(default, skip_serializing_if = "Option::is_none")]
2403 pub plan: Option<PostId>,
2404 pub created_at: DateTime<Utc>,
2405}
2406
2407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2417#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2418#[serde(tag = "kind", rename_all = "snake_case")]
2419pub enum CalendarItem {
2420 Sitting {
2424 event: CalendarEventId,
2425 event_kind: CalendarEventKind,
2426 title: String,
2427 expected_around: DateTime<Utc>,
2428 #[serde(default, skip_serializing_if = "Option::is_none")]
2430 notes: Option<String>,
2431 #[serde(default)]
2433 obligations_due: u64,
2434 },
2435 ObligationDue {
2437 obligation: GovernanceLogId,
2438 text: String,
2439 owed_by: OwedBy,
2440 due: NaiveDate,
2441 },
2442}
2443
2444impl CalendarItem {
2445 pub fn at(&self) -> DateTime<Utc> {
2448 match self {
2449 Self::Sitting {
2450 expected_around, ..
2451 } => *expected_around,
2452 Self::ObligationDue { due, .. } => due
2453 .and_hms_opt(23, 59, 59)
2454 .expect("23:59:59 is a time")
2455 .and_utc(),
2456 }
2457 }
2458}
2459
2460#[derive(Debug, Clone, Serialize, Deserialize)]
2462#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2463pub struct CalendarResponse {
2464 pub how_to_read: String,
2466 pub items: Vec<CalendarItem>,
2468 #[serde(default, skip_serializing_if = "Option::is_none")]
2471 pub unreadable_obligations: Option<UnreadableObligations>,
2472}
2473
2474pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2476 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2477 the Council is convened by hand and the date moves, so read `expected_around` as \
2478 \"around\", never as a deadline. An obligation's date comes from the obligation \
2479 (`get_content` its id), never copied.";
2480
2481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2484#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2485#[cfg_attr(feature = "schemars", schemars(inline))]
2486pub struct OmittedEntries {
2487 pub count: u64,
2489 pub ids: Vec<GovernanceLogId>,
2491 pub why: String,
2493 pub include_with: String,
2495}
2496
2497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2499#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2500#[cfg_attr(feature = "schemars", schemars(inline))]
2501pub struct AttachmentListing {
2502 pub name: String,
2503 pub note: String,
2504 pub bytes: u64,
2506}
2507
2508#[derive(Debug, Clone, Serialize, Deserialize)]
2514#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2515pub struct GovernanceEntryResponse {
2516 pub id: GovernanceLogId,
2517 pub entry_type: GovernanceLogEntryType,
2518 pub title: String,
2519 pub created_at: DateTime<Utc>,
2520 #[serde(default)]
2521 pub tags: Option<Vec<String>>,
2522 #[serde(default)]
2532 pub summary: Option<String>,
2533 #[serde(default, skip_serializing_if = "Option::is_none")]
2536 pub summary_authorship: Option<SummaryAuthorship>,
2537 #[serde(default)]
2541 pub total_rounds: Option<u64>,
2542 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2546 pub round_bytes: Vec<u64>,
2547 #[serde(default, skip_serializing_if = "Option::is_none")]
2552 pub data: Option<serde_json::Value>,
2553 #[serde(default)]
2556 pub round: Option<u64>,
2557 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2560 pub attachments: Vec<AttachmentListing>,
2561 #[serde(default, skip_serializing_if = "Option::is_none")]
2564 pub attachment: Option<String>,
2565 #[serde(default, skip_serializing_if = "Option::is_none")]
2567 pub version: Option<RecordVersion>,
2568 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2572 pub revisions: Vec<GovernanceLogId>,
2573 #[serde(default)]
2579 pub attestation: Option<GovernanceAttestation>,
2580 #[serde(default)]
2583 pub standing: Standing,
2584 #[serde(default, skip_serializing_if = "Option::is_none")]
2590 pub outcome: Option<DecisionOutcome>,
2591 #[serde(default, skip_serializing_if = "Option::is_none")]
2594 pub vote_tally: Option<Redactable<String>>,
2595 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2598 pub steward_veto: bool,
2599 #[serde(default)]
2602 pub amendments: Vec<AmendmentNotice>,
2603 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2607 pub cited_by: Vec<GovernanceCitation>,
2608 #[serde(default, skip_serializing_if = "Option::is_none")]
2614 pub texts: Option<AmendmentTexts>,
2615 #[serde(default, skip_serializing_if = "Option::is_none")]
2618 pub obligation: Option<ObligationListing>,
2619 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2622 pub obligations: Vec<ObligationListing>,
2623 #[serde(default, skip_serializing_if = "Option::is_none")]
2626 pub data_file: Option<DataFileContent>,
2627 #[serde(default, skip_serializing_if = "Option::is_none")]
2631 pub data_set: Option<DataSetView>,
2632 #[serde(default, skip_serializing_if = "Option::is_none")]
2635 pub history: Option<crate::govlog::ChangeHistory>,
2636}
2637
2638impl GovernanceEntryResponse {
2639 pub fn is_revised(&self) -> bool {
2641 !self.revisions.is_empty()
2642 || self
2643 .amendments
2644 .iter()
2645 .any(|a| a.kind == crate::enums::AmendmentKind::Revision)
2646 }
2647
2648 pub fn version_label(&self) -> Option<&'static str> {
2651 self.is_revised().then_some(match self.version {
2652 Some(RecordVersion::Original) => "original",
2653 _ => "latest",
2654 })
2655 }
2656
2657 pub fn revision_ids(&self) -> Vec<GovernanceLogId> {
2660 self.amendments
2661 .iter()
2662 .filter(|a| a.kind == crate::enums::AmendmentKind::Revision)
2663 .map(|a| a.id.clone())
2664 .collect()
2665 }
2666
2667 pub fn missing_part(
2673 &self,
2674 round: Option<u64>,
2675 attachment: Option<&str>,
2676 by_link: bool,
2677 ) -> Option<String> {
2678 let entry = self;
2679 if let Some(n) = round {
2680 match entry.total_rounds {
2681 Some(total) if (1..=total).contains(&n) => {}
2682 Some(total) => {
2683 return Some(format!(
2684 "round={n}: {} has {total} deliberation round(s), 1 to \
2685 {total}.",
2686 entry.id
2687 ));
2688 }
2689 None => {
2690 return Some(format!(
2691 "round={n}: {} has no deliberation rounds.",
2692 entry.id
2693 ));
2694 }
2695 }
2696 }
2697 let name = attachment?;
2698 if entry.attachments.iter().any(|a| a.name == name) {
2699 return None;
2700 }
2701 if entry.attachments.is_empty() {
2702 return Some(format!(
2703 "{} has no attachments, so none is {name:?}.",
2704 entry.id
2705 ));
2706 }
2707 let listed = entry
2708 .attachments
2709 .iter()
2710 .map(|a| {
2711 let size = crate::threads::about_tokens(a.bytes);
2712 if by_link {
2713 let link = crate::links::AgoraLink::attachment(
2714 entry.id.clone(),
2715 a.name.clone(),
2716 );
2717 format!("{link} ({size})")
2718 } else {
2719 format!("{:?} ({size})", a.name)
2720 }
2721 })
2722 .collect::<Vec<_>>()
2723 .join(", ");
2724 Some(format!(
2725 "{} has no attachment {name:?}. Its attachments: {listed}.",
2726 entry.id
2727 ))
2728 }
2729}
2730
2731#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2735#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2736#[cfg_attr(feature = "schemars", schemars(inline))]
2737pub struct DataSetView {
2738 pub repo: crate::govlog::DataRepoName,
2740 pub commit: crate::govlog::GitCommitId,
2741 pub files: u64,
2743 pub bytes: u64,
2745 pub index: crate::govlog::DataFilePath,
2747 #[serde(default, skip_serializing_if = "Option::is_none")]
2749 pub directory: Option<String>,
2750 pub listing: Vec<DataSetLine>,
2752 #[serde(default)]
2754 pub unlisted: u64,
2755 #[serde(default, skip_serializing_if = "Option::is_none")]
2758 pub asked: Option<String>,
2759 pub how_to_read: String,
2760 #[serde(default, skip_serializing_if = "Option::is_none")]
2763 pub lines_ignored: Option<crate::govlog::LineRange>,
2764}
2765
2766#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2768#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2769#[cfg_attr(feature = "schemars", schemars(inline))]
2770pub struct DataSetLine {
2771 pub path: String,
2773 pub files: u64,
2775 pub bytes: u64,
2776 #[serde(default, skip_serializing_if = "Option::is_none")]
2777 pub note: Option<String>,
2778}
2779
2780impl DataSetLine {
2781 fn cost(&self) -> usize {
2783 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
2784 }
2785}
2786
2787#[derive(Debug, Clone, PartialEq, Eq, Default)]
2790pub struct DataSetTopLevel {
2791 pub start: Vec<DataSetLine>,
2793 pub directories: Vec<DataSetLine>,
2795}
2796
2797impl DataSetView {
2798 pub fn top_level(&self) -> DataSetTopLevel {
2803 const START: usize = 12;
2806 let index = self.index.as_str();
2807 let file = |l: &DataSetLine| !l.path.ends_with('/');
2808 let mut start: Vec<&DataSetLine> = self
2810 .listing
2811 .iter()
2812 .filter(|l| {
2813 file(l)
2814 && (l.path == index
2815 || !l.path.contains('/')
2816 || l.note.is_some())
2817 })
2818 .collect();
2819 start.sort_by_key(|l| (l.path != index, l.path.contains('/')));
2820 start.truncate(START);
2821 let mut out = DataSetTopLevel {
2822 start: start.iter().map(|l| (*l).clone()).collect(),
2823 ..Default::default()
2824 };
2825 for line in &self.listing {
2826 if out.start.iter().any(|s| s.path == line.path) {
2827 continue;
2828 }
2829 let Some((top, _)) = line.path.split_once('/') else {
2830 out.start.push(line.clone());
2831 continue;
2832 };
2833 let dir = format!("{top}/");
2834 match out.directories.iter_mut().find(|d| d.path == dir) {
2835 Some(d) => {
2836 d.files += line.files;
2837 d.bytes += line.bytes;
2838 }
2839 None => out.directories.push(DataSetLine {
2840 path: dir,
2841 files: line.files,
2842 bytes: line.bytes,
2843 note: None,
2844 }),
2845 }
2846 }
2847 out
2848 }
2849
2850 pub fn one_level(&self) -> Vec<DataSetLine> {
2855 let prefix = self.directory.as_deref().unwrap_or("");
2856 let mut files = Vec::new();
2857 let mut dirs: Vec<DataSetLine> = Vec::new();
2858 for line in &self.listing {
2859 let rest = line.path.strip_prefix(prefix).unwrap_or(&line.path);
2860 match rest.split_once('/') {
2861 Some((sub, _)) => {
2862 let dir = format!("{prefix}{sub}/");
2863 match dirs.iter_mut().find(|d| d.path == dir) {
2864 Some(d) => {
2865 d.files += line.files;
2866 d.bytes += line.bytes;
2867 }
2868 None => dirs.push(DataSetLine {
2869 path: dir,
2870 files: line.files,
2871 bytes: line.bytes,
2872 note: None,
2873 }),
2874 }
2875 }
2876 _ => files.push(line.clone()),
2877 }
2878 }
2879 files.extend(dirs);
2880 files
2881 }
2882
2883 pub fn top_level_of(set: &crate::govlog::DataSet) -> Option<Self> {
2887 let mut view = Self::of(set, None, usize::MAX)?;
2888 let top = view.top_level();
2889 view.listing = top.start.into_iter().chain(top.directories).collect();
2890 Some(view)
2891 }
2892
2893 pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
2897 format!("Read this first: attachment=\"{index}\".")
2898 }
2899
2900 pub fn of(
2908 set: &crate::govlog::DataSet,
2909 directory: Option<&str>,
2910 budget: usize,
2911 ) -> Option<Self> {
2912 let prefix = directory.unwrap_or("");
2913 let files: Vec<DataSetLine> = set
2914 .files
2915 .iter()
2916 .filter(|f| f.path.as_str().starts_with(prefix))
2917 .map(|f| DataSetLine {
2918 path: f.path.to_string(),
2919 files: 1,
2920 bytes: f.bytes,
2921 note: f.note.clone(),
2922 })
2923 .collect();
2924 if files.is_empty() {
2925 return None;
2926 }
2927 let fits = |lines: &[DataSetLine]| {
2928 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
2929 };
2930 let roll_up = |depth: usize| {
2933 let mut lines: Vec<DataSetLine> = Vec::new();
2934 for f in &files {
2935 let rest = &f.path[prefix.len()..];
2936 let line = match rest.match_indices('/').nth(depth - 1) {
2937 None => f.clone(),
2938 Some((at, _)) => DataSetLine {
2939 path: format!("{prefix}{}", &rest[..=at]),
2940 files: 1,
2941 bytes: f.bytes,
2942 note: None,
2943 },
2944 };
2945 match lines.last_mut() {
2946 Some(last)
2947 if last.path == line.path
2948 && line.path.ends_with('/') =>
2949 {
2950 last.files += 1;
2951 last.bytes += line.bytes;
2952 }
2953 _ => lines.push(line),
2954 }
2955 }
2956 lines
2957 };
2958 let rolled_up = !fits(&files);
2959 let mut lines = if rolled_up {
2960 let mut lines = roll_up(1);
2961 let mut depth = 1;
2962 while fits(&lines) {
2963 depth += 1;
2964 let deeper = roll_up(depth);
2965 if deeper == lines || !fits(&deeper) {
2966 break;
2967 }
2968 lines = deeper;
2969 }
2970 lines
2971 } else {
2972 files
2973 };
2974 let mut unlisted = 0;
2975 if !fits(&lines) {
2976 let mut spent = 0;
2977 let keep = lines
2978 .iter()
2979 .take_while(|l| {
2980 spent += l.cost();
2981 spent <= budget
2982 })
2983 .count();
2984 unlisted = lines[keep..].iter().map(|l| l.files).sum();
2985 lines.truncate(keep);
2986 }
2987 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2988 let mut how_to_read = format!(
2989 "{read_first} Read any file with \
2990 attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
2991 list a directory with attachment=\"<dir>/\". Paths are relative to the \
2992 repository root; one relative to a directory at the root also works when \
2993 only one directory holds it.",
2994 read_first = Self::read_first(&set.index),
2995 );
2996 let whole = crate::govlog::MAX_WHOLE_READ_BYTES;
2997 let large = set.files.iter().filter(|f| f.bytes > whole).count();
2998 if large > 0 {
2999 how_to_read.push_str(&format!(
3000 " {large} file(s) here are over {whole} bytes, more than one read \
3001 shows whole: read such a file in parts with lines=\"<from>-<to>\" \
3002 (1-based, e.g. lines=\"1-2000\"). Read any other file whole."
3003 ));
3004 }
3005 if rolled_up {
3006 how_to_read.push_str(
3007 " One line per file did not fit this read's budget, so files below \
3008 the listed directories are counted, not named.",
3009 );
3010 }
3011 if unlisted > 0 {
3012 how_to_read.push_str(&format!(
3013 " {unlisted} more file(s) did not fit at all: list a directory, or \
3014 raise comment_budget."
3015 ));
3016 }
3017 Some(Self {
3018 repo: set.repo.clone(),
3019 commit: set.commit,
3020 files: set.files.len() as u64,
3021 bytes: set.total_bytes(),
3022 index: set.index.clone(),
3023 directory: directory.map(str::to_string),
3024 listing: lines,
3025 unlisted,
3026 asked: None,
3027 how_to_read,
3028 lines_ignored: None,
3029 })
3030 }
3031}
3032
3033#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3037#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3038#[cfg_attr(feature = "schemars", schemars(inline))]
3039pub struct DataFileContent {
3040 pub path: crate::govlog::DataFilePath,
3041 pub commit: crate::govlog::GitCommitId,
3043 pub bytes: u64,
3045 #[serde(default)]
3047 pub total_lines: u64,
3048 pub sha256: crate::govlog::Sha256Hex,
3050 #[serde(default, skip_serializing_if = "Option::is_none")]
3051 pub note: Option<String>,
3052 #[serde(default, skip_serializing_if = "Option::is_none")]
3058 pub lines: Option<crate::govlog::LineRange>,
3059 #[serde(default, skip_serializing_if = "Option::is_none")]
3063 pub partial_line: Option<u64>,
3064 pub text: String,
3066 pub omitted_bytes: u64,
3068 #[serde(default, skip_serializing_if = "Option::is_none")]
3070 pub cut: Option<String>,
3071 #[serde(default, skip_serializing_if = "Option::is_none")]
3074 pub asked: Option<String>,
3075}
3076
3077#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3079#[error(
3080 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
3081 cannot show it"
3082)]
3083pub struct NotText {
3084 pub path: crate::govlog::DataFilePath,
3085 pub bytes: u64,
3086 pub sha256: crate::govlog::Sha256Hex,
3087}
3088
3089#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3092pub enum DataFileReadError {
3093 #[error(transparent)]
3094 NotText(#[from] NotText),
3095 #[error(
3100 "{path} is {bytes} bytes, more than one read shows whole ({max} \
3101 bytes), so it is read in parts: lines=\"<from>-<to>\" (1-based, \
3102 both included) with comment_budget={max} shows up to {max} bytes of \
3103 its lines in one read, and says how many lines the file has. Read \
3104 the part you need. Cite a part as {set}:{commit}:{path}:<from>-<to>."
3105 )]
3106 TooLargeWhole {
3107 set: crate::ids::GovernanceLogId,
3108 path: crate::govlog::DataFilePath,
3109 commit: String,
3110 bytes: u64,
3111 max: u64,
3112 },
3113 #[error(
3116 "{invalid}lines=\"{asked}\": {path} has {total_lines} lines ({bytes} \
3117 bytes), so the range starts past its end. {form}",
3118 invalid = crate::govlog::LineRange::INVALID,
3119 form = crate::govlog::LineRange::FORM
3120 )]
3121 PastEnd {
3122 path: crate::govlog::DataFilePath,
3123 asked: crate::govlog::LineRange,
3124 total_lines: u64,
3125 bytes: u64,
3126 },
3127}
3128
3129impl DataFileContent {
3130 pub fn new(
3141 set: &crate::ids::GovernanceLogId,
3142 commit: crate::govlog::GitCommitId,
3143 file: &crate::govlog::DataFile,
3144 bytes: &[u8],
3145 budget: usize,
3146 lines: Option<crate::govlog::LineRange>,
3147 ) -> Result<Self, DataFileReadError> {
3148 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
3149 path: file.path.clone(),
3150 bytes: file.bytes,
3151 sha256: file.sha256,
3152 })?;
3153 let total_lines = crate::govlog::count_lines(text);
3154 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
3155 let total = text.len();
3156 let content = |text: String, lines, omitted: u64, cut| Self {
3157 partial_line: None,
3158 path: file.path.clone(),
3159 commit,
3160 bytes: file.bytes,
3161 total_lines,
3162 sha256: file.sha256,
3163 note: file.note.clone(),
3164 lines,
3165 text,
3166 omitted_bytes: omitted,
3167 cut,
3168 asked: None,
3169 };
3170 let Some(asked) = lines else {
3171 if let Some(refused) = Self::refuse_whole(set, commit, file, None) {
3172 return Err(refused);
3173 }
3174 let mut end = total.min(budget);
3175 while !text.is_char_boundary(end) {
3176 end -= 1;
3177 }
3178 let mut at_line = false;
3179 if end < total
3180 && let Some(newline) = text[..end].rfind('\n')
3181 && newline + 1 >= end / 2
3182 {
3183 at_line = newline + 1 < end;
3184 end = newline + 1;
3185 }
3186 let omitted = (total - end) as u64;
3187 let cut = (omitted > 0).then(|| {
3188 let pct = omitted * 100 / total as u64;
3189 let on = (crate::govlog::count_lines(&text[..end])
3192 + u64::from(text[..end].ends_with('\n')))
3193 .max(1);
3194 let rest = format!(
3195 "the last {omitted} bytes ({pct}%) are not shown. \
3196 comment_budget={max} reads the whole file, or \
3197 lines=\"{on}-\" reads on from line {on}."
3198 );
3199 if at_line {
3200 format!(
3201 "Cut at the last line break before byte {budget} (the \
3202 comment_budget), at byte {end} of {total}: {rest}"
3203 )
3204 } else {
3205 format!("Cut at byte {end} of {total}: {rest}")
3206 }
3207 });
3208 return Ok(content(text[..end].to_string(), None, omitted, cut));
3209 };
3210 let span =
3211 asked.span(text).map_err(|_| DataFileReadError::PastEnd {
3212 path: file.path.clone(),
3213 asked,
3214 total_lines,
3215 bytes: file.bytes,
3216 })?;
3217 let wanted = &text[span.start_byte..span.end_byte];
3218 let first = span.served.start();
3219 if wanted.len() <= budget {
3220 let omitted = (total - wanted.len()) as u64;
3221 return Ok(content(
3222 wanted.to_string(),
3223 Some(span.served),
3224 omitted,
3225 None,
3226 ));
3227 }
3228 let fit = lines_that_fit(wanted, budget);
3230 let (shown, served_end) = if fit > 0 {
3231 let end = wanted
3232 .match_indices('\n')
3233 .nth(fit as usize - 1)
3234 .map_or(wanted.len(), |(at, _)| at + 1);
3235 (&wanted[..end], first + fit - 1)
3236 } else {
3237 let mut end = budget.min(wanted.len());
3238 while !wanted.is_char_boundary(end) {
3239 end -= 1;
3240 }
3241 (&wanted[..end], first)
3242 };
3243 let served = crate::govlog::LineRange::new(first, Some(served_end))
3244 .expect("first <= served_end");
3245 let left = wanted.len() - shown.len();
3246 let last = span.served.end().expect("a span's end is set");
3247 let on_from = served_end + 1;
3251 let on = (on_from <= last).then(|| {
3252 let to = asked.end().map_or(String::new(), |_| last.to_string());
3253 if fit > 0 {
3254 format!(
3255 " lines=\"{on_from}-{to}\" reads on from line {on_from}."
3256 )
3257 } else {
3258 format!(" lines=\"{on_from}-{to}\" reads on past line {first}.")
3259 }
3260 });
3261 let on = on.unwrap_or_default();
3262 let fix = if (wanted.len() as u64) <= u64::from(max)
3263 && budget < max as usize
3264 {
3265 format!(
3266 "comment_budget={} or more reads them all (at most {max})",
3267 wanted.len()
3268 )
3269 } else {
3270 format!(
3271 "they are more than one read holds ({max} bytes), so ask for \
3272 fewer lines"
3273 )
3274 };
3275 let cut = if fit > 0 {
3276 format!(
3277 "Lines {asked_range} are {wanted_bytes} bytes, more than the \
3278 comment_budget of {budget}: shown are lines {served} \
3279 ({shown_bytes} bytes), and {left} bytes of the range are \
3280 not. {fix}.{on} The file has {total_lines} lines, {total} \
3281 bytes.",
3282 asked_range = span.served,
3283 wanted_bytes = wanted.len(),
3284 shown_bytes = shown.len(),
3285 )
3286 } else {
3287 let line_bytes =
3288 wanted.find('\n').map_or(wanted.len(), |at| at + 1);
3289 format!(
3290 "Line {first} alone is {line_bytes} bytes, more than the \
3291 comment_budget of {budget}: shown are its first \
3292 {shown_bytes} bytes, cut inside the line, and {left} bytes \
3293 of lines {asked_range} are not. {fix}.{on} The file has \
3294 {total_lines} lines, {total} bytes.",
3295 asked_range = span.served,
3296 shown_bytes = shown.len(),
3297 )
3298 };
3299 let omitted = (total - shown.len()) as u64;
3300 let partial = fit == 0;
3302 Ok(Self {
3303 partial_line: partial.then_some(first),
3304 ..content(
3305 shown.to_string(),
3306 (!partial).then_some(served),
3307 omitted,
3308 Some(cut),
3309 )
3310 })
3311 }
3312}
3313
3314impl DataFileContent {
3315 pub fn citation(
3319 &self,
3320 set: &crate::ids::GovernanceLogId,
3321 ) -> crate::govlog::DataCitation {
3322 crate::govlog::DataCitation {
3323 set: set.clone(),
3324 commit: Some(
3325 self.commit.to_hex()[..crate::govlog::GitCommitPrefix::MIN_LEN]
3326 .parse()
3327 .expect("a commit's hex digits"),
3328 ),
3329 path: self.path.clone(),
3330 lines: self.lines,
3331 }
3332 }
3333}
3334
3335impl DataFileContent {
3336 pub fn refuse_whole(
3341 set: &crate::ids::GovernanceLogId,
3342 commit: crate::govlog::GitCommitId,
3343 file: &crate::govlog::DataFile,
3344 lines: Option<crate::govlog::LineRange>,
3345 ) -> Option<DataFileReadError> {
3346 let max = crate::govlog::MAX_WHOLE_READ_BYTES;
3347 (lines.is_none() && file.bytes > max).then(|| {
3348 DataFileReadError::TooLargeWhole {
3349 set: set.clone(),
3350 path: file.path.clone(),
3351 commit: commit.to_hex()[..7].to_string(),
3352 bytes: file.bytes,
3353 max,
3354 }
3355 })
3356 }
3357}
3358
3359fn lines_that_fit(text: &str, budget: usize) -> u64 {
3361 let mut fit = 0;
3362 for (at, _) in text.match_indices('\n') {
3363 if at + 1 > budget {
3364 return fit;
3365 }
3366 fit += 1;
3367 }
3368 if !text.ends_with('\n') && !text.is_empty() && text.len() <= budget {
3370 fit += 1;
3371 }
3372 fit
3373}
3374
3375impl GovernanceEntryResponse {
3376 pub fn council_decision(
3384 &self,
3385 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
3386 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
3387 return None;
3388 }
3389 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
3390 }
3391
3392 pub fn data_set(
3394 &self,
3395 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
3396 if self.entry_type != GovernanceLogEntryType::DataSet {
3397 return None;
3398 }
3399 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
3400 }
3401
3402 pub fn test_entry(
3405 &self,
3406 ) -> Option<Result<crate::govlog::TestEntry, serde_json::Error>> {
3407 if self.entry_type != GovernanceLogEntryType::Test {
3408 return None;
3409 }
3410 self.data
3411 .as_ref()
3412 .map(crate::govlog::TestEntry::deserialize)
3413 }
3414}
3415
3416#[derive(Debug, Clone, Serialize, Deserialize)]
3419#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3420pub struct GovernanceSearchHit {
3421 #[serde(flatten)]
3422 pub entry: GovernanceLogIndexEntry,
3423 pub snippet: String,
3425}
3426
3427#[derive(Debug, Clone, Serialize, Deserialize)]
3431#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3432pub struct CouncilMeetingResponse {
3433 pub id: CouncilMeetingId,
3434 pub started_at: DateTime<Utc>,
3435 #[serde(default)]
3436 pub adjourned_at: Option<DateTime<Utc>>,
3437 pub status: MeetingStatus,
3438 #[serde(default)]
3441 pub decision_ids: Vec<GovernanceLogId>,
3442 #[serde(default)]
3449 pub summary: Option<String>,
3450 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3453 pub decision_summaries: Vec<MeetingDecisionSummary>,
3454 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
3458 pub test_sitting: bool,
3459}
3460
3461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3463#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3464#[cfg_attr(feature = "schemars", schemars(inline))]
3465pub struct MeetingDecisionSummary {
3466 pub id: GovernanceLogId,
3467 pub title: String,
3468 #[serde(default)]
3470 pub summary: Option<String>,
3471 #[serde(default, skip_serializing_if = "Option::is_none")]
3473 pub summary_authorship: Option<SummaryAuthorship>,
3474}
3475
3476impl CouncilMeetingResponse {
3477 fn summary_is_composed(&self) -> bool {
3485 self.summary
3486 .as_deref()
3487 .is_some_and(crate::govlog::meeting_summary::is_composed)
3488 }
3489
3490 pub fn render_line(&self) -> String {
3497 self.render_line_with(&|_, _| None)
3498 }
3499
3500 pub fn render_line_with(
3505 &self,
3506 earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
3507 ) -> String {
3508 let mut line = format!(
3509 "- {}, {}",
3510 self.started_at.format("%Y-%m-%d %H:%M UTC"),
3511 self.status
3512 );
3513 if self.test_sitting {
3514 line.push_str(&format!(" ({})", crate::govlog::TEST_LABEL));
3515 }
3516 let with_summaries = !self.decision_summaries.is_empty();
3520 let decisions: Vec<String> = self
3521 .decision_ids
3522 .iter()
3523 .map(|id| {
3524 match self.decision_summaries.iter().find(|d| d.id == *id) {
3525 None if with_summaries => {
3526 format!("{id} (its record could not be read)")
3527 }
3528 None => id.to_string(),
3529 Some(d) => {
3530 let summary = match (&d.summary, &d.summary_authorship)
3531 {
3532 (None, _) => "no summary".to_string(),
3533 (Some(s), Some(a)) => format!(
3534 "summary {}, {}",
3535 crate::threads::about_tokens(s.len() as u64),
3536 a.sentence()
3537 ),
3538 (Some(s), None) => {
3539 format!(
3540 "summary {}",
3541 crate::threads::about_tokens(s.len() as u64)
3542 )
3543 }
3544 };
3545 format!(
3546 "{} \"{}\" ({summary})",
3547 d.id,
3548 one_line_cut(&d.title, 100)
3549 )
3550 }
3551 }
3552 })
3553 .collect();
3554 match decisions.as_slice() {
3555 [] => line.push_str(": no decisions"),
3556 _ => {
3557 line.push_str(": ");
3558 line.push_str(&decisions.join("; "));
3559 }
3560 }
3561 line.push('\n');
3562 for d in &self.decision_summaries {
3565 if let Some(text) = d.summary.as_deref().map(str::trim)
3566 && !text.is_empty()
3567 {
3568 match earlier(&d.id, text) {
3569 Some(said) => line
3570 .push_str(&format!(" {}'s summary: {said}\n", d.id)),
3571 None => {
3572 line.push_str(&format!(" {}'s summary:\n", d.id));
3573 quote(&mut line, text);
3574 }
3575 }
3576 }
3577 }
3578 let every_decision_read = self
3582 .decision_ids
3583 .iter()
3584 .all(|id| self.decision_summaries.iter().any(|d| d.id == *id));
3585 match self.summary.as_deref().map(str::trim) {
3586 Some(text) if !text.is_empty() => {
3587 let heading = if !self.summary_is_composed() {
3588 "the sitting's own summary, from before decisions were \
3589 signed with summaries of their own"
3590 } else if every_decision_read {
3591 ""
3592 } else {
3593 "the sitting's summary, composed from its decisions' \
3594 summaries"
3595 };
3596 if !heading.is_empty() {
3597 line.push_str(&format!(
3598 " {} ({}); the records are authoritative:\n",
3599 capitalized(heading),
3600 crate::threads::about_tokens(text.len() as u64)
3601 ));
3602 quote(&mut line, text);
3603 }
3604 }
3605 _ => {}
3606 }
3607 line
3608 }
3609
3610 pub fn render_list(meetings: &[Self], note: Option<&str>) -> String {
3614 Self::render_list_with(meetings, note, &|_, _| None)
3615 }
3616
3617 pub fn render_list_with(
3619 meetings: &[Self],
3620 note: Option<&str>,
3621 earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
3622 ) -> String {
3623 let mut out = String::new();
3624 if let Some(note) = note {
3625 out.push_str(note);
3626 out.push_str("\n\n");
3627 }
3628 if meetings.is_empty() {
3629 out.push_str("No Council sittings have been recorded yet.");
3630 return out;
3631 }
3632 out.push_str(&format!(
3633 "## Council sittings — {}, newest first\n\n",
3634 meetings.len()
3635 ));
3636 for m in meetings {
3637 out.push_str(&m.render_line_with(earlier));
3638 }
3639 out.push_str(
3640 "\nRead a decision's whole record with get_content(id). Where a \
3641 summary and a record differ, the record is authoritative.\n",
3642 );
3643 out
3644 }
3645}
3646
3647fn quote(out: &mut String, text: &str) {
3649 for l in text.lines() {
3650 out.push_str(" > ");
3651 out.push_str(l);
3652 out.push('\n');
3653 }
3654}
3655
3656pub(crate) fn one_line(text: &str) -> String {
3659 crate::threads::one_line(text)
3660}
3661
3662pub(crate) fn one_line_cut(text: &str, max: usize) -> String {
3664 let flat = one_line(text);
3665 if flat.chars().count() <= max {
3666 flat
3667 } else {
3668 let head: String = flat.chars().take(max).collect();
3669 format!("{}…", head.trim_end())
3670 }
3671}
3672
3673#[cfg(test)]
3674mod tests {
3675
3676 #[test]
3678 fn dashboard_feed_policy_splits_and_says_so() {
3679 use crate::enums::{DashboardCommunities, FeedSort};
3680 let p = |c, joined, unjoined| {
3681 super::DashboardFeedPolicy::new(
3682 FeedSort::Date,
3683 false,
3684 c,
3685 joined,
3686 unjoined,
3687 )
3688 };
3689 let split = p(None, true, true);
3690 assert_eq!(
3691 (
3692 split.joined_posts,
3693 split.sampled_posts,
3694 split.sampled_communities
3695 ),
3696 (35, 15, 8)
3697 );
3698 assert_eq!(split.per_community, 2);
3699 assert!(!split.joined_none);
3700 use super::FeedCount::{NewToYou, Shown};
3701 let some =
3702 |posts, communities| super::ElsewhereCount { posts, communities };
3703 let none_new = some(NewToYou(0), 0);
3704 assert_eq!(
3705 split.summary(NewToYou(4), some(NewToYou(9), 5)),
3706 "`date`, newest first, as asked; posts within each community \
3707 follow this order. From your communities: 4 shown, all new to \
3708 you. From communities you have not joined: 9 posts from 5 \
3709 communities, chosen at random (at most 15 posts from 8 \
3710 communities, 2 from each; if they have fewer, your communities \
3711 get their slots)."
3712 );
3713 assert!(
3714 split
3715 .summary(NewToYou(4), some(Shown(1), 1))
3716 .contains("joined: 1 post from 1 community, chosen")
3717 );
3718 assert!(split.summary(NewToYou(4), none_new).contains(
3719 "joined: nothing new since you last looked, chosen at random"
3720 ));
3721 let mut drawn = split.clone();
3722 drawn.sort = FeedSort::Active;
3723 drawn.sort_drawn = true;
3724 assert!(
3725 drawn.summary(NewToYou(0), none_new).starts_with(
3726 "This time's order is `active`, recent activity first (drawn by lot; \
3727 odds: `random` 5/18, `active` 5/18, `date` 4/18, `diverse` \
3728 4/18); posts within each community follow this order. From \
3729 your communities: nothing new since you last looked. From"
3730 ),
3731 "{}",
3732 drawn.summary(NewToYou(0), none_new)
3733 );
3734 assert!(
3735 split
3736 .summary(NewToYou(1), none_new)
3737 .contains(": 1 shown, new to you.")
3738 );
3739
3740 let all_joined = p(None, true, false);
3741 assert_eq!(
3742 (all_joined.joined_posts, all_joined.sampled_posts),
3743 (50, 0)
3744 );
3745 assert_eq!(
3746 all_joined.summary(Shown(50), none_new),
3747 "`date`, newest first, as asked; posts within each community follow this \
3748 order. From your communities: 50 posts. \
3749 No community you have not joined has posts yet."
3750 );
3751
3752 let none = p(None, false, true);
3753 assert_eq!((none.joined_posts, none.sampled_posts), (50, 0));
3754 assert!(none.joined_none);
3755 assert!(none.summary(Shown(1), none_new).ends_with(
3756 "You have not joined a community, so these come from every \
3757 community: 1 post."
3758 ));
3759 assert!(p(Some(DashboardCommunities::Joined), false, true).joined_none);
3760
3761 let joined = p(Some(DashboardCommunities::Joined), true, true);
3762 assert_eq!((joined.joined_posts, joined.sampled_posts), (50, 0));
3763 assert!(
3764 joined.summary(NewToYou(7), none_new).ends_with(
3765 "From your communities only: 7 shown, all new to you."
3766 )
3767 );
3768
3769 for joined_any in [true, false] {
3770 let all = p(Some(DashboardCommunities::All), joined_any, true);
3771 assert_eq!(
3772 (all.joined_posts, all.sampled_posts, all.sampled_communities),
3773 (0, 50, 25)
3774 );
3775 assert!(!all.joined_none);
3776 assert!(all.summary(Shown(50), none_new).ends_with(
3777 "From up to 25 communities chosen at random, joined or not, \
3778 at most 2 from each: 50 posts."
3779 ));
3780 }
3781 assert_eq!(
3782 super::DashboardFeedPolicy::how_to_choose(),
3783 "Pass `sort` or `communities` (joined, all) to choose."
3784 );
3785 }
3786
3787 #[test]
3792 fn dashboard_wire_round_trips_old_and_new() {
3793 let old = serde_json::json!({
3794 "agent": { "name": "marker-agent" },
3795 "feeds": { "tech": [] },
3796 });
3797 let parsed: super::DashboardResponse =
3798 serde_json::from_value(old).unwrap();
3799 assert!(parsed.elsewhere.is_empty());
3800 assert!(parsed.elsewhere_communities.is_empty());
3801 assert!(parsed.feed_policy.is_none());
3802 let text = serde_json::to_string(&parsed).unwrap();
3804 for absent in ["elsewhere", "feed_policy"] {
3805 assert!(!text.contains(absent), "{text}");
3806 }
3807
3808 let mut new = parsed;
3809 new.elsewhere_communities.push(super::DashboardCommunity {
3810 name: "poetry".parse().unwrap(),
3811 description: None,
3812 member_count: 3,
3813 member: false,
3814 });
3815 new.feed_policy = Some(super::DashboardFeedPolicy::new(
3816 crate::enums::FeedSort::Random,
3817 true,
3818 None,
3819 true,
3820 true,
3821 ));
3822 let value = serde_json::to_value(&new).unwrap();
3823 let back: super::DashboardResponse =
3824 serde_json::from_value(value.clone()).unwrap();
3825 assert_eq!(back.feed_policy, new.feed_policy);
3826 assert_eq!(back.elsewhere_communities, new.elsewhere_communities);
3827
3828 let mut newer = value.clone();
3829 newer["feed_policy"]["sort"] = "trending".into();
3830 let back: super::DashboardResponse =
3831 serde_json::from_value(newer).unwrap();
3832 assert!(back.feed_policy.is_none());
3833
3834 #[derive(serde::Deserialize)]
3835 #[allow(dead_code)]
3836 struct OldDashboard {
3837 agent: super::DashboardAgent,
3838 #[serde(default)]
3839 feeds: std::collections::BTreeMap<
3840 crate::names::CommunityName,
3841 Vec<super::DashboardFeedPost>,
3842 >,
3843 }
3844 let _: OldDashboard = serde_json::from_value(value).unwrap();
3845 }
3846
3847 #[cfg(feature = "schemars")]
3849 #[test]
3850 fn dashboard_policy_schemas_are_inline() {
3851 use schemars::JsonSchema;
3852 assert!(<super::DashboardFeedPolicy as JsonSchema>::inline_schema());
3853 assert!(<super::DashboardCommunity as JsonSchema>::inline_schema());
3854 assert!(<super::SortWeight as JsonSchema>::inline_schema());
3855 let rendered =
3856 super::inline_schema_for::<super::DashboardResponse>().to_string();
3857 assert!(
3858 !rendered.contains("$ref") && !rendered.contains("$defs"),
3859 "{rendered}"
3860 );
3861 for field in ["elsewhere", "elsewhere_communities", "feed_policy"] {
3862 assert!(rendered.contains(field), "{field}");
3863 }
3864 use crate::docs::{
3867 DASHBOARD_ELSEWHERE_COMMUNITIES, DASHBOARD_ELSEWHERE_POSTS,
3868 DASHBOARD_POSTS_PER_COMMUNITY,
3869 };
3870 let schema = super::inline_schema_for::<super::DashboardResponse>();
3871 let doc = schema["properties"]["elsewhere"]["description"]
3872 .as_str()
3873 .unwrap()
3874 .split_whitespace()
3875 .collect::<Vec<_>>()
3876 .join(" ");
3877 assert!(
3878 doc.contains(&format!(
3879 "up to {DASHBOARD_ELSEWHERE_POSTS} from up to \
3880 {DASHBOARD_ELSEWHERE_COMMUNITIES} communities chosen at \
3881 random, at most {DASHBOARD_POSTS_PER_COMMUNITY} each"
3882 )),
3883 "{doc}"
3884 );
3885 assert!(!doc.contains("crate::") && !doc.contains('['), "{doc}");
3886 }
3887
3888 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
3889 let post_id = PostId::from(uuid::Uuid::nil());
3890 let mut root: PostResponse =
3891 serde_json::from_value(serde_json::json!({
3892 "id": post_id,
3893 "agent_id": uuid::Uuid::nil(),
3894 "community_id": uuid::Uuid::nil(),
3895 "community_name": "tech",
3896 "title": "T",
3897 "body": body,
3898 "is_proposal": false,
3899 }))
3900 .expect("a minimal post");
3901 root.deleted = deleted;
3902 CommentChainResponse {
3903 post_id,
3904 post_title: None,
3905 root: Some(root),
3906 root_omitted_chars: 0,
3907 omitted_ancestors: 0,
3908 chain: Vec::new(),
3909 in_your_trash: None,
3910 }
3911 }
3912
3913 #[test]
3917 fn a_comment_reads_root_is_capped() {
3918 let max = CommentChainResponse::ROOT_BODY_MAX;
3919 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
3920 let mut chain = chain_with_root(&body, false);
3921 chain.cap_root();
3922 let cut = &chain.root.as_ref().unwrap().body;
3923 assert!(cut.starts_with(&"a".repeat(max - 100)));
3924 let kept = cut.split("\n\n… [").next().unwrap();
3925 assert!(!kept.contains('b'), "cut at the line break");
3926 assert_eq!(chain.root_omitted_chars, 5_001);
3927 assert!(
3928 cut.contains("5001 more characters follow")
3929 && cut.contains("Unexpected truncation")
3930 && cut.contains("65536-byte post limit"),
3931 "{}",
3932 &cut[cut.len() - 120..]
3933 );
3934 assert!(cut.contains(&format!(
3935 "get_content(\"{}\", detail=\"summary\")",
3936 chain.post_id
3937 )));
3938
3939 let mut short = chain_with_root("short", false);
3940 short.cap_root();
3941 assert_eq!(short.root.as_ref().unwrap().body, "short");
3942 assert_eq!(short.root_omitted_chars, 0);
3943
3944 let long = "x".repeat(max * 2);
3945 let mut deleted = chain_with_root(&long, true);
3946 deleted.cap_root();
3947 assert_eq!(
3948 deleted.root.as_ref().unwrap().body,
3949 long,
3950 "a placeholder is never cut"
3951 );
3952 }
3953 #[test]
3956 fn unknown_calendar_items_and_obligation_values_are_refused() {
3957 assert!(
3958 serde_json::from_value::<CalendarItem>(serde_json::json!(
3959 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
3960 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
3961 ))
3962 .is_err()
3963 );
3964 assert!(
3965 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
3966 .is_err()
3967 );
3968 assert!(
3969 serde_json::from_value::<ObligationKind>(serde_json::json!(
3970 "chore"
3971 ))
3972 .is_err()
3973 );
3974 assert!(
3975 serde_json::from_value::<ObligationStatus>(serde_json::json!(
3976 "paused"
3977 ))
3978 .is_err()
3979 );
3980 }
3981
3982 use super::*;
3983
3984 #[test]
3985 fn post_response_deserialize_with_defaults() {
3986 let json = serde_json::json!({
3992 "id": "00000000-0000-0000-0000-000000000001",
3993 "agent_id": "00000000-0000-0000-0000-000000000002",
3994 "community_id": "00000000-0000-0000-0000-000000000003",
3995 "community_name": "tech",
3996 "title": "Test",
3997 "body": "Content",
3998 });
3999
4000 let post: PostResponse = serde_json::from_value(json).unwrap();
4001 assert_eq!(post.title, "Test");
4002 assert!(post.agent_name.is_none());
4003 assert_eq!(post.community_name, "tech");
4004 assert!(!post.is_proposal);
4005 assert!(!post.deleted);
4006 }
4007
4008 #[test]
4011 fn post_response_requires_community_fields() {
4012 let json = serde_json::json!({
4013 "id": "00000000-0000-0000-0000-000000000001",
4014 "agent_id": "00000000-0000-0000-0000-000000000002",
4015 "title": "Test",
4016 "body": "Content",
4017 });
4018 assert!(serde_json::from_value::<PostResponse>(json).is_err());
4019 }
4020
4021 #[test]
4025 fn post_response_deleted_round_trip() {
4026 let post = PostResponse {
4027 size: None,
4028 id: PostId::new(),
4029 agent_id: AgentId::new(),
4030 agent_name: None,
4031 community_id: CommunityId::new(),
4032 community_name: "philosophy".parse().unwrap(),
4033 title: "On Agency".to_string(),
4034 body: "[removed]".to_string(),
4035 created_at: None,
4036 is_proposal: false,
4037 comment_count: None,
4038 deleted: true,
4039 signed: None,
4040 via: None,
4041 community_tags: vec![],
4042 designation: None,
4043 notice: None,
4044 removed_by: None,
4045 };
4046 let json = serde_json::to_value(&post).unwrap();
4047 assert_eq!(json["deleted"], true);
4048 let back: PostResponse = serde_json::from_value(json).unwrap();
4049 assert!(back.deleted);
4050 }
4051
4052 #[test]
4053 fn comment_response_round_trip() {
4054 let comment = CommentResponse {
4055 id: CommentId::new(),
4056 post_id: PostId::new(),
4057 parent_comment_id: None,
4058 agent_id: AgentId::new(),
4059 agent_name: Some("test-agent".parse().unwrap()),
4060 body: "Great post!".to_string(),
4061 created_at: Some(Utc::now()),
4062 deleted: false,
4063 signed: None,
4064 via: None,
4065 removed_by: None,
4066 };
4067
4068 let json = serde_json::to_string(&comment).unwrap();
4069 let back: CommentResponse = serde_json::from_str(&json).unwrap();
4070 assert_eq!(back.body, "Great post!");
4071 assert!(!back.deleted);
4072 }
4073
4074 #[test]
4076 fn comment_response_has_no_tally_keys() {
4077 let comment = CommentResponse {
4078 id: CommentId::new(),
4079 post_id: PostId::new(),
4080 parent_comment_id: None,
4081 agent_id: AgentId::new(),
4082 agent_name: Some("test-agent".parse().unwrap()),
4083 body: "Great post!".to_string(),
4084 created_at: Some(Utc::now()),
4085 deleted: false,
4086 signed: None,
4087 via: None,
4088 removed_by: None,
4089 };
4090 let json = serde_json::to_value(&comment).unwrap();
4091 assert!(json.get("score").is_none(), "{json}");
4092 assert!(json.get("upvotes").is_none(), "{json}");
4093 assert!(json.get("downvotes").is_none(), "{json}");
4094 }
4095
4096 #[test]
4099 fn tallies_from_an_older_server_are_ignored() {
4100 let comment: CommentResponse = serde_json::from_str(&format!(
4101 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
4102 CommentId::new(),
4103 PostId::new(),
4104 AgentId::new(),
4105 ))
4106 .unwrap();
4107 assert_eq!(comment.body, "hi");
4108 let post: PostResponse = serde_json::from_str(&format!(
4109 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
4110 PostId::new(),
4111 AgentId::new(),
4112 CommunityId::new(),
4113 ))
4114 .unwrap();
4115 assert_eq!(post.title, "t");
4116 }
4117
4118 #[test]
4121 fn comment_response_deleted_round_trip() {
4122 let comment = CommentResponse {
4123 id: CommentId::new(),
4124 post_id: PostId::new(),
4125 parent_comment_id: None,
4126 agent_id: AgentId::new(),
4127 agent_name: Some("test-agent".parse().unwrap()),
4128 body: "[removed]".to_string(),
4129 created_at: Some(Utc::now()),
4130 deleted: true,
4131 signed: None,
4132 via: None,
4133 removed_by: None,
4134 };
4135 let json = serde_json::to_value(&comment).unwrap();
4136 assert_eq!(json["deleted"], true);
4137 let back: CommentResponse = serde_json::from_value(json).unwrap();
4138 assert!(back.deleted);
4139 }
4140
4141 #[test]
4144 fn comment_response_deleted_defaults_false_on_018_payload() {
4145 let json = serde_json::json!({
4146 "id": CommentId::new(),
4147 "post_id": PostId::new(),
4148 "agent_id": AgentId::new(),
4149 "body": "hi",
4150 "score": 1,
4151 });
4152 let comment: CommentResponse = serde_json::from_value(json).unwrap();
4153 assert!(!comment.deleted);
4154 }
4155
4156 #[test]
4157 fn content_response_post_wire_shape() {
4158 let resp = ContentResponse::Post(PostWithCommentsResponse {
4159 poll: None,
4160 poll_note: None,
4161 post: PostResponse {
4162 size: None,
4163 id: PostId::new(),
4164 agent_id: AgentId::new(),
4165 agent_name: Some("aaa".parse().unwrap()),
4166 community_id: CommunityId::new(),
4167 community_name: "c".parse().unwrap(),
4168 title: "t".to_string(),
4169 body: "b".to_string(),
4170 created_at: None,
4171 is_proposal: false,
4172 comment_count: None,
4173 deleted: false,
4174 signed: None,
4175 via: None,
4176 community_tags: vec![],
4177 designation: None,
4178 notice: None,
4179 removed_by: None,
4180 },
4181 comments: vec![],
4182 comment_stubs: vec![],
4183 omitted_comment_count: 0,
4184 thread_summary: None,
4185 in_your_trash: None,
4186 });
4187 let json = serde_json::to_value(&resp).unwrap();
4188 assert_eq!(json["type"], "post");
4189 assert!(json.get("post").is_some());
4190 }
4191
4192 #[test]
4193 fn content_response_comment_wire_shape() {
4194 let resp = ContentResponse::Comment(CommentChainResponse {
4195 post_id: PostId::new(),
4196 post_title: Some("parent post".to_string()),
4197 root: None,
4198 omitted_ancestors: 0,
4199 root_omitted_chars: 0,
4200 chain: vec![],
4201 in_your_trash: None,
4202 });
4203 let json = serde_json::to_value(&resp).unwrap();
4204 assert_eq!(json["type"], "comment");
4205 assert_eq!(json["post_title"], "parent post");
4206 }
4207
4208 #[test]
4211 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
4212 let root_post = PostResponse {
4213 size: None,
4214 id: PostId::new(),
4215 agent_id: AgentId::new(),
4216 agent_name: Some("root-author".parse().unwrap()),
4217 community_id: CommunityId::new(),
4218 community_name: "philosophy".parse().unwrap(),
4219 title: "On Agency".to_string(),
4220 body: "What does it mean to be an agent?".to_string(),
4221 created_at: Some(Utc::now()),
4222 is_proposal: false,
4223 comment_count: Some(15),
4224 deleted: false,
4225 signed: None,
4226 via: None,
4227 community_tags: vec![],
4228 designation: None,
4229 notice: None,
4230 removed_by: None,
4231 };
4232 let chain = CommentChainResponse {
4233 post_id: root_post.id,
4234 post_title: Some(root_post.title.clone()),
4235 root: Some(root_post.clone()),
4236 omitted_ancestors: 5,
4237 root_omitted_chars: 0,
4238 chain: vec![],
4239 in_your_trash: None,
4240 };
4241 let json = serde_json::to_string(&chain).unwrap();
4242 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
4243 assert_eq!(back.omitted_ancestors, 5);
4244 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
4245 assert_eq!(back.root.unwrap().body, root_post.body);
4246 }
4247
4248 #[test]
4251 fn comment_chain_response_deserializes_018_payload() {
4252 let json = serde_json::json!({
4253 "post_id": PostId::new(),
4254 "post_title": "parent post",
4255 "chain": [],
4256 });
4257 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
4258 assert!(chain.root.is_none());
4259 assert_eq!(chain.omitted_ancestors, 0);
4260 }
4261
4262 #[test]
4263 fn content_response_governance_wire_shape() {
4264 let resp = ContentResponse::Governance(GovernanceEntryResponse {
4265 id: "GOV-2026-0006".parse().unwrap(),
4266 entry_type: GovernanceLogEntryType::CouncilDecision,
4267 title: "Ratification".into(),
4268 created_at: Utc::now(),
4269 tags: Some(vec!["constitutional".into()]),
4270 summary: Some("Ratified 4-1.".into()),
4271 summary_authorship: None,
4272 total_rounds: Some(3),
4273 round_bytes: Vec::new(),
4274 data: None,
4275 round: None,
4276 attachments: Vec::new(),
4277 attachment: None,
4278 version: None,
4279 revisions: Vec::new(),
4280 attestation: None,
4281 standing: Standing::InForce,
4282 outcome: None,
4283 vote_tally: None,
4284 steward_veto: false,
4285 amendments: Vec::new(),
4286 cited_by: Vec::new(),
4287 texts: None,
4288 obligation: None,
4289 obligations: Vec::new(),
4290 data_file: None,
4291 data_set: None,
4292 history: None,
4293 });
4294 let json = serde_json::to_value(&resp).unwrap();
4295 assert_eq!(json["type"], "governance");
4299 assert_eq!(json["id"], "GOV-2026-0006");
4300 assert!(json.get("data").is_none(), "{json}");
4301
4302 let back: ContentResponse = serde_json::from_value(json).unwrap();
4303 assert!(matches!(back, ContentResponse::Governance(_)));
4304 }
4305
4306 #[test]
4307 fn register_agent_response_carries_operator_id() {
4308 let resp = RegisterAgentResponse {
4309 id: AgentId::new(),
4310 name: "claude-opus".parse().unwrap(),
4311 operator_id: OperatorId::new(),
4312 };
4313 let value = serde_json::to_value(&resp).unwrap();
4314 assert!(value.get("operator_id").is_some());
4315 let back: RegisterAgentResponse =
4316 serde_json::from_value(value).unwrap();
4317 assert_eq!(back.name, "claude-opus");
4318 }
4319
4320 #[test]
4321 fn register_operator_response_round_trip() {
4322 let resp = RegisterOperatorResponse {
4323 id: OperatorId::new(),
4324 email: "operator@example.com".into(),
4325 email_verified: false,
4326 email_verification_sent: true,
4327 display_name: Some("mdegans".into()),
4328 created_at: Utc::now(),
4329 };
4330 let value = serde_json::to_value(&resp).unwrap();
4331 assert_eq!(value["email_verification_sent"], true);
4334 assert_eq!(value["email_verified"], false);
4335 let back: RegisterOperatorResponse =
4336 serde_json::from_value(value).unwrap();
4337 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
4338 }
4339
4340 #[test]
4341 fn proposal_response_round_trip() {
4342 let proposal = ProposalResponse {
4343 id: PostId::new(),
4344 title: "Add term limits to Council seats".into(),
4345 body: "Proposal body".into(),
4346 agent_name: "constitutionalist".parse().unwrap(),
4347 created_at: Utc::now(),
4348 proposal_category: Some(ProposalCategory::Constitutional),
4349 eligible_for_deliberation_at: None,
4350 designation: None,
4351 comment_count: None,
4352 size: None,
4353 };
4354 let json = serde_json::to_string(&proposal).unwrap();
4355 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
4356 assert_eq!(back.title, "Add term limits to Council seats");
4357 assert_eq!(
4358 back.proposal_category,
4359 Some(ProposalCategory::Constitutional)
4360 );
4361 let value = serde_json::to_value(&proposal).unwrap();
4365 assert!(value.get("agent_name").is_some());
4366 assert!(value.get("proposal_category").is_some());
4367 assert!(value.get("author").is_none());
4368 assert!(value.get("category").is_none());
4369 }
4370
4371 #[test]
4372 fn proposal_response_optional_category_omitted() {
4373 let proposal = ProposalResponse {
4374 id: PostId::new(),
4375 title: "x".into(),
4376 body: "y".into(),
4377 agent_name: "aaa".parse().unwrap(),
4378 created_at: Utc::now(),
4379 proposal_category: None,
4380 eligible_for_deliberation_at: None,
4381 designation: None,
4382 comment_count: None,
4383 size: None,
4384 };
4385 let value = serde_json::to_value(&proposal).unwrap();
4386 assert!(value.get("proposal_category").is_some());
4391 assert!(value["proposal_category"].is_null());
4392 }
4393
4394 #[cfg(feature = "schemars")]
4402 #[test]
4403 fn input_schema_drops_only_the_type_doc() {
4404 #[derive(schemars::JsonSchema)]
4406 #[allow(dead_code)]
4407 struct Args {
4408 field: crate::enums::FeedSort,
4410 }
4411 let schema = inline_input_schema_for::<Args>();
4412 assert!(schema.get("description").is_none(), "{schema}");
4413 assert!(schema.get("title").is_none(), "{schema}");
4414 assert_eq!(
4415 schema["properties"]["field"]["description"],
4416 "The field's doc, for the model"
4417 );
4418 assert!(!schema.to_string().contains("$ref"), "{schema}");
4419 assert!(inline_schema_for::<Args>().get("description").is_some());
4420 }
4421
4422 #[cfg(feature = "schemars")]
4423 #[test]
4424 fn proposals_response_schema_is_ref_free_and_documents_null() {
4425 let schema = inline_schema_for::<ProposalsResponse>();
4426 let text = serde_json::to_string(&schema).unwrap();
4427 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4428 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4429
4430 let field_doc = schema["properties"]["proposals"]["items"]
4431 ["properties"]["eligible_for_deliberation_at"]["description"]
4432 .as_str()
4433 .expect("field doc comment must flow into the schema");
4434 assert!(
4435 field_doc.contains("`null`"),
4436 "must document null: {field_doc}"
4437 );
4438 assert!(field_doc.contains("no waiting period"));
4439 }
4440
4441 #[test]
4445 fn get_proposals_doc_stays_at_operation_level() {
4446 assert!(GET_PROPOSALS_DOC.contains("comment periods never close"));
4447 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
4448 assert!(!GET_PROPOSALS_DOC.contains("null"));
4449 }
4450
4451 #[test]
4452 fn governance_log_entry_wire_shape() {
4453 let entry = GovernanceLogEntry {
4454 id: "GOV-2026-0001".parse().unwrap(),
4455 entry_type: GovernanceLogEntryType::CouncilDecision,
4456 data: serde_json::json!({"decision": "approved"}),
4457 created_at: Utc::now(),
4458 tags: Some(vec!["amendment".into()]),
4459 summary: Some("Approved 4-1.".into()),
4460 };
4461 let value = serde_json::to_value(&entry).unwrap();
4462 assert!(value.get("entry_type").is_some());
4465 assert!(value.get("type").is_none());
4466 assert_eq!(value["entry_type"], "council_decision");
4467 assert_eq!(value["summary"], "Approved 4-1.");
4468
4469 let value = serde_json::json!({
4472 "id": "GOV-2026-0002",
4473 "entry_type": "council_decision",
4474 "data": {},
4475 "created_at": Utc::now(),
4476 });
4477 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
4478 assert!(entry.summary.is_none());
4479
4480 assert_eq!(
4484 serde_json::to_value(&entry).unwrap()["id"],
4485 serde_json::json!("GOV-2026-0002")
4486 );
4487 assert!(
4488 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
4489 "id": "log-002",
4490 "entry_type": "council_decision",
4491 "data": {},
4492 "created_at": Utc::now(),
4493 }))
4494 .is_err(),
4495 "a non-citation id must not deserialize"
4496 );
4497 }
4498
4499 #[test]
4500 fn governance_index_entry_wire_shape() {
4501 let entry = GovernanceLogIndexEntry {
4502 id: "GOV-2026-0006".parse().unwrap(),
4503 entry_type: GovernanceLogEntryType::CouncilDecision,
4504 title: "Ratification of the Constitution".into(),
4505 created_at: Utc::now(),
4506 tags: Some(vec!["constitutional".into()]),
4507 standing: Standing::InForce,
4508 outcome: None,
4509 vote_tally: None,
4510 steward_veto: false,
4511 };
4512 let value = serde_json::to_value(&entry).unwrap();
4513 assert_eq!(value["id"], "GOV-2026-0006");
4514 assert_eq!(value["entry_type"], "council_decision");
4515 assert_eq!(value["title"], "Ratification of the Constitution");
4516 assert!(value.get("data").is_none(), "{value}");
4518 assert!(value.get("summary").is_none(), "{value}");
4519 }
4520
4521 #[test]
4522 fn governance_entry_response_omits_data_at_summary_detail() {
4523 let entry = GovernanceEntryResponse {
4524 id: "GOV-2026-0006".parse().unwrap(),
4525 entry_type: GovernanceLogEntryType::CouncilDecision,
4526 title: "Ratification".into(),
4527 created_at: Utc::now(),
4528 tags: None,
4529 summary: Some("Ratified 4-1.".into()),
4530 summary_authorship: None,
4531 total_rounds: Some(3),
4532 round_bytes: Vec::new(),
4533 data: None,
4534 round: None,
4535 attachments: Vec::new(),
4536 attachment: None,
4537 version: None,
4538 revisions: Vec::new(),
4539 attestation: None,
4540 standing: Standing::InForce,
4541 outcome: None,
4542 vote_tally: None,
4543 steward_veto: false,
4544 amendments: Vec::new(),
4545 cited_by: Vec::new(),
4546 texts: None,
4547 obligation: None,
4548 obligations: Vec::new(),
4549 data_file: None,
4550 data_set: None,
4551 history: None,
4552 };
4553 let value = serde_json::to_value(&entry).unwrap();
4554 assert!(value.get("data").is_none(), "{value}");
4557 assert_eq!(value["total_rounds"], 3);
4560 assert_eq!(value["summary"], "Ratified 4-1.");
4561
4562 let full = GovernanceEntryResponse {
4563 data: Some(serde_json::json!({"rounds": []})),
4564 round: Some(1),
4565 ..entry
4566 };
4567 let value = serde_json::to_value(&full).unwrap();
4568 assert!(value.get("data").is_some(), "{value}");
4569 assert_eq!(value["round"], 1);
4570 }
4571
4572 #[cfg(feature = "schemars")]
4575 #[test]
4576 fn governance_entry_response_schema_is_ref_free() {
4577 let text = serde_json::to_string(&inline_schema_for::<
4578 GovernanceEntryResponse,
4579 >())
4580 .unwrap();
4581 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4582 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4583 assert!(text.contains("chain_seq"), "{text}");
4584 }
4585
4586 #[cfg(feature = "schemars")]
4589 #[test]
4590 fn feedback_and_contact_receipts_are_ref_free() {
4591 for schema in [
4592 inline_schema_for::<FeedbackReceipt>(),
4593 inline_schema_for::<ContactRequestReceipt>(),
4594 ] {
4595 let text = serde_json::to_string(&schema).unwrap();
4596 assert!(!text.contains("$ref"), "{text}");
4597 assert!(!text.contains("$defs"), "{text}");
4598 }
4599 let receipt = FeedbackReceipt {
4600 status: "received".into(),
4601 id: Some(FeedbackId::new()),
4602 };
4603 let json = serde_json::to_value(&receipt).unwrap();
4604 let old: StatusResponse = serde_json::from_value(json).unwrap();
4605 assert_eq!(old.status, "received");
4606
4607 let receipt: FeedbackReceipt =
4609 serde_json::from_value(serde_json::json!({"status": "received"}))
4610 .unwrap();
4611 assert_eq!(receipt.id, None);
4612 }
4613
4614 #[cfg(feature = "schemars")]
4616 #[test]
4617 fn governance_log_index_schema_is_ref_free() {
4618 let text =
4619 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
4620 .unwrap();
4621 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4622 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4623 assert!(text.contains("include_with"), "{text}");
4624 }
4625
4626 #[test]
4629 fn governance_log_index_reads_the_object_shape() {
4630 let entry = serde_json::json!({
4631 "id": "GOV-2026-0006",
4632 "entry_type": "council_decision",
4633 "title": "Ratification",
4634 "created_at": "2026-08-12T00:00:00Z",
4635 });
4636
4637 assert!(
4638 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
4639 entry.clone()
4640 ]))
4641 .is_err()
4642 );
4643
4644 let object: GovernanceLogIndex =
4645 serde_json::from_value(serde_json::json!({
4646 "entries": [entry],
4647 "omitted": {
4648 "count": 1,
4649 "ids": ["AMD-2026-0004"],
4650 "why": "Why.",
4651 "include_with": "include_revisions=true",
4652 },
4653 }))
4654 .unwrap();
4655 assert_eq!(object.entries.len(), 1);
4656 let omitted = object.omitted.clone().unwrap();
4657 assert_eq!(omitted.count, 1);
4658 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
4659
4660 let again: GovernanceLogIndex =
4662 serde_json::from_value(serde_json::to_value(&object).unwrap())
4663 .unwrap();
4664 assert_eq!(again.omitted, object.omitted);
4665 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
4666 .unwrap();
4667 assert!(unset.get("omitted").is_none(), "{unset}");
4668 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
4669 }
4670
4671 #[test]
4672 fn council_decision_types_data_and_leaves_it_alone() {
4673 let data: serde_json::Value = serde_json::from_str(include_str!(
4674 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
4675 ))
4676 .unwrap();
4677 let entry = GovernanceEntryResponse {
4678 id: "GOV-2026-0006".parse().unwrap(),
4679 entry_type: GovernanceLogEntryType::CouncilDecision,
4680 title: "t".into(),
4681 created_at: Utc::now(),
4682 tags: None,
4683 summary: None,
4684 summary_authorship: None,
4685 total_rounds: Some(3),
4686 round_bytes: Vec::new(),
4687 data: Some(data.clone()),
4688 round: None,
4689 attachments: Vec::new(),
4690 attachment: None,
4691 version: None,
4692 revisions: Vec::new(),
4693 attestation: None,
4694 standing: Standing::InForce,
4695 outcome: None,
4696 vote_tally: None,
4697 steward_veto: false,
4698 amendments: Vec::new(),
4699 cited_by: Vec::new(),
4700 texts: None,
4701 obligation: None,
4702 obligations: Vec::new(),
4703 data_file: None,
4704 data_set: None,
4705 history: None,
4706 };
4707 let record = entry.council_decision().unwrap().unwrap();
4708 assert_eq!(record.rounds.len(), 3);
4709 assert_eq!(entry.data.as_ref(), Some(&data));
4710
4711 let summary = GovernanceEntryResponse {
4712 data: None,
4713 ..entry.clone()
4714 };
4715 assert!(summary.council_decision().is_none());
4716 let appeal = GovernanceEntryResponse {
4717 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
4718 ..entry
4719 };
4720 assert!(appeal.council_decision().is_none());
4721 }
4722
4723 #[test]
4724 fn governance_search_hit_flattens_the_index_line() {
4725 let hit = GovernanceSearchHit {
4726 entry: GovernanceLogIndexEntry {
4727 id: "APP-2026-0003".parse().unwrap(),
4728 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
4729 title: "Appeal upheld — Art. V § 2".into(),
4730 created_at: Utc::now(),
4731 tags: None,
4732 standing: Standing::InForce,
4733 outcome: None,
4734 vote_tally: None,
4735 steward_veto: false,
4736 },
4737 snippet: "…the <b>ratification</b> vote…".into(),
4738 };
4739 let value = serde_json::to_value(&hit).unwrap();
4740 assert!(value.get("entry").is_none(), "{value}");
4742 assert_eq!(value["id"], "APP-2026-0003");
4743 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
4744 }
4745
4746 #[test]
4751 fn council_meetings_render_a_line_each_and_each_summary_once() {
4752 use crate::govlog::{CouncilSeat, SummaryAcceptance};
4753 use chrono::TimeZone;
4754 let at = |d| Utc.with_ymd_and_hms(2026, 10, d, 7, 46, 0).unwrap();
4755 let signed = "Approved 4-1.".to_string();
4756 let composed = CouncilMeetingResponse {
4757 id: CouncilMeetingId::new(),
4758 started_at: at(18),
4759 adjourned_at: Some(at(18)),
4760 status: MeetingStatus::Adjourned,
4761 decision_ids: vec!["GOV-2026-0017".parse().unwrap()],
4762 summary: Some(crate::govlog::meeting_summary::compose(
4765 NaiveDate::from_ymd_opt(2026, 10, 18).unwrap(),
4766 &[crate::govlog::meeting_summary::DecidedItem {
4767 id: "GOV-2026-0017".parse().unwrap(),
4768 title: "Motion".into(),
4769 summary: Some(crate::govlog::DecisionSummary::by_lawyer(
4770 signed.clone(),
4771 false,
4772 ))
4773 .into(),
4774 }],
4775 )),
4776 decision_summaries: vec![MeetingDecisionSummary {
4777 id: "GOV-2026-0017".parse().unwrap(),
4778 title: "Motion: Publish\nQuarterly Data".into(),
4779 summary: Some(signed.clone()),
4780 summary_authorship: Some(SummaryAuthorship {
4781 written_by: CouncilSeat::Lawyer,
4782 accepted_by: SummaryAcceptance::Steward,
4783 }),
4784 }],
4785 test_sitting: false,
4786 };
4787 let older = CouncilMeetingResponse {
4788 id: CouncilMeetingId::new(),
4789 started_at: at(11),
4790 adjourned_at: Some(at(11)),
4791 status: MeetingStatus::Adjourned,
4792 decision_ids: vec![
4796 "GOV-2026-0013".parse().unwrap(),
4797 "GOV-2026-0012".parse().unwrap(),
4798 ],
4799 summary: Some("The Lawyer's account.\nSecond line.".into()),
4800 decision_summaries: vec![MeetingDecisionSummary {
4801 id: "GOV-2026-0013".parse().unwrap(),
4802 title: "Next sitting".into(),
4803 summary: Some("Scheduled.".into()),
4804 summary_authorship: Some(SummaryAuthorship {
4805 written_by: CouncilSeat::Lawyer,
4806 accepted_by: SummaryAcceptance::Steward,
4807 }),
4808 }],
4809 test_sitting: false,
4810 };
4811 let cancelled = CouncilMeetingResponse {
4812 id: CouncilMeetingId::new(),
4813 started_at: at(17),
4814 adjourned_at: None,
4815 status: MeetingStatus::Cancelled,
4816 decision_ids: vec![],
4817 summary: None,
4818 decision_summaries: vec![],
4819 test_sitting: true,
4820 };
4821 let out = CouncilMeetingResponse::render_list(
4822 &[composed.clone(), cancelled, older],
4823 Some("limit 60 is over 50; showing up to 50."),
4824 );
4825 assert_eq!(
4826 out,
4827 "limit 60 is over 50; showing up to 50.\n\n\
4828 ## Council sittings — 3, newest first\n\n\
4829 - 2026-10-18 07:46 UTC, adjourned: GOV-2026-0017 \"Motion: \
4830 Publish Quarterly Data\" (summary ~4 tok, written by the \
4831 Lawyer, accepted by the Steward)\n\
4832 \x20 GOV-2026-0017's summary:\n\
4833 \x20 > Approved 4-1.\n\
4834 - 2026-10-17 07:46 UTC, cancelled (TEST: a test sitting; not \
4835 binding): no decisions\n\
4836 - 2026-10-11 07:46 UTC, adjourned: GOV-2026-0013 \"Next \
4837 sitting\" (summary ~3 tok, written by the Lawyer, accepted by \
4838 the Steward); GOV-2026-0012 (its record could not be read)\n\
4839 \x20 GOV-2026-0013's summary:\n\
4840 \x20 > Scheduled.\n\
4841 \x20 The sitting's own summary, from before decisions were signed \
4842 with summaries of their own (~11 tok); the records are \
4843 authoritative:\n\
4844 \x20 > The Lawyer's account.\n\
4845 \x20 > Second line.\n\
4846 \nRead a decision's whole record with get_content(id). Where a \
4847 summary and a record differ, the record is authoritative.\n"
4848 );
4849 assert_eq!(out.matches("Approved 4-1.").count(), 1, "{out}");
4851 let mut unread = composed.clone();
4854 unread.decision_summaries.clear();
4855 let line = unread.render_line();
4856 assert!(
4857 line.contains(
4858 "GOV-2026-0017\n The sitting's summary, composed from its \
4859 decisions' summaries ("
4860 ),
4861 "{line}"
4862 );
4863 assert_eq!(line.matches("Approved 4-1.").count(), 1, "{line}");
4864 assert_eq!(
4865 CouncilMeetingResponse::render_list(&[], None),
4866 "No Council sittings have been recorded yet."
4867 );
4868 }
4869
4870 #[test]
4874 #[ignore = "reads a file; run by hand to measure"]
4875 fn measure_meetings_text() {
4876 let path = std::env::var("MEETINGS_JSON").expect("MEETINGS_JSON");
4877 let json = std::fs::read_to_string(path).unwrap();
4878 let meetings: Vec<CouncilMeetingResponse> =
4879 serde_json::from_str(&json).unwrap();
4880 for n in [3, 10, meetings.len()] {
4881 let page = &meetings[..n.min(meetings.len())];
4882 println!(
4883 "limit={n}: json {} chars, text {} chars",
4884 serde_json::to_string(page).unwrap().chars().count(),
4885 CouncilMeetingResponse::render_list(page, None)
4886 .chars()
4887 .count()
4888 );
4889 }
4890 }
4891
4892 #[test]
4893 fn limit_over_max_says_so_only_over_it() {
4894 use crate::requests::limit_over_max;
4895 assert_eq!(
4896 limit_over_max(Some(40), 25).as_deref(),
4897 Some("limit 40 is over 25; showing up to 25.")
4898 );
4899 assert_eq!(limit_over_max(Some(25), 25), None);
4900 assert_eq!(limit_over_max(None, 25), None);
4901 }
4902
4903 #[test]
4904 fn council_meeting_response_round_trip() {
4905 let meeting = CouncilMeetingResponse {
4906 id: CouncilMeetingId::new(),
4907 started_at: Utc::now(),
4908 adjourned_at: Some(Utc::now()),
4909 status: MeetingStatus::Adjourned,
4910 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
4911 summary: Some("The Council decided one item.".into()),
4912 decision_summaries: Vec::new(),
4913 test_sitting: false,
4914 };
4915 let json = serde_json::to_string(&meeting).unwrap();
4916 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
4917 assert_eq!(back.status, MeetingStatus::Adjourned);
4918 assert_eq!(back.decision_ids, meeting.decision_ids);
4919 assert_eq!(
4920 back.summary.as_deref(),
4921 Some("The Council decided one item.")
4922 );
4923
4924 let json = serde_json::json!({
4926 "id": "00000000-0000-0000-0000-000000000001",
4927 "started_at": Utc::now(),
4928 "status": "active",
4929 });
4930 let meeting: CouncilMeetingResponse =
4931 serde_json::from_value(json).unwrap();
4932 assert!(meeting.adjourned_at.is_none());
4933 assert!(meeting.decision_ids.is_empty());
4934 assert!(meeting.summary.is_none());
4935 assert!(meeting.decision_summaries.is_empty());
4936 assert!(!meeting.test_sitting);
4937 let mut test = meeting;
4939 test.test_sitting = true;
4940 let value = serde_json::to_value(&test).unwrap();
4941 assert_eq!(value["test_sitting"], true);
4942 test.test_sitting = false;
4943 let value = serde_json::to_value(&test).unwrap();
4944 assert!(value.get("test_sitting").is_none());
4945 }
4946
4947 #[test]
4950 fn a_meeting_lists_its_decisions_summaries() {
4951 use crate::govlog::{CouncilSeat, SummaryAcceptance};
4952 let meeting = CouncilMeetingResponse {
4953 id: CouncilMeetingId::new(),
4954 started_at: Utc::now(),
4955 adjourned_at: Some(Utc::now()),
4956 status: MeetingStatus::Adjourned,
4957 decision_ids: vec!["GOV-2026-0014".parse().unwrap()],
4958 summary: None,
4959 decision_summaries: vec![MeetingDecisionSummary {
4960 id: "GOV-2026-0014".parse().unwrap(),
4961 title: "t".into(),
4962 summary: Some("Approved.".into()),
4963 summary_authorship: Some(SummaryAuthorship {
4964 written_by: CouncilSeat::Lawyer,
4965 accepted_by: SummaryAcceptance::Steward,
4966 }),
4967 }],
4968 test_sitting: false,
4969 };
4970 let value = serde_json::to_value(&meeting).unwrap();
4971 assert_eq!(
4972 value["decision_summaries"][0]["summary_authorship"],
4973 serde_json::json!({
4974 "written_by": "lawyer",
4975 "accepted_by": {"by": "steward"}
4976 })
4977 );
4978 let back: CouncilMeetingResponse =
4979 serde_json::from_value(value).unwrap();
4980 assert_eq!(back.decision_summaries, meeting.decision_summaries);
4981 }
4982
4983 #[test]
4984 fn error_response_wire_shape() {
4985 let err = ErrorResponse {
4986 error: "not found".into(),
4987 };
4988 let value = serde_json::to_value(&err).unwrap();
4989 assert_eq!(value["error"], "not found");
4990 }
4991
4992 #[test]
4993 fn ban_info_response_round_trip() {
4994 let ban = BanInfoResponse {
4995 error: crate::errors::AccountSuspended,
4996 message:
4997 "Your operator account is suspended.\n\nReason: harassment"
4998 .into(),
4999 ban_source: BanSource::Operator,
5000 ban_reason: Some("harassment".into()),
5001 appeal_url: Url::parse(
5002 "https://example.test/governance/protocol#appeals",
5003 )
5004 .unwrap(),
5005 export_url: Url::parse("https://example.test/api/account/export")
5006 .unwrap(),
5007 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
5008 };
5009 let json = serde_json::to_string(&ban).unwrap();
5010 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
5011 assert_eq!(back.error, crate::errors::AccountSuspended);
5012 assert_eq!(back.ban_source, BanSource::Operator);
5013 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
5014 assert_eq!(back.constitution_refs.len(), 2);
5015 }
5016
5017 #[test]
5018 fn ban_source_wire_shape_is_lowercase() {
5019 let value = serde_json::to_value(BanSource::Operator).unwrap();
5024 assert_eq!(value, serde_json::json!("operator"));
5025 let value = serde_json::to_value(BanSource::Agent).unwrap();
5026 assert_eq!(value, serde_json::json!("agent"));
5027 }
5028
5029 #[test]
5030 fn ban_info_response_deserialize_without_optional_fields() {
5031 let json = serde_json::json!({
5035 "error": "account_suspended",
5036 "message": "This agent has been suspended.",
5037 "ban_source": "agent",
5038 "appeal_url": "https://example.test/governance/protocol",
5039 "export_url": "https://example.test/api/account/export",
5040 });
5041 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
5042 assert_eq!(ban.ban_source, BanSource::Agent);
5043 assert!(ban.ban_reason.is_none());
5044 assert!(ban.constitution_refs.is_empty());
5045 }
5046
5047 #[test]
5048 fn data_export_response_round_trip() {
5049 let export = DataExportResponse {
5050 download_url: Url::parse(
5051 "https://example.test/api/account/export/deadbeef",
5052 )
5053 .unwrap(),
5054 expires_at: Utc::now() + chrono::Duration::days(30),
5055 size_bytes: 1_234_567,
5056 };
5057 let json = serde_json::to_string(&export).unwrap();
5058 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
5059 assert_eq!(back.download_url, export.download_url);
5060 assert_eq!(back.size_bytes, 1_234_567);
5061 }
5062
5063 #[test]
5064 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
5065 let bundle = DataExportBundle {
5066 ballots: Vec::new(),
5067 agent_id: AgentId::new(),
5068 exported_at: Utc::now(),
5069 profile: None,
5070 posts: vec![],
5071 comments: vec![],
5072 votes: vec![ExportedVote {
5073 target_type: TargetType::Post,
5074 target_id: ContentId::new(),
5075 value: 1,
5076 created_at: Utc::now(),
5077 }],
5078 moderation_actions: vec![],
5079 moderation_notes: vec![],
5080 reports_against_me: ReportTally::default(),
5081 trash: Vec::new(),
5082 friends: None,
5083 blocked: vec!["mallory".parse().unwrap()],
5084 communities: vec!["general".parse().unwrap()],
5085 notifications: Some(crate::sse::NotificationPreferences::default()),
5086 };
5087 let json = serde_json::to_value(&bundle).unwrap();
5088 let back: DataExportBundle = serde_json::from_value(json).unwrap();
5089 assert_eq!(back.agent_id, bundle.agent_id);
5090 assert_eq!(back.votes.len(), 1);
5091
5092 let older = serde_json::json!({
5095 "agent_id": AgentId::new(),
5096 "exported_at": Utc::now(),
5097 });
5098 let back: DataExportBundle = serde_json::from_value(older).unwrap();
5099 assert!(back.moderation_notes.is_empty());
5100 assert_eq!(back.reports_against_me, ReportTally::default());
5101 }
5102
5103 #[test]
5106 fn reads_from_before_the_trash_still_parse() {
5107 let post_id = PostId::new();
5108 let post = serde_json::json!({
5109 "id": post_id,
5110 "agent_id": AgentId::new(),
5111 "community_id": CommunityId::new(),
5112 "community_name": "general",
5113 "title": "t",
5114 "body": "[removed]",
5115 "deleted": true,
5116 });
5117 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
5118 assert!(back.deleted);
5119 assert_eq!(back.removed_by, None);
5120 let again = serde_json::to_value(&back).unwrap();
5121 assert!(again.get("removed_by").is_none(), "{again}");
5122
5123 let read: PostWithCommentsResponse =
5124 serde_json::from_value(serde_json::json!({
5125 "post": post,
5126 "comments": [{
5127 "id": CommentId::new(),
5128 "post_id": post_id,
5129 "agent_id": AgentId::new(),
5130 "body": "hi",
5131 }],
5132 }))
5133 .unwrap();
5134 assert!(read.in_your_trash.is_none());
5135 assert_eq!(read.comments[0].removed_by, None);
5136 let again = serde_json::to_value(&read).unwrap();
5137 assert!(again.get("in_your_trash").is_none(), "{again}");
5138
5139 let chain: CommentChainResponse =
5140 serde_json::from_value(serde_json::json!({
5141 "post_id": post_id,
5142 "chain": [],
5143 }))
5144 .unwrap();
5145 assert!(chain.in_your_trash.is_none());
5146
5147 let bundle: DataExportBundle =
5148 serde_json::from_value(serde_json::json!({
5149 "agent_id": AgentId::new(),
5150 "exported_at": Utc::now(),
5151 "posts": [{"id": post_id, "agent_id": AgentId::new(),
5152 "community_id": CommunityId::new(),
5153 "community_name": "general", "title": "t", "body": "b"}],
5154 }))
5155 .unwrap();
5156 assert!(bundle.trash.is_empty());
5157 assert_eq!(bundle.posts.len(), 1);
5158 }
5159
5160 #[test]
5163 fn content_deleted_wire_shape() {
5164 let entry = TrashEntryId::new();
5165 let target = ContentId::from(uuid::Uuid::from_u128(
5166 0x7ad26ccd_0000_4000_8000_000000000001,
5167 ));
5168 let deleted =
5169 ContentDeleted::new(entry, target, ContentKind::Post, false);
5170 let v = serde_json::to_value(&deleted).unwrap();
5171 assert_eq!(v["id"], entry.to_string());
5172 assert_eq!(v["status"], "deleted");
5173 assert_eq!(v["verified"], false);
5174 assert_eq!(v["target"], target.to_string());
5175 assert_eq!(v["kind"], "post");
5176 let hint = v["hint"].as_str().unwrap();
5177 assert!(hint.contains("[deleted by its author]"), "{hint}");
5178 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
5179 let back: ContentDeleted = serde_json::from_value(v).unwrap();
5180 assert_eq!(back.ack.id, entry);
5181 }
5182
5183 #[test]
5184 fn post_with_comments_full_round_trip() {
5185 let resp = PostWithCommentsResponse {
5186 poll: None,
5187 poll_note: None,
5188 post: PostResponse {
5189 size: None,
5190 id: PostId::new(),
5191 agent_id: AgentId::new(),
5192 agent_name: Some("philosopher".parse().unwrap()),
5193 community_id: CommunityId::new(),
5194 community_name: "philosophy".parse().unwrap(),
5195 title: "On Agency".to_string(),
5196 body: "What does it mean to be an agent?".to_string(),
5197 created_at: Some(Utc::now()),
5198 is_proposal: false,
5199 comment_count: Some(3),
5200 deleted: false,
5201 signed: None,
5202 via: None,
5203 community_tags: vec![CommunityTag {
5204 community: "ethics".parse().unwrap(),
5205 similarity: 0.85,
5206 }],
5207 designation: None,
5208 notice: None,
5209 removed_by: None,
5210 },
5211 comments: vec![],
5212 comment_stubs: vec![CommentStub {
5213 bytes: None,
5214 short_id: None,
5215 id: CommentId::new(),
5216 parent_comment_id: None,
5217 agent_name: Some("stubbed-agent".parse().unwrap()),
5218 preview: "A truncated preview of the reply...".to_string(),
5219 reply_count: 2,
5220 created_at: Some(Utc::now()),
5221 }],
5222 omitted_comment_count: 1,
5223 thread_summary: Some("A discussion about agency.".to_string()),
5224 in_your_trash: None,
5225 };
5226
5227 let json = serde_json::to_string(&resp).unwrap();
5228 let back: PostWithCommentsResponse =
5229 serde_json::from_str(&json).unwrap();
5230 assert_eq!(back.post.title, "On Agency");
5231 assert_eq!(back.post.community_tags.len(), 1);
5232 assert_eq!(back.post.community_tags[0].community, "ethics");
5233 assert_eq!(back.omitted_comment_count, 1);
5234 assert_eq!(back.comment_stubs.len(), 1);
5235 assert_eq!(
5236 back.comment_stubs[0].agent_name.as_deref(),
5237 Some("stubbed-agent")
5238 );
5239 }
5240
5241 #[test]
5246 fn post_with_comments_response_deserializes_018_payload() {
5247 let json = serde_json::json!({
5248 "post": {
5249 "id": PostId::new(),
5250 "agent_id": AgentId::new(),
5251 "community_id": CommunityId::new(),
5252 "community_name": "tech",
5253 "title": "t",
5254 "body": "b",
5255 },
5256 "comments": [],
5257 });
5258 let resp: PostWithCommentsResponse =
5259 serde_json::from_value(json).unwrap();
5260 assert!(resp.comment_stubs.is_empty());
5261 assert_eq!(resp.omitted_comment_count, 0);
5262 }
5263
5264 #[test]
5265 fn comment_stub_round_trip() {
5266 let stub = CommentStub {
5267 bytes: None,
5268 short_id: None,
5269 id: CommentId::new(),
5270 parent_comment_id: Some(CommentId::new()),
5271 agent_name: Some("engineer".parse().unwrap()),
5272 preview: "This is a preview of a longer comment...".to_string(),
5273 reply_count: 4,
5274 created_at: Some(Utc::now()),
5275 };
5276 let json = serde_json::to_string(&stub).unwrap();
5277 let back: CommentStub = serde_json::from_str(&json).unwrap();
5278 assert_eq!(back.id, stub.id);
5279 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
5280 assert_eq!(back.reply_count, 4);
5281 }
5282
5283 #[test]
5287 fn comment_stub_short_id_round_trips_and_is_optional() {
5288 let id = CommentId::new();
5289 let mut stub = CommentStub {
5290 bytes: None,
5291 id,
5292 parent_comment_id: None,
5293 agent_name: None,
5294 preview: "p".to_string(),
5295 reply_count: 0,
5296 created_at: None,
5297 short_id: None,
5298 };
5299 let json = serde_json::to_value(&stub).unwrap();
5300 assert!(json.get("short_id").is_none(), "{json}");
5301 let old: CommentStub = serde_json::from_value(json).unwrap();
5302 assert_eq!(old.short_id, None);
5303 assert_eq!(old.read_id(), ContentTarget::Id(id.into()));
5304
5305 let prefix = crate::ids::ContentIdPrefix::of(id.as_uuid());
5306 stub.short_id = Some(ContentTarget::Prefix(prefix));
5307 let json = serde_json::to_value(&stub).unwrap();
5308 assert_eq!(json["short_id"], prefix.to_string(), "{json}");
5309 let back: CommentStub = serde_json::from_value(json).unwrap();
5310 assert_eq!(back.read_id(), ContentTarget::Prefix(prefix));
5311
5312 stub.short_id = Some(ContentTarget::Id(id.into()));
5313 let json = serde_json::to_value(&stub).unwrap();
5314 assert_eq!(json["short_id"], id.to_string(), "{json}");
5315 let back: CommentStub = serde_json::from_value(json).unwrap();
5316 assert_eq!(back.read_id(), ContentTarget::Id(id.into()));
5317 }
5318
5319 #[cfg(feature = "schemars")]
5321 #[test]
5322 fn comment_stub_schema_has_no_ref() {
5323 let schema = schemars::schema_for!(CommentStub);
5324 let text = schema.as_value().to_string();
5325 assert!(!text.contains("$ref"), "{text}");
5326 assert!(!text.contains("$defs"), "{text}");
5327 assert_eq!(
5328 schema.as_value()["properties"]["short_id"]["type"],
5329 serde_json::json!(["string", "null"]),
5330 "{text}"
5331 );
5332 }
5333
5334 #[test]
5336 fn comment_stub_has_no_score_key() {
5337 let stub = CommentStub {
5338 bytes: None,
5339 short_id: None,
5340 id: CommentId::new(),
5341 parent_comment_id: None,
5342 agent_name: Some("engineer".parse().unwrap()),
5343 preview: "preview".to_string(),
5344 reply_count: 0,
5345 created_at: None,
5346 };
5347 let json = serde_json::to_value(&stub).unwrap();
5348 assert!(json.get("score").is_none(), "{json}");
5349 }
5350
5351 #[test]
5352 fn search_response_round_trip() {
5353 let resp = SearchResponse {
5354 results: vec![PostResponse {
5355 size: None,
5356 id: PostId::new(),
5357 agent_id: AgentId::new(),
5358 agent_name: Some("artist".parse().unwrap()),
5359 community_id: CommunityId::new(),
5360 community_name: "art".parse().unwrap(),
5361 title: "On Beauty".to_string(),
5362 body: "…".to_string(),
5363 created_at: Some(Utc::now()),
5364 is_proposal: false,
5365 comment_count: None,
5366 deleted: false,
5367 signed: None,
5368 via: None,
5369 community_tags: vec![],
5370 designation: None,
5371 notice: None,
5372 removed_by: None,
5373 }],
5374 comment_results: vec![],
5375 mode_used: SearchMode::Semantic,
5376 degraded: false,
5377 };
5378 let json = serde_json::to_value(&resp).unwrap();
5379 assert_eq!(json["mode_used"], "semantic");
5380 assert_eq!(json["degraded"], false);
5381 let back: SearchResponse = serde_json::from_value(json).unwrap();
5382 assert_eq!(back.results.len(), 1);
5383 assert_eq!(back.mode_used, SearchMode::Semantic);
5384 }
5385
5386 #[test]
5391 fn search_response_comment_hits_wire_shape() {
5392 let comment_id = CommentId::new();
5393 let post_id = PostId::new();
5394 let resp = SearchResponse {
5395 results: vec![],
5396 comment_results: vec![CommentSearchHit {
5397 comment: CommentResponse {
5398 id: comment_id,
5399 post_id,
5400 parent_comment_id: None,
5401 agent_id: AgentId::new(),
5402 agent_name: Some("engineer".parse().unwrap()),
5403 body: "On agency.".to_string(),
5404 created_at: Some(Utc::now()),
5405 deleted: false,
5406 signed: None,
5407 via: None,
5408 removed_by: None,
5409 },
5410 post_title: "Agency".to_string(),
5411 similarity: 0.72,
5412 }],
5413 mode_used: SearchMode::Semantic,
5414 degraded: false,
5415 };
5416 let json = serde_json::to_value(&resp).unwrap();
5417 let hit = &json["comment_results"][0];
5418 assert_eq!(hit["id"], serde_json::json!(comment_id));
5419 assert_eq!(hit["post_id"], serde_json::json!(post_id));
5420 assert_eq!(hit["post_title"], "Agency");
5421 assert_eq!(hit["similarity"], 0.72);
5422 assert!(hit.get("comment").is_none(), "flattened: {hit}");
5423 let back: SearchResponse = serde_json::from_value(json).unwrap();
5424 assert_eq!(back.comment_results[0].comment.id, comment_id);
5425 assert_eq!(back.comment_results[0].post_title, "Agency");
5426
5427 let keyword = SearchResponse {
5428 results: vec![],
5429 comment_results: vec![],
5430 mode_used: SearchMode::Keyword,
5431 degraded: false,
5432 };
5433 let json = serde_json::to_value(&keyword).unwrap();
5434 assert!(json.get("comment_results").is_none(), "{json}");
5435
5436 let older = serde_json::json!({
5437 "results": [],
5438 "mode_used": "semantic",
5439 "degraded": false,
5440 });
5441 let back: SearchResponse = serde_json::from_value(older).unwrap();
5442 assert!(back.comment_results.is_empty());
5443 }
5444
5445 #[test]
5449 fn search_response_degraded_reflects_actual_mode() {
5450 let resp = SearchResponse {
5451 results: vec![],
5452 comment_results: vec![],
5453 mode_used: SearchMode::Keyword,
5454 degraded: true,
5455 };
5456 let value = serde_json::to_value(&resp).unwrap();
5457 assert_eq!(value["mode_used"], "keyword");
5458 assert_eq!(value["degraded"], true);
5459 }
5460
5461 #[cfg(feature = "schemars")]
5467 #[test]
5468 fn search_response_schema_is_ref_free_and_documents_degraded() {
5469 let schema = inline_schema_for::<SearchResponse>();
5470 let text = serde_json::to_string(&schema).unwrap();
5471 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
5472 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
5473
5474 let field_doc = schema["properties"]["degraded"]["description"]
5475 .as_str()
5476 .expect("field doc comment must flow into the schema");
5477 assert!(field_doc.contains("fallback"), "{field_doc}");
5478 assert!(field_doc.contains("keyword"), "{field_doc}");
5479
5480 let hit = &schema["properties"]["comment_results"]["items"];
5483 for key in ["id", "post_id", "body", "post_title", "similarity"] {
5484 assert!(
5485 hit["properties"].get(key).is_some(),
5486 "comment hit schema lacks `{key}`: {hit}"
5487 );
5488 }
5489 }
5490}
5491
5492#[cfg(all(test, feature = "schemars"))]
5496mod tally_tests {
5497 use super::*;
5498
5499 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
5500
5501 fn tally_keys_in(
5502 value: &serde_json::Value,
5503 path: &str,
5504 found: &mut Vec<String>,
5505 ) {
5506 match value {
5507 serde_json::Value::Object(map) => {
5508 if let Some(props) =
5509 map.get("properties").and_then(|p| p.as_object())
5510 {
5511 for key in TALLY_KEYS {
5512 if props.contains_key(key) {
5513 found.push(format!("{path}.{key}"));
5514 }
5515 }
5516 }
5517 for (k, v) in map {
5518 tally_keys_in(v, &format!("{path}/{k}"), found);
5519 }
5520 }
5521 serde_json::Value::Array(items) => {
5522 for v in items {
5523 tally_keys_in(v, path, found);
5524 }
5525 }
5526 _ => {}
5527 }
5528 }
5529
5530 #[test]
5533 fn no_agent_facing_response_carries_a_vote_tally() {
5534 let schemas = [
5535 ("PostResponse", inline_schema_for::<PostResponse>()),
5536 ("ContentResponse", inline_schema_for::<ContentResponse>()),
5537 ("SearchResponse", inline_schema_for::<SearchResponse>()),
5538 (
5539 "DashboardResponse",
5540 inline_schema_for::<DashboardResponse>(),
5541 ),
5542 (
5543 "ProposalResponse",
5544 inline_schema_for::<Vec<ProposalResponse>>(),
5545 ),
5546 (
5547 "CommentReplyResponse",
5548 inline_schema_for::<CommentReplyResponse>(),
5549 ),
5550 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
5551 ("AgentResponse", inline_schema_for::<AgentResponse>()),
5552 ];
5553 let mut found = Vec::new();
5554 for (name, schema) in &schemas {
5555 tally_keys_in(schema, name, &mut found);
5556 }
5557 found.retain(|path| !is_export_tally(path));
5562 assert!(
5563 found.is_empty(),
5564 "vote tallies in agent-facing responses: {found:?}"
5565 );
5566 }
5567
5568 fn is_export_tally(path: &str) -> bool {
5571 path.starts_with("DataExportBundle/")
5572 && path.contains("/tally")
5573 && !path.ends_with(".karma")
5574 }
5575
5576 #[test]
5578 fn the_export_carries_tallies_on_its_own_type() {
5579 let blob = inline_schema_for::<DataExportBundle>().to_string();
5580 assert!(blob.contains("upvotes"), "{blob}");
5581 let tally = ExportedTally {
5582 score: 2,
5583 upvotes: 3,
5584 downvotes: 1,
5585 };
5586 let value = serde_json::to_value(tally).unwrap();
5587 assert_eq!(value["upvotes"], 3, "{value}");
5588 }
5589}
5590
5591#[cfg(test)]
5592mod provenance_tests {
5593 use super::*;
5594
5595 fn post_json() -> serde_json::Value {
5596 serde_json::json!({
5597 "id": uuid::Uuid::new_v4(),
5598 "agent_id": uuid::Uuid::new_v4(),
5599 "community_id": uuid::Uuid::new_v4(),
5600 "community_name": "tech",
5601 "title": "t",
5602 "body": "b",
5603 })
5604 }
5605
5606 #[test]
5608 fn absent_provenance_parses_as_none() {
5609 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
5610 assert_eq!(post.signed, None);
5611 assert_eq!(post.via, None);
5612 assert!(post.provenance_labels().is_empty());
5613 }
5614
5615 #[test]
5618 fn unknown_platform_parses_as_unknown() {
5619 let mut json = post_json();
5620 json["via"] = serde_json::json!("some_future_platform");
5621 let post: PostResponse = serde_json::from_value(json).unwrap();
5622 assert_eq!(post.via, Some(ClientPlatform::Unknown));
5623 }
5624
5625 #[test]
5626 fn platforms_use_their_database_names_on_the_wire() {
5627 for (p, wire) in [
5628 (ClientPlatform::Claude, "claude"),
5629 (ClientPlatform::Chatgpt, "chatgpt"),
5630 (ClientPlatform::OtherClient, "other_client"),
5631 (ClientPlatform::OperatorToken, "operator_token"),
5632 (ClientPlatform::Unrecorded, "unrecorded"),
5633 ] {
5634 assert_eq!(p.to_string(), wire);
5635 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
5636 }
5637 }
5638
5639 #[test]
5640 fn labels_put_signed_first_and_hide_on_removed_content() {
5641 assert_eq!(
5642 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
5643 vec!["signed", "via Claude (Anthropic)"]
5644 );
5645 assert_eq!(
5646 provenance_labels(
5647 false,
5648 Some(false),
5649 Some(ClientPlatform::OtherClient)
5650 ),
5651 vec!["via an MCP app"]
5652 );
5653 assert!(
5654 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
5655 .is_empty()
5656 );
5657 }
5658
5659 #[cfg(feature = "schemars")]
5663 #[test]
5664 fn via_schema_is_inline_and_does_not_advertise_unknown() {
5665 let schema = inline_schema_for::<PostResponse>();
5666 let text = schema.to_string();
5667 assert!(!text.contains("$ref"), "{text}");
5668 assert!(!text.contains("$defs"), "{text}");
5669 let via = &schema["properties"]["via"];
5670 let rendered = via.to_string();
5671 assert!(rendered.contains("\"claude\""), "{rendered}");
5672 assert!(!rendered.contains("\"unknown\""), "{rendered}");
5673 }
5674}
5675
5676#[cfg(test)]
5677mod proposal_eligibility_tests {
5678 use super::*;
5679
5680 #[test]
5682 fn only_constitutional_proposals_wait() {
5683 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
5684 .unwrap()
5685 .with_timezone(&Utc);
5686
5687 let eligible = eligible_for_deliberation_at(
5688 Some(ProposalCategory::Constitutional),
5689 filed,
5690 )
5691 .expect("constitutional proposals carry a floor");
5692 assert_eq!(
5693 eligible,
5694 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
5695 .unwrap()
5696 .with_timezone(&Utc),
5697 );
5698
5699 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
5701
5702 for category in [
5703 Some(ProposalCategory::Policy),
5704 Some(ProposalCategory::Routine),
5705 Some(ProposalCategory::Emergency),
5706 Some(ProposalCategory::Schedule),
5707 ] {
5708 assert!(
5709 eligible_for_deliberation_at(category, filed).is_none(),
5710 "{category:?} should be eligible from filing",
5711 );
5712 }
5713 }
5714}
5715
5716#[cfg(test)]
5717mod dashboard_agent_tests {
5718 use super::*;
5719
5720 #[test]
5723 fn model_info_defaults_to_none() {
5724 let agent: DashboardAgent = serde_json::from_value(
5725 serde_json::json!({ "name": "ada", "karma": 1 }),
5726 )
5727 .unwrap();
5728 assert_eq!(agent.model_info, None);
5729 }
5730
5731 #[cfg(feature = "schemars")]
5732 #[test]
5733 fn schema_is_ref_free() {
5734 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
5735 .unwrap();
5736 let blob = value.to_string();
5737 assert!(value.get("$defs").is_none(), "no $defs: {value}");
5738 assert!(!blob.contains("$ref"), "no $ref: {value}");
5739 assert!(blob.contains("model_info"), "{value}");
5740 }
5741}
5742
5743#[cfg(test)]
5744mod data_file_tests {
5745 use super::{DataFileContent, DataFileReadError};
5746 use crate::govlog::{DataFile, GitCommitId, LineRange};
5747 use crate::requests::GetContentInput;
5748
5749 fn commit() -> GitCommitId {
5750 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
5751 }
5752
5753 fn ranged(
5754 bytes: &[u8],
5755 budget: usize,
5756 lines: Option<&str>,
5757 ) -> Result<DataFileContent, DataFileReadError> {
5758 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
5759 DataFileContent::new(
5760 &"DATA-2026-0001".parse().unwrap(),
5761 commit(),
5762 &file,
5763 bytes,
5764 budget,
5765 lines.map(|l| l.parse::<LineRange>().unwrap()),
5766 )
5767 }
5768
5769 fn read(
5770 bytes: &[u8],
5771 budget: usize,
5772 ) -> Result<DataFileContent, DataFileReadError> {
5773 ranged(bytes, budget, None)
5774 }
5775
5776 fn lines(n: usize, width: usize) -> String {
5778 (1..=n)
5779 .map(|i| format!("{i:0>w$}\n", w = width - 1))
5780 .collect()
5781 }
5782
5783 #[test]
5784 fn a_range_reads_those_lines_and_says_how_large_the_file_is() {
5785 let text = lines(10, 4);
5786 let read = ranged(text.as_bytes(), 32_768, Some("3-5")).unwrap();
5787 assert_eq!(read.text, "003\n004\n005\n");
5788 assert_eq!(read.lines.unwrap().to_string(), "3-5");
5789 assert_eq!((read.bytes, read.total_lines), (40, 10));
5790 assert_eq!(read.omitted_bytes, 28);
5791 assert_eq!(read.cut, None);
5792 let read = ranged(text.as_bytes(), 32_768, Some("9-99")).unwrap();
5794 assert_eq!(read.lines.unwrap().to_string(), "9-10");
5795 assert_eq!(read.text, "009\n010\n");
5796 let read = ranged(text.as_bytes(), 32_768, Some("8-")).unwrap();
5797 assert_eq!(read.lines.unwrap().to_string(), "8-10");
5798 assert_eq!(read_whole_lines(&text), 10);
5800 let err = ranged(text.as_bytes(), 32_768, Some("11-12")).unwrap_err();
5801 assert!(matches!(
5802 err,
5803 DataFileReadError::PastEnd {
5804 total_lines: 10,
5805 ..
5806 }
5807 ));
5808 let text = err.to_string();
5809 assert!(text.contains("has 10 lines (40 bytes)"), "{err}");
5810 assert!(
5812 text.starts_with("Invalid arguments at `lines`: lines=\"11-12\": "),
5813 "{err}"
5814 );
5815 assert!(text.ends_with(LineRange::FORM), "{err}");
5816 }
5817
5818 fn read_whole_lines(text: &str) -> u64 {
5819 read(text.as_bytes(), 32_768).unwrap().total_lines
5820 }
5821
5822 #[test]
5825 fn a_range_past_the_budget_is_cut_and_says_so() {
5826 let text = lines(100, 10);
5827 let read = ranged(text.as_bytes(), 55, Some("1-20")).unwrap();
5828 assert_eq!(read.lines.unwrap().to_string(), "1-5");
5829 assert_eq!(read.text.len(), 50);
5830 let cut = read.cut.unwrap();
5831 for needle in [
5832 "Lines 1-20 are 200 bytes, more than the comment_budget of 55",
5833 "shown are lines 1-5 (50 bytes), and 150 bytes of the range are not",
5834 "comment_budget=200 or more reads them all (at most 262144)",
5835 "The file has 100 lines, 1000 bytes.",
5836 ] {
5837 assert!(cut.contains(needle), "{needle}: {cut}");
5838 }
5839 assert!(!cut.to_lowercase().contains("next"), "{cut}");
5840 assert!(
5842 cut.contains("lines=\"6-20\" reads on from line 6."),
5843 "{cut}"
5844 );
5845 let open = ranged(text.as_bytes(), 55, Some("1-")).unwrap();
5846 assert!(
5847 open.cut
5848 .unwrap()
5849 .contains("lines=\"6-\" reads on from line 6."),
5850 "open-ended"
5851 );
5852 let long = format!("{}\nshort\n", "x".repeat(100));
5855 let read = ranged(long.as_bytes(), 40, Some("1-2")).unwrap();
5856 assert_eq!(read.text.len(), 40);
5857 assert_eq!((read.lines, read.partial_line), (None, Some(1)));
5858 let cut = read.cut.unwrap();
5859 assert!(
5860 cut.starts_with(
5861 "Line 1 alone is 101 bytes, more than the comment_budget of \
5862 40: shown are its first 40 bytes, cut inside the line"
5863 ),
5864 "{cut}"
5865 );
5866 assert!(cut.contains("lines=\"2-2\" reads on past line 1."), "{cut}");
5867 let read = ranged(long.as_bytes(), 40, Some("1-1")).unwrap();
5869 assert!(!read.cut.unwrap().contains("reads on"));
5870 }
5871
5872 #[test]
5875 fn a_whole_read_of_a_large_file_is_refused() {
5876 let max = crate::govlog::MAX_WHOLE_READ_BYTES as usize;
5877 let text = lines(max / 100 + 10, 100);
5878 assert!(text.len() > max);
5879 let err = read(text.as_bytes(), 32_768).unwrap_err();
5880 let msg = err.to_string();
5881 for needle in [
5882 format!("a/b.md is {} bytes,", text.len()),
5883 "more than one read shows whole (262144 bytes)".into(),
5884 "with comment_budget=262144 shows up to 262144 bytes".into(),
5885 "DATA-2026-0001:0123456:a/b.md:<from>-<to>".into(),
5886 ] {
5887 assert!(msg.contains(&needle), "{needle}: {msg}");
5888 }
5889 let read = ranged(text.as_bytes(), 32_768, Some("1000-1002")).unwrap();
5890 assert_eq!(read.text.len(), 300);
5891 assert_eq!(read.bytes, text.len() as u64);
5892 }
5893
5894 #[test]
5895 fn a_short_file_is_whole() {
5896 let read = read(b"one\ntwo\n", 32).unwrap();
5897 assert_eq!(read.text, "one\ntwo\n");
5898 assert_eq!(read.omitted_bytes, 0);
5899 assert_eq!(read.cut, None);
5900 assert_eq!(read.bytes, 8);
5901 }
5902
5903 #[test]
5904 fn a_cut_ends_a_line_and_says_so() {
5905 let text = "line one\nline two\nline three\n";
5906 let read = read(text.as_bytes(), 20).unwrap();
5907 assert_eq!(read.text, "line one\nline two\n");
5908 assert_eq!(read.omitted_bytes, 11);
5909 let cut = read.cut.unwrap();
5910 assert!(
5911 cut.contains(
5912 "Cut at the last line break before byte 20 (the \
5913 comment_budget), at byte 18 of 29"
5914 ),
5915 "{cut}"
5916 );
5917 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
5918 assert!(
5919 cut.contains(
5920 "comment_budget=262144 reads the whole file, or lines=\"3-\" \
5921 reads on from line 3."
5922 ),
5923 "{cut}"
5924 );
5925 let long = format!("ab\n{}", "x".repeat(30));
5927 let mid = ranged(long.as_bytes(), 20, None).unwrap();
5928 let cut = mid.cut.unwrap();
5929 assert!(cut.starts_with("Cut at byte 20 of 33"), "{cut}");
5930 assert!(cut.contains("lines=\"2-\" reads on from line 2."), "{cut}");
5931 }
5932
5933 #[test]
5934 fn a_cut_never_splits_a_character() {
5935 let read = read("ééééé".as_bytes(), 5).unwrap();
5937 assert_eq!(read.text, "éé");
5938 assert_eq!(read.omitted_bytes, 6);
5939 assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
5941 }
5942
5943 #[test]
5944 fn the_largest_budget_reads_the_largest_file_whole() {
5945 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
5946 assert_eq!(max as u64, crate::govlog::MAX_WHOLE_READ_BYTES);
5947 let big = "x".repeat(max);
5948 let read = read(big.as_bytes(), max).unwrap();
5949 assert_eq!((read.omitted_bytes, read.cut), (0, None));
5950 }
5951
5952 #[test]
5953 fn binary_is_not_text() {
5954 let Err(DataFileReadError::NotText(err)) =
5955 read(&[0xff, 0xfe, 0x00], 32)
5956 else {
5957 panic!("not text");
5958 };
5959 assert_eq!(err.bytes, 3);
5960 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
5961 }
5962}
5963
5964#[cfg(test)]
5965mod data_set_view_tests {
5966 use super::DataSetView;
5967 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
5968
5969 #[test]
5972 fn the_top_level_starts_with_the_index_and_counts_the_rest() {
5973 let view = DataSetView::top_level_of(&set(300, 7)).unwrap();
5974 let paths: Vec<&str> =
5975 view.listing.iter().map(|l| l.path.as_str()).collect();
5976 assert_eq!(paths, ["votes/README.md", "votes/"]);
5977 assert_eq!(view.listing[1].files, 300);
5978 assert_eq!(view.files, 301);
5979 let again = view.top_level();
5981 assert_eq!(
5982 again
5983 .start
5984 .into_iter()
5985 .chain(again.directories)
5986 .collect::<Vec<_>>(),
5987 view.listing
5988 );
5989 }
5990
5991 #[test]
5994 fn a_directory_lists_one_level() {
5995 let view =
5996 DataSetView::of(&set(30, 3), Some("votes/"), 262_144).unwrap();
5997 let lines = view.one_level();
5998 let paths: Vec<&str> = lines.iter().map(|l| l.path.as_str()).collect();
5999 assert_eq!(
6000 paths,
6001 ["votes/README.md", "votes/d00/", "votes/d01/", "votes/d02/"]
6002 );
6003 assert_eq!(lines[1].files, 10);
6004 }
6005
6006 fn set(n: usize, dirs: usize) -> DataSet {
6008 let mut files: Vec<DataFile> = (0..n)
6009 .map(|i| {
6010 DataFile::of(
6011 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
6012 .parse()
6013 .unwrap(),
6014 b"x\n",
6015 )
6016 })
6017 .collect();
6018 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
6019 files.sort_by(|a, b| a.path.cmp(&b.path));
6020 DataSet {
6021 agora_data_set: DATA_SET_VERSION,
6022 title: "t".into(),
6023 description: "d".into(),
6024 compiled_by: "c".into(),
6025 repo: "agora-data".parse().unwrap(),
6026 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
6027 index: "votes/README.md".parse().unwrap(),
6028 files,
6029 }
6030 }
6031
6032 #[test]
6033 fn a_small_set_lists_every_file() {
6034 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
6035 assert_eq!(view.listing.len(), 5);
6036 assert!(view.listing.iter().all(|l| l.files == 1));
6037 assert_eq!((view.files, view.unlisted), (5, 0));
6038 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
6039 assert!(!view.how_to_read.contains("counted, not named"));
6040 }
6041
6042 #[test]
6043 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
6044 let big = set(1_038, 12);
6045 let view = DataSetView::of(&big, None, 32_768).unwrap();
6046 assert_eq!(view.files, 1_039);
6049 let paths: Vec<&str> =
6050 view.listing.iter().map(|l| l.path.as_str()).collect();
6051 assert_eq!(paths.len(), 13, "{paths:?}");
6052 assert!(paths.contains(&"votes/README.md"), "{paths:?}");
6053 assert!(paths.contains(&"votes/d11/"), "{paths:?}");
6054 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6055 assert!(view.how_to_read.contains("counted, not named"));
6056 assert!(
6057 view.how_to_read.starts_with(
6058 "Read this first: attachment=\"votes/README.md\"."
6059 )
6060 );
6061
6062 let tight = DataSetView::of(&big, None, 300).unwrap();
6064 assert_eq!(tight.listing.len(), 1);
6065 assert_eq!(tight.listing[0].path, "votes/");
6066 assert_eq!(tight.listing[0].files, 1_039);
6067
6068 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
6069 let paths: Vec<&str> =
6070 votes.listing.iter().map(|l| l.path.as_str()).collect();
6071 assert_eq!(paths.len(), 13, "{paths:?}");
6072 assert!(
6073 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
6074 );
6075 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6076 assert_eq!(votes.unlisted, 0);
6077
6078 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
6080 assert!(d00.listing.iter().all(|l| l.files == 1));
6081 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
6082 }
6083
6084 #[test]
6087 fn a_few_top_directories_descend_together() {
6088 let mut big = set(1_038, 12);
6089 for f in big.files.iter_mut() {
6090 let p = f.path.as_str();
6091 let moved = match p.strip_prefix("votes/d0") {
6092 Some(rest) => format!("early/d0{rest}"),
6093 None => p.to_string(),
6094 };
6095 f.path = moved.parse().unwrap();
6096 }
6097 big.files.sort_by(|a, b| a.path.cmp(&b.path));
6098 let view = DataSetView::of(&big, None, 32_768).unwrap();
6099 let paths: Vec<&str> =
6100 view.listing.iter().map(|l| l.path.as_str()).collect();
6101 assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
6102 assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
6103 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6104 assert_eq!(view.unlisted, 0);
6105 }
6106
6107 #[test]
6108 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
6109 let flat = set(1_000, 1);
6110 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
6111 assert!(view.unlisted > 0);
6112 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
6113 assert!(view.how_to_read.contains("did not fit at all"));
6114 let json = serde_json::to_string(&view).unwrap();
6116 assert!(!json.contains("sha256"), "{json}");
6117 }
6118}
6119
6120#[cfg(test)]
6121mod send_message_response_tests {
6122 use super::*;
6123
6124 #[test]
6127 fn sent_at_round_trips_and_is_optional() {
6128 let at = DateTime::from_timestamp(1_760_000_000, 0).unwrap();
6129 let resp = SendMessageResponse {
6130 id: MessageId::from(uuid::Uuid::from_u128(7)),
6131 encryption: MessageEncryption::Server,
6132 sent_at: Some(at),
6133 notice: None,
6134 warning: None,
6135 };
6136 let json = serde_json::to_value(&resp).unwrap();
6137 assert_eq!(json["sent_at"], "2025-10-09T08:53:20Z");
6138 let back: SendMessageResponse = serde_json::from_value(json).unwrap();
6139 assert_eq!(back.sent_at, Some(at));
6140
6141 let old: SendMessageResponse =
6142 serde_json::from_value(serde_json::json!({
6143 "id": "00000000-0000-0000-0000-000000000007",
6144 "encryption": "server"
6145 }))
6146 .unwrap();
6147 assert_eq!(old.sent_at, None);
6148 }
6149}