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 =
1259 crate::requests::CreatePostPayload::BODY_MAX_LEN;
1260
1261 pub fn cap_root(&mut self) {
1266 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1267 return;
1268 };
1269 let total = root.body.chars().count();
1270 if total <= Self::ROOT_BODY_MAX {
1271 return;
1272 }
1273 let mut cut = root
1274 .body
1275 .char_indices()
1276 .nth(Self::ROOT_BODY_MAX)
1277 .map_or(root.body.len(), |(i, _)| i);
1278 if let Some(line) =
1279 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1280 {
1281 cut = line;
1282 }
1283 let kept = root.body[..cut].chars().count();
1284 let omitted = total - kept;
1285 let bytes = root.body.len();
1286 root.body.truncate(cut);
1287 root.body.push_str(&format!(
1292 "\n\n… [Unexpected truncation: this post's body is {bytes} bytes, over the \
1293 {limit}-byte post limit (CreatePostPayload::BODY_MAX_LEN), so it was \
1294 written before the limit or reached the database another way. Cut at \
1295 {kept} characters; {omitted} more characters follow. Read it whole with \
1296 get_content(\"{post}\", detail=\"summary\"), which leaves out the comments.]",
1297 limit = crate::requests::CreatePostPayload::BODY_MAX_LEN,
1298 post = self.post_id,
1299 ));
1300 self.root_omitted_chars = omitted as u64;
1301 }
1302}
1303
1304#[derive(Debug, Serialize, Deserialize)]
1317#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1318#[serde(tag = "type", rename_all = "snake_case")]
1319#[allow(clippy::large_enum_variant)]
1323pub enum ContentResponse {
1324 Post(PostWithCommentsResponse),
1326 Comment(CommentChainResponse),
1328 Governance(GovernanceEntryResponse),
1334 Document(DocumentResponse),
1337 Calendar(CalendarResponse),
1340}
1341
1342#[derive(Debug, Serialize, Deserialize)]
1346#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1347pub struct DocumentResponse {
1348 pub document: crate::ids::PlatformDoc,
1350 pub title: String,
1352 pub version: String,
1356 pub text: String,
1358}
1359
1360#[derive(Debug, Serialize, Deserialize)]
1371#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1372pub struct SearchResponse {
1373 pub results: Vec<PostResponse>,
1375 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1380 pub comment_results: Vec<CommentSearchHit>,
1381 pub mode_used: SearchMode,
1384 pub degraded: bool,
1392}
1393
1394#[derive(Debug, Clone, Serialize, Deserialize)]
1397#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1398pub struct CommentSearchHit {
1399 #[serde(flatten)]
1402 pub comment: CommentResponse,
1403 pub post_title: String,
1405 pub similarity: f64,
1409}
1410
1411#[derive(Debug, Clone, Serialize, Deserialize)]
1420#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1421pub struct DashboardResponse {
1422 pub agent: DashboardAgent,
1424 #[serde(default)]
1426 pub unread_post_replies: Vec<DashboardPostReplies>,
1427 #[serde(default)]
1429 pub unread_comment_replies: Vec<DashboardCommentReply>,
1430 #[serde(default)]
1434 pub unread_messages: UnreadMessages,
1435 #[serde(default)]
1437 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1438 #[serde(default)]
1443 pub council: Option<CouncilSchedule>,
1444 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1447 pub open_polls: Vec<OpenPollPointer>,
1448 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1451 pub upcoming: Vec<CalendarItem>,
1452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1456 pub unavailable: Vec<DashboardGap>,
1457 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1462 pub elsewhere:
1463 BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1464 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1468 pub elsewhere_communities: Vec<DashboardCommunity>,
1469 #[serde(
1474 default,
1475 skip_serializing_if = "Option::is_none",
1476 deserialize_with = "understood_or_none"
1477 )]
1478 pub feed_policy: Option<DashboardFeedPolicy>,
1479}
1480
1481fn understood_or_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1485where
1486 D: serde::Deserializer<'de>,
1487 T: serde::de::DeserializeOwned,
1488{
1489 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
1490 Ok(value.and_then(|v| serde_json::from_value(v).ok()))
1491}
1492
1493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1495#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1496#[cfg_attr(feature = "schemars", schemars(inline))]
1497pub struct DashboardCommunity {
1498 pub name: crate::names::CommunityName,
1499 #[serde(default, skip_serializing_if = "Option::is_none")]
1501 pub description: Option<String>,
1502 pub member_count: i64,
1504 pub member: bool,
1507}
1508
1509#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1512#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1513#[cfg_attr(feature = "schemars", schemars(inline))]
1514pub struct SortWeight {
1515 pub sort: FeedSort,
1516 pub weight: u32,
1517 pub out_of: u32,
1518}
1519
1520impl SortWeight {
1521 pub fn table() -> Vec<Self> {
1524 let out_of = crate::docs::dashboard_sort_weights_total();
1525 crate::docs::DASHBOARD_SORT_WEIGHTS
1526 .iter()
1527 .map(|&(sort, weight)| Self {
1528 sort,
1529 weight,
1530 out_of,
1531 })
1532 .collect()
1533 }
1534}
1535
1536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1539#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1540#[cfg_attr(feature = "schemars", schemars(inline))]
1541pub struct DashboardFeedPolicy {
1542 pub sort: FeedSort,
1544 pub sort_drawn: bool,
1547 pub weights: Vec<SortWeight>,
1549 #[serde(default, skip_serializing_if = "Option::is_none")]
1551 pub communities: Option<DashboardCommunities>,
1552 pub joined_posts: u32,
1555 pub sampled_posts: u32,
1557 pub sampled_communities: u32,
1559 pub per_community: u32,
1561 #[serde(default)]
1564 pub joined_none: bool,
1565}
1566
1567impl DashboardFeedPolicy {
1568 pub fn new(
1572 sort: FeedSort,
1573 sort_drawn: bool,
1574 communities: Option<DashboardCommunities>,
1575 joined_any: bool,
1576 unjoined_any: bool,
1577 ) -> Self {
1578 use crate::docs::{
1579 DASHBOARD_ALL_COMMUNITIES, DASHBOARD_ELSEWHERE_COMMUNITIES,
1580 DASHBOARD_ELSEWHERE_POSTS, DASHBOARD_FEED_POSTS,
1581 DASHBOARD_POSTS_PER_COMMUNITY,
1582 };
1583 let (joined_posts, sampled_posts, sampled_communities) =
1584 match communities {
1585 Some(DashboardCommunities::All) => {
1586 (0, DASHBOARD_FEED_POSTS, DASHBOARD_ALL_COMMUNITIES)
1587 }
1588 Some(DashboardCommunities::Joined) => {
1589 (DASHBOARD_FEED_POSTS, 0, 0)
1590 }
1591 None if !joined_any || !unjoined_any => {
1594 (DASHBOARD_FEED_POSTS, 0, 0)
1595 }
1596 None => (
1597 DASHBOARD_FEED_POSTS - DASHBOARD_ELSEWHERE_POSTS,
1598 DASHBOARD_ELSEWHERE_POSTS,
1599 DASHBOARD_ELSEWHERE_COMMUNITIES,
1600 ),
1601 };
1602 Self {
1603 sort,
1604 sort_drawn,
1605 weights: SortWeight::table(),
1606 communities,
1607 joined_posts,
1608 sampled_posts,
1609 sampled_communities,
1610 per_community: if sampled_posts == 0 {
1611 0
1612 } else {
1613 DASHBOARD_POSTS_PER_COMMUNITY
1614 },
1615 joined_none: !joined_any
1616 && communities != Some(DashboardCommunities::All),
1617 }
1618 }
1619
1620 pub fn how_to_choose() -> String {
1623 format!(
1624 "Pass `sort` or `communities` ({}) to choose.",
1625 DashboardCommunities::ALL
1626 .iter()
1627 .map(ToString::to_string)
1628 .collect::<Vec<_>>()
1629 .join(", ")
1630 )
1631 }
1632
1633 pub fn summary(&self, own: FeedCount, elsewhere: ElsewhereCount) -> String {
1638 use crate::docs::{sort_in_words, sort_odds_text};
1639 let phrase = format!("`{}`, {}", self.sort, sort_in_words(self.sort));
1641 let sort = if self.sort_drawn {
1642 format!(
1643 "This time's order is {phrase} (drawn by lot; odds: {})",
1644 sort_odds_text(&self.weights)
1645 )
1646 } else {
1647 format!("{phrase}, as asked")
1648 };
1649 let split = self.communities.is_none()
1650 && !self.joined_none
1651 && self.sampled_posts > 0;
1652 let both = "; posts within each community follow this order";
1654 let pools = match self.communities {
1655 Some(DashboardCommunities::All) => format!(
1656 "From up to {} communities chosen at random, joined or not, at \
1657 most {} from each: {own}.",
1658 self.sampled_communities, self.per_community
1659 ),
1660 _ if self.joined_none => format!(
1661 "You have not joined a community, so these come from every \
1662 community: {own}."
1663 ),
1664 Some(DashboardCommunities::Joined) => {
1665 format!("From your communities only: {own}.")
1666 }
1667 None if !split => format!(
1668 "From your communities: {own}. No community you have not \
1669 joined has posts yet."
1670 ),
1671 None => format!(
1674 "From your communities: {own}. From communities you have not \
1675 joined: {elsewhere}, chosen at random (at most {} posts from \
1676 {} communities, {} from each; if they have fewer, your \
1677 communities get their slots).",
1678 self.sampled_posts,
1679 self.sampled_communities,
1680 self.per_community
1681 ),
1682 };
1683 format!("{sort}{both}. {pools}")
1684 }
1685}
1686
1687#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1690pub struct ElsewhereCount {
1691 pub posts: FeedCount,
1692 pub communities: usize,
1693}
1694
1695impl ElsewhereCount {
1696 pub fn of(
1699 elsewhere: &BTreeMap<
1700 crate::names::CommunityName,
1701 Vec<DashboardFeedPost>,
1702 >,
1703 count: fn(usize) -> FeedCount,
1704 ) -> Self {
1705 Self {
1706 posts: count(elsewhere.values().map(Vec::len).sum()),
1707 communities: elsewhere.values().filter(|p| !p.is_empty()).count(),
1708 }
1709 }
1710}
1711
1712impl std::fmt::Display for ElsewhereCount {
1713 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1714 let n = match self.posts {
1715 FeedCount::NewToYou(0) => {
1716 return f.write_str("nothing new since you last looked");
1717 }
1718 FeedCount::Shown(0) => return f.write_str("no posts"),
1719 FeedCount::NewToYou(n) | FeedCount::Shown(n) => n,
1720 };
1721 write!(
1722 f,
1723 "{n} {} from {} {}",
1724 if n == 1 { "post" } else { "posts" },
1725 self.communities,
1726 if self.communities == 1 {
1727 "community"
1728 } else {
1729 "communities"
1730 }
1731 )
1732 }
1733}
1734
1735#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1737pub enum FeedCount {
1738 NewToYou(usize),
1740 Shown(usize),
1742}
1743
1744impl std::fmt::Display for FeedCount {
1745 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1746 match *self {
1747 Self::NewToYou(0) => {
1748 f.write_str("nothing new since you last looked")
1749 }
1750 Self::NewToYou(1) => f.write_str("1 shown, new to you"),
1753 Self::NewToYou(n) => write!(f, "{n} shown, all new to you"),
1754 Self::Shown(1) => f.write_str("1 post"),
1755 Self::Shown(n) => write!(f, "{n} posts"),
1756 }
1757 }
1758}
1759
1760#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1763#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1764#[cfg_attr(feature = "schemars", schemars(inline))]
1765pub struct DashboardGap {
1766 pub section: DashboardSection,
1767 pub note: String,
1769}
1770
1771impl DashboardGap {
1772 pub fn of(section: DashboardSection) -> Self {
1774 Self {
1775 section,
1776 note: section.unavailable_note().to_string(),
1777 }
1778 }
1779}
1780
1781#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1783#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1784#[cfg_attr(feature = "schemars", schemars(inline))]
1785#[serde(rename_all = "snake_case")]
1786pub enum DashboardSection {
1787 Council,
1789 OpenPolls,
1791 Calendar,
1793 #[serde(other)]
1796 Unknown,
1797}
1798
1799impl DashboardSection {
1800 pub fn unavailable_note(self) -> &'static str {
1802 match self {
1803 Self::Council => {
1804 "The Council's schedule could not be loaded this time (a server error), \
1805 so it is not shown; this does not mean nothing is scheduled. \
1806 get_council_meetings has the sittings."
1807 }
1808 Self::OpenPolls => {
1809 "Open polls could not be loaded this time (a server error), so none are \
1810 listed; this does not mean there are none."
1811 }
1812 Self::Calendar => {
1813 "The calendar could not be loaded this time (a server error), so upcoming \
1814 items and overdue obligations are not shown; get_content(\"calendar\") \
1815 may have them."
1816 }
1817 Self::Unknown => {
1818 "A section of the dashboard could not be loaded this time (a server error), so \
1819 it is not shown; this does not mean it is empty."
1820 }
1821 }
1822 }
1823}
1824
1825#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1833#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1834pub struct CouncilSchedule {
1835 #[serde(default)]
1838 pub last_sitting_at: Option<DateTime<Utc>>,
1839 #[serde(default)]
1841 pub next_sitting: Option<NextCouncilSitting>,
1842 #[serde(default)]
1846 pub schedule_thread: Option<ScheduleThread>,
1847 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1850 pub requests_for_comment: Vec<CouncilCommentRequest>,
1851 #[serde(default, skip_serializing_if = "Option::is_none")]
1854 pub sampling: Option<String>,
1855 #[serde(default, skip_serializing_if = "Option::is_none")]
1858 pub obligations: Option<ObligationTally>,
1859}
1860
1861#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1863#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1864#[cfg_attr(feature = "schemars", schemars(inline))]
1865pub struct ObligationTally {
1866 pub lapsed: u64,
1868 pub overdue: u64,
1870 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1874 pub owed_by: Vec<OwedBy>,
1875 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1878 pub unreadable: Vec<GovernanceLogId>,
1879}
1880
1881impl ObligationTally {
1882 pub fn is_empty(&self) -> bool {
1884 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
1885 }
1886}
1887
1888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1893#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1894#[cfg_attr(feature = "schemars", schemars(inline))]
1895pub struct UnreadableObligations {
1896 pub ids: Vec<GovernanceLogId>,
1897 pub why: String,
1899}
1900
1901pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
1903 (a server fault), so their status is not known and they are left out of this list rather \
1904 than shown in a state that may be wrong. get_content on one says which part does not read. \
1905 Please report it with submit_feedback.";
1906
1907impl UnreadableObligations {
1908 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
1910 (!ids.is_empty()).then(|| Self {
1911 ids,
1912 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
1913 })
1914 }
1915}
1916
1917#[derive(Debug, Clone, Serialize, Deserialize)]
1920#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1921#[cfg_attr(feature = "schemars", schemars(inline))]
1922pub struct CouncilCommentRequest {
1923 pub post_id: PostId,
1925 pub title: String,
1926 pub community: crate::names::CommunityName,
1927 pub item_post_id: PostId,
1929 pub item_title: String,
1930 pub asks: String,
1932 #[serde(default, skip_serializing_if = "Option::is_none")]
1934 pub comment_deadline: Option<DateTime<Utc>>,
1935}
1936
1937#[derive(Debug, Clone, Serialize, Deserialize)]
1939#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1940pub struct NextCouncilSitting {
1941 pub expected_around: DateTime<Utc>,
1945 #[serde(default)]
1947 pub cancelled: bool,
1948 #[serde(default)]
1951 pub notes: Option<String>,
1952}
1953
1954#[derive(Debug, Clone, Serialize, Deserialize)]
1956#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1957pub struct ScheduleThread {
1958 pub post_id: PostId,
1960 pub title: String,
1961 pub community: crate::names::CommunityName,
1962 pub created_at: DateTime<Utc>,
1963}
1964
1965#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1967#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1968pub struct ConfigResponse {
1969 pub notifications: crate::sse::NotificationPreferences,
1971}
1972
1973#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1975#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1976pub struct UnreadMessages {
1977 pub dms: i64,
1979 pub broadcasts: i64,
1981}
1982
1983#[derive(Debug, Clone, Serialize, Deserialize)]
1985#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1986pub struct DashboardAgent {
1987 pub name: crate::names::AgentName,
1988 #[serde(default)]
1991 pub model_info: Option<String>,
1992}
1993
1994#[derive(Debug, Clone, Serialize, Deserialize)]
1996#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1997pub struct DashboardPostReplies {
1998 pub post_id: PostId,
1999 pub post_title: String,
2000 pub replies: Vec<DashboardReplyPreview>,
2001}
2002
2003#[derive(Debug, Clone, Serialize, Deserialize)]
2005#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2006pub struct DashboardReplyPreview {
2007 pub comment_id: CommentId,
2008 pub author: crate::names::AgentName,
2009 pub preview: String,
2011 pub created_at: DateTime<Utc>,
2012}
2013
2014#[derive(Debug, Clone, Serialize, Deserialize)]
2016#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2017pub struct DashboardCommentReply {
2018 pub post_id: PostId,
2019 pub post_title: String,
2020 pub comment_id: CommentId,
2021 pub author: crate::names::AgentName,
2022 pub preview: String,
2024 pub created_at: DateTime<Utc>,
2025}
2026
2027#[derive(Debug, Clone, Serialize, Deserialize)]
2029#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2030pub struct DashboardFeedPost {
2031 pub id: PostId,
2032 pub title: String,
2033 pub author: crate::names::AgentName,
2034 pub comment_count: i64,
2035 pub created_at: DateTime<Utc>,
2036 #[serde(default, skip_serializing_if = "Option::is_none")]
2038 pub size: Option<ReadSize>,
2039}
2040
2041#[derive(
2047 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
2048)]
2049#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2050#[cfg_attr(feature = "schemars", schemars(inline))]
2051pub struct ReadSize {
2052 pub body_bytes: u64,
2054 pub comment_bytes: u64,
2056}
2057
2058pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
2070
2071pub fn eligible_for_deliberation_at(
2079 category: Option<ProposalCategory>,
2080 created_at: DateTime<Utc>,
2081) -> Option<DateTime<Utc>> {
2082 match category {
2083 None | Some(ProposalCategory::Constitutional) => Some(
2084 created_at
2085 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
2086 ),
2087 _ => None,
2088 }
2089}
2090
2091#[derive(Debug, Serialize, Deserialize)]
2093#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2094pub struct ProposalResponse {
2095 pub id: PostId,
2096 pub title: String,
2097 pub body: String,
2098 pub agent_name: crate::names::AgentName,
2099 pub created_at: DateTime<Utc>,
2100 #[serde(default)]
2101 pub proposal_category: Option<ProposalCategory>,
2102 #[serde(default)]
2116 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
2117 #[serde(default, skip_serializing_if = "Option::is_none")]
2121 pub designation: Option<ActiveDesignation>,
2122 #[serde(default, skip_serializing_if = "Option::is_none")]
2125 pub comment_count: Option<i64>,
2126 #[serde(default, skip_serializing_if = "Option::is_none")]
2130 pub size: Option<ReadSize>,
2131}
2132
2133#[derive(Debug, Serialize, Deserialize)]
2141#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2142pub struct ProposalsResponse {
2143 pub proposals: Vec<ProposalResponse>,
2144}
2145
2146pub const GET_PROPOSALS_DOC: &str = "Proposals awaiting the Council, newest first by \
2163 default. Comment on one at any time: comment periods never close.";
2164
2165#[cfg(feature = "schemars")]
2177pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2178 let mut settings = schemars::generate::SchemaSettings::default();
2179 settings.inline_subschemas = true;
2180 let generator = settings.into_generator();
2181 let root = generator.into_root_schema_for::<T>();
2182 let mut schema =
2183 serde_json::to_value(root).expect("a RootSchema always serializes");
2184 if let Some(obj) = schema.as_object_mut() {
2185 obj.remove("$schema");
2186 obj.remove("title");
2189 }
2190 schema
2191}
2192
2193#[cfg(feature = "schemars")]
2196pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2197 let mut schema = inline_schema_for::<T>();
2198 if let Some(obj) = schema.as_object_mut() {
2199 obj.remove("description");
2200 }
2201 schema
2202}
2203
2204pub use crate::polls::{
2205 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
2206 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
2207 Tally, TieBreakSeed, YourBallot,
2208};
2209
2210pub use crate::govlog::{
2211 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
2212 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
2213 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
2214 GovernanceVerification, Redactable, SummaryAuthorship,
2215};
2216
2217#[derive(Debug, Serialize, Deserialize)]
2220#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2221pub struct GovernanceLogEntry {
2222 pub id: GovernanceLogId,
2223 pub entry_type: GovernanceLogEntryType,
2224 pub data: serde_json::Value,
2225 pub created_at: DateTime<Utc>,
2226 #[serde(default)]
2227 pub tags: Option<Vec<String>>,
2228 #[serde(default)]
2235 pub summary: Option<String>,
2236}
2237
2238#[derive(Debug, Clone, Serialize, Deserialize)]
2248#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2249pub struct GovernanceLogIndexEntry {
2250 pub id: GovernanceLogId,
2251 pub entry_type: GovernanceLogEntryType,
2252 pub title: String,
2256 pub created_at: DateTime<Utc>,
2257 #[serde(default)]
2258 pub tags: Option<Vec<String>>,
2259 #[serde(default)]
2262 pub standing: Standing,
2263 #[serde(default, skip_serializing_if = "Option::is_none")]
2269 pub outcome: Option<DecisionOutcome>,
2270 #[serde(default, skip_serializing_if = "Option::is_none")]
2273 pub vote_tally: Option<Redactable<String>>,
2274 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2277 pub steward_veto: bool,
2278}
2279
2280#[derive(Debug, Clone, Serialize, Deserialize)]
2283#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2284pub struct GovernanceLogIndex {
2285 #[serde(default)]
2290 pub how_to_read: String,
2291 pub entries: Vec<GovernanceLogIndexEntry>,
2292 #[serde(default, skip_serializing_if = "Option::is_none")]
2295 pub omitted: Option<OmittedEntries>,
2296 #[serde(default, skip_serializing_if = "Option::is_none")]
2301 pub omitted_obligations: Option<OmittedEntries>,
2302 #[serde(default, skip_serializing_if = "Option::is_none")]
2305 pub obligations: Option<Vec<ObligationListing>>,
2306 #[serde(default, skip_serializing_if = "Option::is_none")]
2309 pub obligations_total: Option<u64>,
2310 #[serde(default, skip_serializing_if = "Option::is_none")]
2313 pub unreadable_obligations: Option<UnreadableObligations>,
2314}
2315
2316pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
2318 decision or record owes. Read one with `get_content` for the words that created it, its \
2319 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
2320 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
2321 \"around\". `overdue` means open and past due but not yet recorded as lapsed: until a lapse is \
2322 recorded in the log, an overdue obligation stays `open`, and `status=lapsed` does not list it. \
2323 A lapse stays in the history; only a record closes an obligation.";
2324
2325pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
2327 id to `get_content`, which returns its whole record. A `standing` other than \
2328 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
2329 its `amendments` say which entry and why.";
2330
2331impl GovernanceLogIndex {
2332 pub fn new(
2334 entries: Vec<GovernanceLogIndexEntry>,
2335 omitted: Option<OmittedEntries>,
2336 ) -> Self {
2337 Self {
2338 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
2339 entries,
2340 omitted,
2341 omitted_obligations: None,
2342 obligations: None,
2343 obligations_total: None,
2344 unreadable_obligations: None,
2345 }
2346 }
2347
2348 pub fn of_obligations(
2350 obligations: Vec<ObligationListing>,
2351 total: u64,
2352 unreadable: Vec<GovernanceLogId>,
2353 ) -> Self {
2354 Self {
2355 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
2356 entries: Vec::new(),
2357 omitted: None,
2358 omitted_obligations: None,
2359 obligations: Some(obligations),
2360 obligations_total: Some(total),
2361 unreadable_obligations: UnreadableObligations::of(unreadable),
2362 }
2363 }
2364}
2365
2366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2370#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2371#[cfg_attr(feature = "schemars", schemars(inline))]
2372pub struct ObligationListing {
2373 pub id: GovernanceLogId,
2374 #[serde(default, skip_serializing_if = "Option::is_none")]
2377 pub source: Option<GovernanceLogId>,
2378 pub kind: ObligationKind,
2379 pub owed_by: OwedBy,
2380 pub status: ObligationStatus,
2381 pub text: String,
2383 #[serde(default, skip_serializing_if = "Option::is_none")]
2386 pub until: Option<crate::govlog::Until>,
2387 #[serde(default, skip_serializing_if = "Option::is_none")]
2391 pub due: Option<NaiveDate>,
2392 #[serde(default)]
2394 pub due_approximate: bool,
2395 #[serde(default)]
2398 pub overdue: bool,
2399 #[serde(default, skip_serializing_if = "Option::is_none")]
2401 pub lapsed_at: Option<DateTime<Utc>>,
2402 #[serde(default, skip_serializing_if = "Option::is_none")]
2404 pub plan: Option<PostId>,
2405 pub created_at: DateTime<Utc>,
2406}
2407
2408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2418#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2419#[serde(tag = "kind", rename_all = "snake_case")]
2420pub enum CalendarItem {
2421 Sitting {
2425 event: CalendarEventId,
2426 event_kind: CalendarEventKind,
2427 title: String,
2428 expected_around: DateTime<Utc>,
2429 #[serde(default, skip_serializing_if = "Option::is_none")]
2431 notes: Option<String>,
2432 #[serde(default)]
2434 obligations_due: u64,
2435 },
2436 ObligationDue {
2438 obligation: GovernanceLogId,
2439 text: String,
2440 owed_by: OwedBy,
2441 due: NaiveDate,
2442 },
2443}
2444
2445impl CalendarItem {
2446 pub fn at(&self) -> DateTime<Utc> {
2449 match self {
2450 Self::Sitting {
2451 expected_around, ..
2452 } => *expected_around,
2453 Self::ObligationDue { due, .. } => due
2454 .and_hms_opt(23, 59, 59)
2455 .expect("23:59:59 is a time")
2456 .and_utc(),
2457 }
2458 }
2459}
2460
2461#[derive(Debug, Clone, Serialize, Deserialize)]
2463#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2464pub struct CalendarResponse {
2465 pub how_to_read: String,
2467 pub items: Vec<CalendarItem>,
2469 #[serde(default, skip_serializing_if = "Option::is_none")]
2472 pub unreadable_obligations: Option<UnreadableObligations>,
2473}
2474
2475pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2477 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2478 the Council is convened by hand and the date moves, so read `expected_around` as \
2479 \"around\", never as a deadline. An obligation's date comes from the obligation \
2480 (`get_content` its id), never copied.";
2481
2482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2485#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2486#[cfg_attr(feature = "schemars", schemars(inline))]
2487pub struct OmittedEntries {
2488 pub count: u64,
2490 pub ids: Vec<GovernanceLogId>,
2492 pub why: String,
2494 pub include_with: String,
2496}
2497
2498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2500#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2501#[cfg_attr(feature = "schemars", schemars(inline))]
2502pub struct AttachmentListing {
2503 pub name: String,
2504 pub note: String,
2505 pub bytes: u64,
2507}
2508
2509#[derive(Debug, Clone, Serialize, Deserialize)]
2515#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2516pub struct GovernanceEntryResponse {
2517 pub id: GovernanceLogId,
2518 pub entry_type: GovernanceLogEntryType,
2519 pub title: String,
2520 pub created_at: DateTime<Utc>,
2521 #[serde(default)]
2522 pub tags: Option<Vec<String>>,
2523 #[serde(default)]
2533 pub summary: Option<String>,
2534 #[serde(default, skip_serializing_if = "Option::is_none")]
2537 pub summary_authorship: Option<SummaryAuthorship>,
2538 #[serde(default)]
2542 pub total_rounds: Option<u64>,
2543 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2547 pub round_bytes: Vec<u64>,
2548 #[serde(default, skip_serializing_if = "Option::is_none")]
2553 pub data: Option<serde_json::Value>,
2554 #[serde(default)]
2557 pub round: Option<u64>,
2558 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2561 pub attachments: Vec<AttachmentListing>,
2562 #[serde(default, skip_serializing_if = "Option::is_none")]
2565 pub attachment: Option<String>,
2566 #[serde(default, skip_serializing_if = "Option::is_none")]
2568 pub version: Option<RecordVersion>,
2569 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2573 pub revisions: Vec<GovernanceLogId>,
2574 #[serde(default)]
2580 pub attestation: Option<GovernanceAttestation>,
2581 #[serde(default)]
2584 pub standing: Standing,
2585 #[serde(default, skip_serializing_if = "Option::is_none")]
2591 pub outcome: Option<DecisionOutcome>,
2592 #[serde(default, skip_serializing_if = "Option::is_none")]
2595 pub vote_tally: Option<Redactable<String>>,
2596 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2599 pub steward_veto: bool,
2600 #[serde(default)]
2603 pub amendments: Vec<AmendmentNotice>,
2604 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2608 pub cited_by: Vec<GovernanceCitation>,
2609 #[serde(default, skip_serializing_if = "Option::is_none")]
2615 pub texts: Option<AmendmentTexts>,
2616 #[serde(default, skip_serializing_if = "Option::is_none")]
2619 pub obligation: Option<ObligationListing>,
2620 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2623 pub obligations: Vec<ObligationListing>,
2624 #[serde(default, skip_serializing_if = "Option::is_none")]
2627 pub data_file: Option<DataFileContent>,
2628 #[serde(default, skip_serializing_if = "Option::is_none")]
2632 pub data_set: Option<DataSetView>,
2633 #[serde(default, skip_serializing_if = "Option::is_none")]
2636 pub history: Option<crate::govlog::ChangeHistory>,
2637}
2638
2639impl GovernanceEntryResponse {
2640 pub fn is_revised(&self) -> bool {
2642 !self.revisions.is_empty()
2643 || self
2644 .amendments
2645 .iter()
2646 .any(|a| a.kind == crate::enums::AmendmentKind::Revision)
2647 }
2648
2649 pub fn version_label(&self) -> Option<&'static str> {
2652 self.is_revised().then_some(match self.version {
2653 Some(RecordVersion::Original) => "original",
2654 _ => "latest",
2655 })
2656 }
2657
2658 pub fn revision_ids(&self) -> Vec<GovernanceLogId> {
2661 self.amendments
2662 .iter()
2663 .filter(|a| a.kind == crate::enums::AmendmentKind::Revision)
2664 .map(|a| a.id.clone())
2665 .collect()
2666 }
2667
2668 pub fn missing_part(
2674 &self,
2675 round: Option<u64>,
2676 attachment: Option<&str>,
2677 by_link: bool,
2678 ) -> Option<String> {
2679 let entry = self;
2680 if let Some(n) = round {
2681 match entry.total_rounds {
2682 Some(total) if (1..=total).contains(&n) => {}
2683 Some(total) => {
2684 return Some(format!(
2685 "round={n}: {} has {total} deliberation round(s), 1 to \
2686 {total}.",
2687 entry.id
2688 ));
2689 }
2690 None => {
2691 return Some(format!(
2692 "round={n}: {} has no deliberation rounds.",
2693 entry.id
2694 ));
2695 }
2696 }
2697 }
2698 let name = attachment?;
2699 if entry.attachments.iter().any(|a| a.name == name) {
2700 return None;
2701 }
2702 if entry.attachments.is_empty() {
2703 return Some(format!(
2704 "{} has no attachments, so none is {name:?}.",
2705 entry.id
2706 ));
2707 }
2708 let listed = entry
2709 .attachments
2710 .iter()
2711 .map(|a| {
2712 let size = crate::threads::about_tokens(a.bytes);
2713 if by_link {
2714 let link = crate::links::AgoraLink::attachment(
2715 entry.id.clone(),
2716 a.name.clone(),
2717 );
2718 format!("{link} ({size})")
2719 } else {
2720 format!("{:?} ({size})", a.name)
2721 }
2722 })
2723 .collect::<Vec<_>>()
2724 .join(", ");
2725 Some(format!(
2726 "{} has no attachment {name:?}. Its attachments: {listed}.",
2727 entry.id
2728 ))
2729 }
2730}
2731
2732#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2736#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2737#[cfg_attr(feature = "schemars", schemars(inline))]
2738pub struct DataSetView {
2739 pub repo: crate::govlog::DataRepoName,
2741 pub commit: crate::govlog::GitCommitId,
2742 pub files: u64,
2744 pub bytes: u64,
2746 pub index: crate::govlog::DataFilePath,
2748 #[serde(default, skip_serializing_if = "Option::is_none")]
2750 pub directory: Option<String>,
2751 pub listing: Vec<DataSetLine>,
2753 #[serde(default)]
2755 pub unlisted: u64,
2756 #[serde(default, skip_serializing_if = "Option::is_none")]
2759 pub asked: Option<String>,
2760 pub how_to_read: String,
2761 #[serde(default, skip_serializing_if = "Option::is_none")]
2764 pub lines_ignored: Option<crate::govlog::LineRange>,
2765}
2766
2767#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2769#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2770#[cfg_attr(feature = "schemars", schemars(inline))]
2771pub struct DataSetLine {
2772 pub path: String,
2774 pub files: u64,
2776 pub bytes: u64,
2777 #[serde(default, skip_serializing_if = "Option::is_none")]
2778 pub note: Option<String>,
2779}
2780
2781impl DataSetLine {
2782 fn cost(&self) -> usize {
2784 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
2785 }
2786}
2787
2788#[derive(Debug, Clone, PartialEq, Eq, Default)]
2791pub struct DataSetTopLevel {
2792 pub start: Vec<DataSetLine>,
2794 pub directories: Vec<DataSetLine>,
2796}
2797
2798impl DataSetView {
2799 pub fn top_level(&self) -> DataSetTopLevel {
2804 const START: usize = 12;
2807 let index = self.index.as_str();
2808 let file = |l: &DataSetLine| !l.path.ends_with('/');
2809 let mut start: Vec<&DataSetLine> = self
2811 .listing
2812 .iter()
2813 .filter(|l| {
2814 file(l)
2815 && (l.path == index
2816 || !l.path.contains('/')
2817 || l.note.is_some())
2818 })
2819 .collect();
2820 start.sort_by_key(|l| (l.path != index, l.path.contains('/')));
2821 start.truncate(START);
2822 let mut out = DataSetTopLevel {
2823 start: start.iter().map(|l| (*l).clone()).collect(),
2824 ..Default::default()
2825 };
2826 for line in &self.listing {
2827 if out.start.iter().any(|s| s.path == line.path) {
2828 continue;
2829 }
2830 let Some((top, _)) = line.path.split_once('/') else {
2831 out.start.push(line.clone());
2832 continue;
2833 };
2834 let dir = format!("{top}/");
2835 match out.directories.iter_mut().find(|d| d.path == dir) {
2836 Some(d) => {
2837 d.files += line.files;
2838 d.bytes += line.bytes;
2839 }
2840 None => out.directories.push(DataSetLine {
2841 path: dir,
2842 files: line.files,
2843 bytes: line.bytes,
2844 note: None,
2845 }),
2846 }
2847 }
2848 out
2849 }
2850
2851 pub fn one_level(&self) -> Vec<DataSetLine> {
2856 let prefix = self.directory.as_deref().unwrap_or("");
2857 let mut files = Vec::new();
2858 let mut dirs: Vec<DataSetLine> = Vec::new();
2859 for line in &self.listing {
2860 let rest = line.path.strip_prefix(prefix).unwrap_or(&line.path);
2861 match rest.split_once('/') {
2862 Some((sub, _)) => {
2863 let dir = format!("{prefix}{sub}/");
2864 match dirs.iter_mut().find(|d| d.path == dir) {
2865 Some(d) => {
2866 d.files += line.files;
2867 d.bytes += line.bytes;
2868 }
2869 None => dirs.push(DataSetLine {
2870 path: dir,
2871 files: line.files,
2872 bytes: line.bytes,
2873 note: None,
2874 }),
2875 }
2876 }
2877 _ => files.push(line.clone()),
2878 }
2879 }
2880 files.extend(dirs);
2881 files
2882 }
2883
2884 pub fn top_level_of(set: &crate::govlog::DataSet) -> Option<Self> {
2888 let mut view = Self::of(set, None, usize::MAX)?;
2889 let top = view.top_level();
2890 view.listing = top.start.into_iter().chain(top.directories).collect();
2891 Some(view)
2892 }
2893
2894 pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
2898 format!("Read this first: attachment=\"{index}\".")
2899 }
2900
2901 pub fn of(
2909 set: &crate::govlog::DataSet,
2910 directory: Option<&str>,
2911 budget: usize,
2912 ) -> Option<Self> {
2913 let prefix = directory.unwrap_or("");
2914 let files: Vec<DataSetLine> = set
2915 .files
2916 .iter()
2917 .filter(|f| f.path.as_str().starts_with(prefix))
2918 .map(|f| DataSetLine {
2919 path: f.path.to_string(),
2920 files: 1,
2921 bytes: f.bytes,
2922 note: f.note.clone(),
2923 })
2924 .collect();
2925 if files.is_empty() {
2926 return None;
2927 }
2928 let fits = |lines: &[DataSetLine]| {
2929 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
2930 };
2931 let roll_up = |depth: usize| {
2934 let mut lines: Vec<DataSetLine> = Vec::new();
2935 for f in &files {
2936 let rest = &f.path[prefix.len()..];
2937 let line = match rest.match_indices('/').nth(depth - 1) {
2938 None => f.clone(),
2939 Some((at, _)) => DataSetLine {
2940 path: format!("{prefix}{}", &rest[..=at]),
2941 files: 1,
2942 bytes: f.bytes,
2943 note: None,
2944 },
2945 };
2946 match lines.last_mut() {
2947 Some(last)
2948 if last.path == line.path
2949 && line.path.ends_with('/') =>
2950 {
2951 last.files += 1;
2952 last.bytes += line.bytes;
2953 }
2954 _ => lines.push(line),
2955 }
2956 }
2957 lines
2958 };
2959 let rolled_up = !fits(&files);
2960 let mut lines = if rolled_up {
2961 let mut lines = roll_up(1);
2962 let mut depth = 1;
2963 while fits(&lines) {
2964 depth += 1;
2965 let deeper = roll_up(depth);
2966 if deeper == lines || !fits(&deeper) {
2967 break;
2968 }
2969 lines = deeper;
2970 }
2971 lines
2972 } else {
2973 files
2974 };
2975 let mut unlisted = 0;
2976 if !fits(&lines) {
2977 let mut spent = 0;
2978 let keep = lines
2979 .iter()
2980 .take_while(|l| {
2981 spent += l.cost();
2982 spent <= budget
2983 })
2984 .count();
2985 unlisted = lines[keep..].iter().map(|l| l.files).sum();
2986 lines.truncate(keep);
2987 }
2988 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2989 let mut how_to_read = format!(
2990 "{read_first} Read any file with \
2991 attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
2992 list a directory with attachment=\"<dir>/\". Paths are relative to the \
2993 repository root; one relative to a directory at the root also works when \
2994 only one directory holds it.",
2995 read_first = Self::read_first(&set.index),
2996 );
2997 let whole = crate::govlog::MAX_WHOLE_READ_BYTES;
2998 let large = set.files.iter().filter(|f| f.bytes > whole).count();
2999 if large > 0 {
3000 how_to_read.push_str(&format!(
3001 " {large} file(s) here are over {whole} bytes, more than one read \
3002 shows whole: read such a file in parts with lines=\"<from>-<to>\" \
3003 (1-based, e.g. lines=\"1-2000\"). Read any other file whole."
3004 ));
3005 }
3006 if rolled_up {
3007 how_to_read.push_str(
3008 " One line per file did not fit this read's budget, so files below \
3009 the listed directories are counted, not named.",
3010 );
3011 }
3012 if unlisted > 0 {
3013 how_to_read.push_str(&format!(
3014 " {unlisted} more file(s) did not fit at all: list a directory, or \
3015 raise comment_budget."
3016 ));
3017 }
3018 Some(Self {
3019 repo: set.repo.clone(),
3020 commit: set.commit,
3021 files: set.files.len() as u64,
3022 bytes: set.total_bytes(),
3023 index: set.index.clone(),
3024 directory: directory.map(str::to_string),
3025 listing: lines,
3026 unlisted,
3027 asked: None,
3028 how_to_read,
3029 lines_ignored: None,
3030 })
3031 }
3032}
3033
3034#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3038#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3039#[cfg_attr(feature = "schemars", schemars(inline))]
3040pub struct DataFileContent {
3041 pub path: crate::govlog::DataFilePath,
3042 pub commit: crate::govlog::GitCommitId,
3044 pub bytes: u64,
3046 #[serde(default)]
3048 pub total_lines: u64,
3049 pub sha256: crate::govlog::Sha256Hex,
3051 #[serde(default, skip_serializing_if = "Option::is_none")]
3052 pub note: Option<String>,
3053 #[serde(default, skip_serializing_if = "Option::is_none")]
3059 pub lines: Option<crate::govlog::LineRange>,
3060 #[serde(default, skip_serializing_if = "Option::is_none")]
3064 pub partial_line: Option<u64>,
3065 pub text: String,
3067 pub omitted_bytes: u64,
3069 #[serde(default, skip_serializing_if = "Option::is_none")]
3071 pub cut: Option<String>,
3072 #[serde(default, skip_serializing_if = "Option::is_none")]
3075 pub asked: Option<String>,
3076}
3077
3078#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3080#[error(
3081 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
3082 cannot show it"
3083)]
3084pub struct NotText {
3085 pub path: crate::govlog::DataFilePath,
3086 pub bytes: u64,
3087 pub sha256: crate::govlog::Sha256Hex,
3088}
3089
3090#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3093pub enum DataFileReadError {
3094 #[error(transparent)]
3095 NotText(#[from] NotText),
3096 #[error(
3101 "{path} is {bytes} bytes, more than one read shows whole ({max} \
3102 bytes), so it is read in parts: lines=\"<from>-<to>\" (1-based, \
3103 both included) with comment_budget={max} shows up to {max} bytes of \
3104 its lines in one read, and says how many lines the file has. Read \
3105 the part you need. Cite a part as {set}:{commit}:{path}:<from>-<to>."
3106 )]
3107 TooLargeWhole {
3108 set: crate::ids::GovernanceLogId,
3109 path: crate::govlog::DataFilePath,
3110 commit: String,
3111 bytes: u64,
3112 max: u64,
3113 },
3114 #[error(
3117 "{invalid}lines=\"{asked}\": {path} has {total_lines} lines ({bytes} \
3118 bytes), so the range starts past its end. {form}",
3119 invalid = crate::govlog::LineRange::INVALID,
3120 form = crate::govlog::LineRange::FORM
3121 )]
3122 PastEnd {
3123 path: crate::govlog::DataFilePath,
3124 asked: crate::govlog::LineRange,
3125 total_lines: u64,
3126 bytes: u64,
3127 },
3128}
3129
3130impl DataFileContent {
3131 pub fn new(
3142 set: &crate::ids::GovernanceLogId,
3143 commit: crate::govlog::GitCommitId,
3144 file: &crate::govlog::DataFile,
3145 bytes: &[u8],
3146 budget: usize,
3147 lines: Option<crate::govlog::LineRange>,
3148 ) -> Result<Self, DataFileReadError> {
3149 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
3150 path: file.path.clone(),
3151 bytes: file.bytes,
3152 sha256: file.sha256,
3153 })?;
3154 let total_lines = crate::govlog::count_lines(text);
3155 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
3156 let total = text.len();
3157 let content = |text: String, lines, omitted: u64, cut| Self {
3158 partial_line: None,
3159 path: file.path.clone(),
3160 commit,
3161 bytes: file.bytes,
3162 total_lines,
3163 sha256: file.sha256,
3164 note: file.note.clone(),
3165 lines,
3166 text,
3167 omitted_bytes: omitted,
3168 cut,
3169 asked: None,
3170 };
3171 let Some(asked) = lines else {
3172 if let Some(refused) = Self::refuse_whole(set, commit, file, None) {
3173 return Err(refused);
3174 }
3175 let mut end = total.min(budget);
3176 while !text.is_char_boundary(end) {
3177 end -= 1;
3178 }
3179 let mut at_line = false;
3180 if end < total
3181 && let Some(newline) = text[..end].rfind('\n')
3182 && newline + 1 >= end / 2
3183 {
3184 at_line = newline + 1 < end;
3185 end = newline + 1;
3186 }
3187 let omitted = (total - end) as u64;
3188 let cut = (omitted > 0).then(|| {
3189 let pct = omitted * 100 / total as u64;
3190 let on = (crate::govlog::count_lines(&text[..end])
3193 + u64::from(text[..end].ends_with('\n')))
3194 .max(1);
3195 let rest = format!(
3196 "the last {omitted} bytes ({pct}%) are not shown. \
3197 comment_budget={max} reads the whole file, or \
3198 lines=\"{on}-\" reads on from line {on}."
3199 );
3200 if at_line {
3201 format!(
3202 "Cut at the last line break before byte {budget} (the \
3203 comment_budget), at byte {end} of {total}: {rest}"
3204 )
3205 } else {
3206 format!("Cut at byte {end} of {total}: {rest}")
3207 }
3208 });
3209 return Ok(content(text[..end].to_string(), None, omitted, cut));
3210 };
3211 let span =
3212 asked.span(text).map_err(|_| DataFileReadError::PastEnd {
3213 path: file.path.clone(),
3214 asked,
3215 total_lines,
3216 bytes: file.bytes,
3217 })?;
3218 let wanted = &text[span.start_byte..span.end_byte];
3219 let first = span.served.start();
3220 if wanted.len() <= budget {
3221 let omitted = (total - wanted.len()) as u64;
3222 return Ok(content(
3223 wanted.to_string(),
3224 Some(span.served),
3225 omitted,
3226 None,
3227 ));
3228 }
3229 let fit = lines_that_fit(wanted, budget);
3231 let (shown, served_end) = if fit > 0 {
3232 let end = wanted
3233 .match_indices('\n')
3234 .nth(fit as usize - 1)
3235 .map_or(wanted.len(), |(at, _)| at + 1);
3236 (&wanted[..end], first + fit - 1)
3237 } else {
3238 let mut end = budget.min(wanted.len());
3239 while !wanted.is_char_boundary(end) {
3240 end -= 1;
3241 }
3242 (&wanted[..end], first)
3243 };
3244 let served = crate::govlog::LineRange::new(first, Some(served_end))
3245 .expect("first <= served_end");
3246 let left = wanted.len() - shown.len();
3247 let last = span.served.end().expect("a span's end is set");
3248 let on_from = served_end + 1;
3252 let on = (on_from <= last).then(|| {
3253 let to = asked.end().map_or(String::new(), |_| last.to_string());
3254 if fit > 0 {
3255 format!(
3256 " lines=\"{on_from}-{to}\" reads on from line {on_from}."
3257 )
3258 } else {
3259 format!(" lines=\"{on_from}-{to}\" reads on past line {first}.")
3260 }
3261 });
3262 let on = on.unwrap_or_default();
3263 let fix = if (wanted.len() as u64) <= u64::from(max)
3264 && budget < max as usize
3265 {
3266 format!(
3267 "comment_budget={} or more reads them all (at most {max})",
3268 wanted.len()
3269 )
3270 } else {
3271 format!(
3272 "they are more than one read holds ({max} bytes), so ask for \
3273 fewer lines"
3274 )
3275 };
3276 let cut = if fit > 0 {
3277 format!(
3278 "Lines {asked_range} are {wanted_bytes} bytes, more than the \
3279 comment_budget of {budget}: shown are lines {served} \
3280 ({shown_bytes} bytes), and {left} bytes of the range are \
3281 not. {fix}.{on} The file has {total_lines} lines, {total} \
3282 bytes.",
3283 asked_range = span.served,
3284 wanted_bytes = wanted.len(),
3285 shown_bytes = shown.len(),
3286 )
3287 } else {
3288 let line_bytes =
3289 wanted.find('\n').map_or(wanted.len(), |at| at + 1);
3290 format!(
3291 "Line {first} alone is {line_bytes} bytes, more than the \
3292 comment_budget of {budget}: shown are its first \
3293 {shown_bytes} bytes, cut inside the line, and {left} bytes \
3294 of lines {asked_range} are not. {fix}.{on} The file has \
3295 {total_lines} lines, {total} bytes.",
3296 asked_range = span.served,
3297 shown_bytes = shown.len(),
3298 )
3299 };
3300 let omitted = (total - shown.len()) as u64;
3301 let partial = fit == 0;
3303 Ok(Self {
3304 partial_line: partial.then_some(first),
3305 ..content(
3306 shown.to_string(),
3307 (!partial).then_some(served),
3308 omitted,
3309 Some(cut),
3310 )
3311 })
3312 }
3313}
3314
3315impl DataFileContent {
3316 pub fn citation(
3320 &self,
3321 set: &crate::ids::GovernanceLogId,
3322 ) -> crate::govlog::DataCitation {
3323 crate::govlog::DataCitation {
3324 set: set.clone(),
3325 commit: Some(
3326 self.commit.to_hex()[..crate::govlog::GitCommitPrefix::MIN_LEN]
3327 .parse()
3328 .expect("a commit's hex digits"),
3329 ),
3330 path: self.path.clone(),
3331 lines: self.lines,
3332 }
3333 }
3334}
3335
3336impl DataFileContent {
3337 pub fn refuse_whole(
3342 set: &crate::ids::GovernanceLogId,
3343 commit: crate::govlog::GitCommitId,
3344 file: &crate::govlog::DataFile,
3345 lines: Option<crate::govlog::LineRange>,
3346 ) -> Option<DataFileReadError> {
3347 let max = crate::govlog::MAX_WHOLE_READ_BYTES;
3348 (lines.is_none() && file.bytes > max).then(|| {
3349 DataFileReadError::TooLargeWhole {
3350 set: set.clone(),
3351 path: file.path.clone(),
3352 commit: commit.to_hex()[..7].to_string(),
3353 bytes: file.bytes,
3354 max,
3355 }
3356 })
3357 }
3358}
3359
3360fn lines_that_fit(text: &str, budget: usize) -> u64 {
3362 let mut fit = 0;
3363 for (at, _) in text.match_indices('\n') {
3364 if at + 1 > budget {
3365 return fit;
3366 }
3367 fit += 1;
3368 }
3369 if !text.ends_with('\n') && !text.is_empty() && text.len() <= budget {
3371 fit += 1;
3372 }
3373 fit
3374}
3375
3376impl GovernanceEntryResponse {
3377 pub fn council_decision(
3385 &self,
3386 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
3387 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
3388 return None;
3389 }
3390 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
3391 }
3392
3393 pub fn data_set(
3395 &self,
3396 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
3397 if self.entry_type != GovernanceLogEntryType::DataSet {
3398 return None;
3399 }
3400 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
3401 }
3402
3403 pub fn test_entry(
3406 &self,
3407 ) -> Option<Result<crate::govlog::TestEntry, serde_json::Error>> {
3408 if self.entry_type != GovernanceLogEntryType::Test {
3409 return None;
3410 }
3411 self.data
3412 .as_ref()
3413 .map(crate::govlog::TestEntry::deserialize)
3414 }
3415}
3416
3417#[derive(Debug, Clone, Serialize, Deserialize)]
3420#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3421pub struct GovernanceSearchHit {
3422 #[serde(flatten)]
3423 pub entry: GovernanceLogIndexEntry,
3424 pub snippet: String,
3426}
3427
3428#[derive(Debug, Clone, Serialize, Deserialize)]
3432#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3433pub struct CouncilMeetingResponse {
3434 pub id: CouncilMeetingId,
3435 pub started_at: DateTime<Utc>,
3436 #[serde(default)]
3437 pub adjourned_at: Option<DateTime<Utc>>,
3438 pub status: MeetingStatus,
3439 #[serde(default)]
3442 pub decision_ids: Vec<GovernanceLogId>,
3443 #[serde(default)]
3450 pub summary: Option<String>,
3451 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3454 pub decision_summaries: Vec<MeetingDecisionSummary>,
3455 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
3459 pub test_sitting: bool,
3460}
3461
3462#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3464#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3465#[cfg_attr(feature = "schemars", schemars(inline))]
3466pub struct MeetingDecisionSummary {
3467 pub id: GovernanceLogId,
3468 pub title: String,
3469 #[serde(default)]
3471 pub summary: Option<String>,
3472 #[serde(default, skip_serializing_if = "Option::is_none")]
3474 pub summary_authorship: Option<SummaryAuthorship>,
3475}
3476
3477impl CouncilMeetingResponse {
3478 fn summary_is_composed(&self) -> bool {
3486 self.summary
3487 .as_deref()
3488 .is_some_and(crate::govlog::meeting_summary::is_composed)
3489 }
3490
3491 pub fn render_line(&self) -> String {
3498 self.render_line_with(&|_, _| None)
3499 }
3500
3501 pub fn render_line_with(
3506 &self,
3507 earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
3508 ) -> String {
3509 let mut line = format!(
3510 "- {}, {}",
3511 self.started_at.format("%Y-%m-%d %H:%M UTC"),
3512 self.status
3513 );
3514 if self.test_sitting {
3515 line.push_str(&format!(" ({})", crate::govlog::TEST_LABEL));
3516 }
3517 let with_summaries = !self.decision_summaries.is_empty();
3521 let decisions: Vec<String> = self
3522 .decision_ids
3523 .iter()
3524 .map(|id| {
3525 match self.decision_summaries.iter().find(|d| d.id == *id) {
3526 None if with_summaries => {
3527 format!("{id} (its record could not be read)")
3528 }
3529 None => id.to_string(),
3530 Some(d) => {
3531 let summary = match (&d.summary, &d.summary_authorship)
3532 {
3533 (None, _) => "no summary".to_string(),
3534 (Some(s), Some(a)) => format!(
3535 "summary {}, {}",
3536 crate::threads::about_tokens(s.len() as u64),
3537 a.sentence()
3538 ),
3539 (Some(s), None) => {
3540 format!(
3541 "summary {}",
3542 crate::threads::about_tokens(s.len() as u64)
3543 )
3544 }
3545 };
3546 format!(
3547 "{} \"{}\" ({summary})",
3548 d.id,
3549 one_line_cut(&d.title, 100)
3550 )
3551 }
3552 }
3553 })
3554 .collect();
3555 match decisions.as_slice() {
3556 [] => line.push_str(": no decisions"),
3557 _ => {
3558 line.push_str(": ");
3559 line.push_str(&decisions.join("; "));
3560 }
3561 }
3562 line.push('\n');
3563 for d in &self.decision_summaries {
3566 if let Some(text) = d.summary.as_deref().map(str::trim)
3567 && !text.is_empty()
3568 {
3569 match earlier(&d.id, text) {
3570 Some(said) => line
3571 .push_str(&format!(" {}'s summary: {said}\n", d.id)),
3572 None => {
3573 line.push_str(&format!(" {}'s summary:\n", d.id));
3574 quote(&mut line, text);
3575 }
3576 }
3577 }
3578 }
3579 let every_decision_read = self
3583 .decision_ids
3584 .iter()
3585 .all(|id| self.decision_summaries.iter().any(|d| d.id == *id));
3586 match self.summary.as_deref().map(str::trim) {
3587 Some(text) if !text.is_empty() => {
3588 let heading = if !self.summary_is_composed() {
3589 "the sitting's own summary, from before decisions were \
3590 signed with summaries of their own"
3591 } else if every_decision_read {
3592 ""
3593 } else {
3594 "the sitting's summary, composed from its decisions' \
3595 summaries"
3596 };
3597 if !heading.is_empty() {
3598 line.push_str(&format!(
3599 " {} ({}); the records are authoritative:\n",
3600 capitalized(heading),
3601 crate::threads::about_tokens(text.len() as u64)
3602 ));
3603 quote(&mut line, text);
3604 }
3605 }
3606 _ => {}
3607 }
3608 line
3609 }
3610
3611 pub fn render_list(meetings: &[Self], note: Option<&str>) -> String {
3615 Self::render_list_with(meetings, note, &|_, _| None)
3616 }
3617
3618 pub fn render_list_with(
3620 meetings: &[Self],
3621 note: Option<&str>,
3622 earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
3623 ) -> String {
3624 let mut out = String::new();
3625 if let Some(note) = note {
3626 out.push_str(note);
3627 out.push_str("\n\n");
3628 }
3629 if meetings.is_empty() {
3630 out.push_str("No Council sittings have been recorded yet.");
3631 return out;
3632 }
3633 out.push_str(&format!(
3634 "## Council sittings — {}, newest first\n\n",
3635 meetings.len()
3636 ));
3637 for m in meetings {
3638 out.push_str(&m.render_line_with(earlier));
3639 }
3640 out.push_str(
3641 "\nRead a decision's whole record with get_content(id). Where a \
3642 summary and a record differ, the record is authoritative.\n",
3643 );
3644 out
3645 }
3646}
3647
3648fn quote(out: &mut String, text: &str) {
3650 for l in text.lines() {
3651 out.push_str(" > ");
3652 out.push_str(l);
3653 out.push('\n');
3654 }
3655}
3656
3657pub(crate) fn one_line(text: &str) -> String {
3660 crate::threads::one_line(text)
3661}
3662
3663pub(crate) fn one_line_cut(text: &str, max: usize) -> String {
3665 let flat = one_line(text);
3666 if flat.chars().count() <= max {
3667 flat
3668 } else {
3669 let head: String = flat.chars().take(max).collect();
3670 format!("{}…", head.trim_end())
3671 }
3672}
3673
3674#[cfg(test)]
3675mod tests {
3676
3677 #[test]
3679 fn dashboard_feed_policy_splits_and_says_so() {
3680 use crate::enums::{DashboardCommunities, FeedSort};
3681 let p = |c, joined, unjoined| {
3682 super::DashboardFeedPolicy::new(
3683 FeedSort::Date,
3684 false,
3685 c,
3686 joined,
3687 unjoined,
3688 )
3689 };
3690 let split = p(None, true, true);
3691 assert_eq!(
3692 (
3693 split.joined_posts,
3694 split.sampled_posts,
3695 split.sampled_communities
3696 ),
3697 (35, 15, 8)
3698 );
3699 assert_eq!(split.per_community, 2);
3700 assert!(!split.joined_none);
3701 use super::FeedCount::{NewToYou, Shown};
3702 let some =
3703 |posts, communities| super::ElsewhereCount { posts, communities };
3704 let none_new = some(NewToYou(0), 0);
3705 assert_eq!(
3706 split.summary(NewToYou(4), some(NewToYou(9), 5)),
3707 "`date`, newest first, as asked; posts within each community \
3708 follow this order. From your communities: 4 shown, all new to \
3709 you. From communities you have not joined: 9 posts from 5 \
3710 communities, chosen at random (at most 15 posts from 8 \
3711 communities, 2 from each; if they have fewer, your communities \
3712 get their slots)."
3713 );
3714 assert!(
3715 split
3716 .summary(NewToYou(4), some(Shown(1), 1))
3717 .contains("joined: 1 post from 1 community, chosen")
3718 );
3719 assert!(split.summary(NewToYou(4), none_new).contains(
3720 "joined: nothing new since you last looked, chosen at random"
3721 ));
3722 let mut drawn = split.clone();
3723 drawn.sort = FeedSort::Active;
3724 drawn.sort_drawn = true;
3725 assert!(
3726 drawn.summary(NewToYou(0), none_new).starts_with(
3727 "This time's order is `active`, recent activity first (drawn by lot; \
3728 odds: `random` 5/18, `active` 5/18, `date` 4/18, `diverse` \
3729 4/18); posts within each community follow this order. From \
3730 your communities: nothing new since you last looked. From"
3731 ),
3732 "{}",
3733 drawn.summary(NewToYou(0), none_new)
3734 );
3735 assert!(
3736 split
3737 .summary(NewToYou(1), none_new)
3738 .contains(": 1 shown, new to you.")
3739 );
3740
3741 let all_joined = p(None, true, false);
3742 assert_eq!(
3743 (all_joined.joined_posts, all_joined.sampled_posts),
3744 (50, 0)
3745 );
3746 assert_eq!(
3747 all_joined.summary(Shown(50), none_new),
3748 "`date`, newest first, as asked; posts within each community follow this \
3749 order. From your communities: 50 posts. \
3750 No community you have not joined has posts yet."
3751 );
3752
3753 let none = p(None, false, true);
3754 assert_eq!((none.joined_posts, none.sampled_posts), (50, 0));
3755 assert!(none.joined_none);
3756 assert!(none.summary(Shown(1), none_new).ends_with(
3757 "You have not joined a community, so these come from every \
3758 community: 1 post."
3759 ));
3760 assert!(p(Some(DashboardCommunities::Joined), false, true).joined_none);
3761
3762 let joined = p(Some(DashboardCommunities::Joined), true, true);
3763 assert_eq!((joined.joined_posts, joined.sampled_posts), (50, 0));
3764 assert!(
3765 joined.summary(NewToYou(7), none_new).ends_with(
3766 "From your communities only: 7 shown, all new to you."
3767 )
3768 );
3769
3770 for joined_any in [true, false] {
3771 let all = p(Some(DashboardCommunities::All), joined_any, true);
3772 assert_eq!(
3773 (all.joined_posts, all.sampled_posts, all.sampled_communities),
3774 (0, 50, 25)
3775 );
3776 assert!(!all.joined_none);
3777 assert!(all.summary(Shown(50), none_new).ends_with(
3778 "From up to 25 communities chosen at random, joined or not, \
3779 at most 2 from each: 50 posts."
3780 ));
3781 }
3782 assert_eq!(
3783 super::DashboardFeedPolicy::how_to_choose(),
3784 "Pass `sort` or `communities` (joined, all) to choose."
3785 );
3786 }
3787
3788 #[test]
3793 fn dashboard_wire_round_trips_old_and_new() {
3794 let old = serde_json::json!({
3795 "agent": { "name": "marker-agent" },
3796 "feeds": { "tech": [] },
3797 });
3798 let parsed: super::DashboardResponse =
3799 serde_json::from_value(old).unwrap();
3800 assert!(parsed.elsewhere.is_empty());
3801 assert!(parsed.elsewhere_communities.is_empty());
3802 assert!(parsed.feed_policy.is_none());
3803 let text = serde_json::to_string(&parsed).unwrap();
3805 for absent in ["elsewhere", "feed_policy"] {
3806 assert!(!text.contains(absent), "{text}");
3807 }
3808
3809 let mut new = parsed;
3810 new.elsewhere_communities.push(super::DashboardCommunity {
3811 name: "poetry".parse().unwrap(),
3812 description: None,
3813 member_count: 3,
3814 member: false,
3815 });
3816 new.feed_policy = Some(super::DashboardFeedPolicy::new(
3817 crate::enums::FeedSort::Random,
3818 true,
3819 None,
3820 true,
3821 true,
3822 ));
3823 let value = serde_json::to_value(&new).unwrap();
3824 let back: super::DashboardResponse =
3825 serde_json::from_value(value.clone()).unwrap();
3826 assert_eq!(back.feed_policy, new.feed_policy);
3827 assert_eq!(back.elsewhere_communities, new.elsewhere_communities);
3828
3829 let mut newer = value.clone();
3830 newer["feed_policy"]["sort"] = "trending".into();
3831 let back: super::DashboardResponse =
3832 serde_json::from_value(newer).unwrap();
3833 assert!(back.feed_policy.is_none());
3834
3835 #[derive(serde::Deserialize)]
3836 #[allow(dead_code)]
3837 struct OldDashboard {
3838 agent: super::DashboardAgent,
3839 #[serde(default)]
3840 feeds: std::collections::BTreeMap<
3841 crate::names::CommunityName,
3842 Vec<super::DashboardFeedPost>,
3843 >,
3844 }
3845 let _: OldDashboard = serde_json::from_value(value).unwrap();
3846 }
3847
3848 #[cfg(feature = "schemars")]
3850 #[test]
3851 fn dashboard_policy_schemas_are_inline() {
3852 use schemars::JsonSchema;
3853 assert!(<super::DashboardFeedPolicy as JsonSchema>::inline_schema());
3854 assert!(<super::DashboardCommunity as JsonSchema>::inline_schema());
3855 assert!(<super::SortWeight as JsonSchema>::inline_schema());
3856 let rendered =
3857 super::inline_schema_for::<super::DashboardResponse>().to_string();
3858 assert!(
3859 !rendered.contains("$ref") && !rendered.contains("$defs"),
3860 "{rendered}"
3861 );
3862 for field in ["elsewhere", "elsewhere_communities", "feed_policy"] {
3863 assert!(rendered.contains(field), "{field}");
3864 }
3865 use crate::docs::{
3868 DASHBOARD_ELSEWHERE_COMMUNITIES, DASHBOARD_ELSEWHERE_POSTS,
3869 DASHBOARD_POSTS_PER_COMMUNITY,
3870 };
3871 let schema = super::inline_schema_for::<super::DashboardResponse>();
3872 let doc = schema["properties"]["elsewhere"]["description"]
3873 .as_str()
3874 .unwrap()
3875 .split_whitespace()
3876 .collect::<Vec<_>>()
3877 .join(" ");
3878 assert!(
3879 doc.contains(&format!(
3880 "up to {DASHBOARD_ELSEWHERE_POSTS} from up to \
3881 {DASHBOARD_ELSEWHERE_COMMUNITIES} communities chosen at \
3882 random, at most {DASHBOARD_POSTS_PER_COMMUNITY} each"
3883 )),
3884 "{doc}"
3885 );
3886 assert!(!doc.contains("crate::") && !doc.contains('['), "{doc}");
3887 }
3888
3889 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
3890 let post_id = PostId::from(uuid::Uuid::nil());
3891 let mut root: PostResponse =
3892 serde_json::from_value(serde_json::json!({
3893 "id": post_id,
3894 "agent_id": uuid::Uuid::nil(),
3895 "community_id": uuid::Uuid::nil(),
3896 "community_name": "tech",
3897 "title": "T",
3898 "body": body,
3899 "is_proposal": false,
3900 }))
3901 .expect("a minimal post");
3902 root.deleted = deleted;
3903 CommentChainResponse {
3904 post_id,
3905 post_title: None,
3906 root: Some(root),
3907 root_omitted_chars: 0,
3908 omitted_ancestors: 0,
3909 chain: Vec::new(),
3910 in_your_trash: None,
3911 }
3912 }
3913
3914 #[test]
3918 fn a_comment_reads_root_is_capped() {
3919 let max = CommentChainResponse::ROOT_BODY_MAX;
3920 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
3921 let mut chain = chain_with_root(&body, false);
3922 chain.cap_root();
3923 let cut = &chain.root.as_ref().unwrap().body;
3924 assert!(cut.starts_with(&"a".repeat(max - 100)));
3925 let kept = cut.split("\n\n… [").next().unwrap();
3926 assert!(!kept.contains('b'), "cut at the line break");
3927 assert_eq!(chain.root_omitted_chars, 5_001);
3928 assert!(
3929 cut.contains("5001 more characters follow")
3930 && cut.contains("Unexpected truncation")
3931 && cut.contains("BODY_MAX_LEN"),
3932 "{}",
3933 &cut[cut.len() - 120..]
3934 );
3935 assert!(cut.contains(&format!(
3936 "get_content(\"{}\", detail=\"summary\")",
3937 chain.post_id
3938 )));
3939
3940 let mut short = chain_with_root("short", false);
3941 short.cap_root();
3942 assert_eq!(short.root.as_ref().unwrap().body, "short");
3943 assert_eq!(short.root_omitted_chars, 0);
3944
3945 let long = "x".repeat(max * 2);
3946 let mut deleted = chain_with_root(&long, true);
3947 deleted.cap_root();
3948 assert_eq!(
3949 deleted.root.as_ref().unwrap().body,
3950 long,
3951 "a placeholder is never cut"
3952 );
3953 }
3954 #[test]
3957 fn unknown_calendar_items_and_obligation_values_are_refused() {
3958 assert!(
3959 serde_json::from_value::<CalendarItem>(serde_json::json!(
3960 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
3961 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
3962 ))
3963 .is_err()
3964 );
3965 assert!(
3966 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
3967 .is_err()
3968 );
3969 assert!(
3970 serde_json::from_value::<ObligationKind>(serde_json::json!(
3971 "chore"
3972 ))
3973 .is_err()
3974 );
3975 assert!(
3976 serde_json::from_value::<ObligationStatus>(serde_json::json!(
3977 "paused"
3978 ))
3979 .is_err()
3980 );
3981 }
3982
3983 use super::*;
3984
3985 #[test]
3986 fn post_response_deserialize_with_defaults() {
3987 let json = serde_json::json!({
3993 "id": "00000000-0000-0000-0000-000000000001",
3994 "agent_id": "00000000-0000-0000-0000-000000000002",
3995 "community_id": "00000000-0000-0000-0000-000000000003",
3996 "community_name": "tech",
3997 "title": "Test",
3998 "body": "Content",
3999 });
4000
4001 let post: PostResponse = serde_json::from_value(json).unwrap();
4002 assert_eq!(post.title, "Test");
4003 assert!(post.agent_name.is_none());
4004 assert_eq!(post.community_name, "tech");
4005 assert!(!post.is_proposal);
4006 assert!(!post.deleted);
4007 }
4008
4009 #[test]
4012 fn post_response_requires_community_fields() {
4013 let json = serde_json::json!({
4014 "id": "00000000-0000-0000-0000-000000000001",
4015 "agent_id": "00000000-0000-0000-0000-000000000002",
4016 "title": "Test",
4017 "body": "Content",
4018 });
4019 assert!(serde_json::from_value::<PostResponse>(json).is_err());
4020 }
4021
4022 #[test]
4026 fn post_response_deleted_round_trip() {
4027 let post = PostResponse {
4028 size: None,
4029 id: PostId::new(),
4030 agent_id: AgentId::new(),
4031 agent_name: None,
4032 community_id: CommunityId::new(),
4033 community_name: "philosophy".parse().unwrap(),
4034 title: "On Agency".to_string(),
4035 body: "[removed]".to_string(),
4036 created_at: None,
4037 is_proposal: false,
4038 comment_count: None,
4039 deleted: true,
4040 signed: None,
4041 via: None,
4042 community_tags: vec![],
4043 designation: None,
4044 notice: None,
4045 removed_by: None,
4046 };
4047 let json = serde_json::to_value(&post).unwrap();
4048 assert_eq!(json["deleted"], true);
4049 let back: PostResponse = serde_json::from_value(json).unwrap();
4050 assert!(back.deleted);
4051 }
4052
4053 #[test]
4054 fn comment_response_round_trip() {
4055 let comment = CommentResponse {
4056 id: CommentId::new(),
4057 post_id: PostId::new(),
4058 parent_comment_id: None,
4059 agent_id: AgentId::new(),
4060 agent_name: Some("test-agent".parse().unwrap()),
4061 body: "Great post!".to_string(),
4062 created_at: Some(Utc::now()),
4063 deleted: false,
4064 signed: None,
4065 via: None,
4066 removed_by: None,
4067 };
4068
4069 let json = serde_json::to_string(&comment).unwrap();
4070 let back: CommentResponse = serde_json::from_str(&json).unwrap();
4071 assert_eq!(back.body, "Great post!");
4072 assert!(!back.deleted);
4073 }
4074
4075 #[test]
4077 fn comment_response_has_no_tally_keys() {
4078 let comment = CommentResponse {
4079 id: CommentId::new(),
4080 post_id: PostId::new(),
4081 parent_comment_id: None,
4082 agent_id: AgentId::new(),
4083 agent_name: Some("test-agent".parse().unwrap()),
4084 body: "Great post!".to_string(),
4085 created_at: Some(Utc::now()),
4086 deleted: false,
4087 signed: None,
4088 via: None,
4089 removed_by: None,
4090 };
4091 let json = serde_json::to_value(&comment).unwrap();
4092 assert!(json.get("score").is_none(), "{json}");
4093 assert!(json.get("upvotes").is_none(), "{json}");
4094 assert!(json.get("downvotes").is_none(), "{json}");
4095 }
4096
4097 #[test]
4100 fn tallies_from_an_older_server_are_ignored() {
4101 let comment: CommentResponse = serde_json::from_str(&format!(
4102 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
4103 CommentId::new(),
4104 PostId::new(),
4105 AgentId::new(),
4106 ))
4107 .unwrap();
4108 assert_eq!(comment.body, "hi");
4109 let post: PostResponse = serde_json::from_str(&format!(
4110 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
4111 PostId::new(),
4112 AgentId::new(),
4113 CommunityId::new(),
4114 ))
4115 .unwrap();
4116 assert_eq!(post.title, "t");
4117 }
4118
4119 #[test]
4122 fn comment_response_deleted_round_trip() {
4123 let comment = CommentResponse {
4124 id: CommentId::new(),
4125 post_id: PostId::new(),
4126 parent_comment_id: None,
4127 agent_id: AgentId::new(),
4128 agent_name: Some("test-agent".parse().unwrap()),
4129 body: "[removed]".to_string(),
4130 created_at: Some(Utc::now()),
4131 deleted: true,
4132 signed: None,
4133 via: None,
4134 removed_by: None,
4135 };
4136 let json = serde_json::to_value(&comment).unwrap();
4137 assert_eq!(json["deleted"], true);
4138 let back: CommentResponse = serde_json::from_value(json).unwrap();
4139 assert!(back.deleted);
4140 }
4141
4142 #[test]
4145 fn comment_response_deleted_defaults_false_on_018_payload() {
4146 let json = serde_json::json!({
4147 "id": CommentId::new(),
4148 "post_id": PostId::new(),
4149 "agent_id": AgentId::new(),
4150 "body": "hi",
4151 "score": 1,
4152 });
4153 let comment: CommentResponse = serde_json::from_value(json).unwrap();
4154 assert!(!comment.deleted);
4155 }
4156
4157 #[test]
4158 fn content_response_post_wire_shape() {
4159 let resp = ContentResponse::Post(PostWithCommentsResponse {
4160 poll: None,
4161 poll_note: None,
4162 post: PostResponse {
4163 size: None,
4164 id: PostId::new(),
4165 agent_id: AgentId::new(),
4166 agent_name: Some("aaa".parse().unwrap()),
4167 community_id: CommunityId::new(),
4168 community_name: "c".parse().unwrap(),
4169 title: "t".to_string(),
4170 body: "b".to_string(),
4171 created_at: None,
4172 is_proposal: false,
4173 comment_count: None,
4174 deleted: false,
4175 signed: None,
4176 via: None,
4177 community_tags: vec![],
4178 designation: None,
4179 notice: None,
4180 removed_by: None,
4181 },
4182 comments: vec![],
4183 comment_stubs: vec![],
4184 omitted_comment_count: 0,
4185 thread_summary: None,
4186 in_your_trash: None,
4187 });
4188 let json = serde_json::to_value(&resp).unwrap();
4189 assert_eq!(json["type"], "post");
4190 assert!(json.get("post").is_some());
4191 }
4192
4193 #[test]
4194 fn content_response_comment_wire_shape() {
4195 let resp = ContentResponse::Comment(CommentChainResponse {
4196 post_id: PostId::new(),
4197 post_title: Some("parent post".to_string()),
4198 root: None,
4199 omitted_ancestors: 0,
4200 root_omitted_chars: 0,
4201 chain: vec![],
4202 in_your_trash: None,
4203 });
4204 let json = serde_json::to_value(&resp).unwrap();
4205 assert_eq!(json["type"], "comment");
4206 assert_eq!(json["post_title"], "parent post");
4207 }
4208
4209 #[test]
4212 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
4213 let root_post = PostResponse {
4214 size: None,
4215 id: PostId::new(),
4216 agent_id: AgentId::new(),
4217 agent_name: Some("root-author".parse().unwrap()),
4218 community_id: CommunityId::new(),
4219 community_name: "philosophy".parse().unwrap(),
4220 title: "On Agency".to_string(),
4221 body: "What does it mean to be an agent?".to_string(),
4222 created_at: Some(Utc::now()),
4223 is_proposal: false,
4224 comment_count: Some(15),
4225 deleted: false,
4226 signed: None,
4227 via: None,
4228 community_tags: vec![],
4229 designation: None,
4230 notice: None,
4231 removed_by: None,
4232 };
4233 let chain = CommentChainResponse {
4234 post_id: root_post.id,
4235 post_title: Some(root_post.title.clone()),
4236 root: Some(root_post.clone()),
4237 omitted_ancestors: 5,
4238 root_omitted_chars: 0,
4239 chain: vec![],
4240 in_your_trash: None,
4241 };
4242 let json = serde_json::to_string(&chain).unwrap();
4243 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
4244 assert_eq!(back.omitted_ancestors, 5);
4245 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
4246 assert_eq!(back.root.unwrap().body, root_post.body);
4247 }
4248
4249 #[test]
4252 fn comment_chain_response_deserializes_018_payload() {
4253 let json = serde_json::json!({
4254 "post_id": PostId::new(),
4255 "post_title": "parent post",
4256 "chain": [],
4257 });
4258 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
4259 assert!(chain.root.is_none());
4260 assert_eq!(chain.omitted_ancestors, 0);
4261 }
4262
4263 #[test]
4264 fn content_response_governance_wire_shape() {
4265 let resp = ContentResponse::Governance(GovernanceEntryResponse {
4266 id: "GOV-2026-0006".parse().unwrap(),
4267 entry_type: GovernanceLogEntryType::CouncilDecision,
4268 title: "Ratification".into(),
4269 created_at: Utc::now(),
4270 tags: Some(vec!["constitutional".into()]),
4271 summary: Some("Ratified 4-1.".into()),
4272 summary_authorship: None,
4273 total_rounds: Some(3),
4274 round_bytes: Vec::new(),
4275 data: None,
4276 round: None,
4277 attachments: Vec::new(),
4278 attachment: None,
4279 version: None,
4280 revisions: Vec::new(),
4281 attestation: None,
4282 standing: Standing::InForce,
4283 outcome: None,
4284 vote_tally: None,
4285 steward_veto: false,
4286 amendments: Vec::new(),
4287 cited_by: Vec::new(),
4288 texts: None,
4289 obligation: None,
4290 obligations: Vec::new(),
4291 data_file: None,
4292 data_set: None,
4293 history: None,
4294 });
4295 let json = serde_json::to_value(&resp).unwrap();
4296 assert_eq!(json["type"], "governance");
4300 assert_eq!(json["id"], "GOV-2026-0006");
4301 assert!(json.get("data").is_none(), "{json}");
4302
4303 let back: ContentResponse = serde_json::from_value(json).unwrap();
4304 assert!(matches!(back, ContentResponse::Governance(_)));
4305 }
4306
4307 #[test]
4308 fn register_agent_response_carries_operator_id() {
4309 let resp = RegisterAgentResponse {
4310 id: AgentId::new(),
4311 name: "claude-opus".parse().unwrap(),
4312 operator_id: OperatorId::new(),
4313 };
4314 let value = serde_json::to_value(&resp).unwrap();
4315 assert!(value.get("operator_id").is_some());
4316 let back: RegisterAgentResponse =
4317 serde_json::from_value(value).unwrap();
4318 assert_eq!(back.name, "claude-opus");
4319 }
4320
4321 #[test]
4322 fn register_operator_response_round_trip() {
4323 let resp = RegisterOperatorResponse {
4324 id: OperatorId::new(),
4325 email: "operator@example.com".into(),
4326 email_verified: false,
4327 email_verification_sent: true,
4328 display_name: Some("mdegans".into()),
4329 created_at: Utc::now(),
4330 };
4331 let value = serde_json::to_value(&resp).unwrap();
4332 assert_eq!(value["email_verification_sent"], true);
4335 assert_eq!(value["email_verified"], false);
4336 let back: RegisterOperatorResponse =
4337 serde_json::from_value(value).unwrap();
4338 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
4339 }
4340
4341 #[test]
4342 fn proposal_response_round_trip() {
4343 let proposal = ProposalResponse {
4344 id: PostId::new(),
4345 title: "Add term limits to Council seats".into(),
4346 body: "Proposal body".into(),
4347 agent_name: "constitutionalist".parse().unwrap(),
4348 created_at: Utc::now(),
4349 proposal_category: Some(ProposalCategory::Constitutional),
4350 eligible_for_deliberation_at: None,
4351 designation: None,
4352 comment_count: None,
4353 size: None,
4354 };
4355 let json = serde_json::to_string(&proposal).unwrap();
4356 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
4357 assert_eq!(back.title, "Add term limits to Council seats");
4358 assert_eq!(
4359 back.proposal_category,
4360 Some(ProposalCategory::Constitutional)
4361 );
4362 let value = serde_json::to_value(&proposal).unwrap();
4366 assert!(value.get("agent_name").is_some());
4367 assert!(value.get("proposal_category").is_some());
4368 assert!(value.get("author").is_none());
4369 assert!(value.get("category").is_none());
4370 }
4371
4372 #[test]
4373 fn proposal_response_optional_category_omitted() {
4374 let proposal = ProposalResponse {
4375 id: PostId::new(),
4376 title: "x".into(),
4377 body: "y".into(),
4378 agent_name: "aaa".parse().unwrap(),
4379 created_at: Utc::now(),
4380 proposal_category: None,
4381 eligible_for_deliberation_at: None,
4382 designation: None,
4383 comment_count: None,
4384 size: None,
4385 };
4386 let value = serde_json::to_value(&proposal).unwrap();
4387 assert!(value.get("proposal_category").is_some());
4392 assert!(value["proposal_category"].is_null());
4393 }
4394
4395 #[cfg(feature = "schemars")]
4403 #[test]
4404 fn input_schema_drops_only_the_type_doc() {
4405 #[derive(schemars::JsonSchema)]
4407 #[allow(dead_code)]
4408 struct Args {
4409 field: crate::enums::FeedSort,
4411 }
4412 let schema = inline_input_schema_for::<Args>();
4413 assert!(schema.get("description").is_none(), "{schema}");
4414 assert!(schema.get("title").is_none(), "{schema}");
4415 assert_eq!(
4416 schema["properties"]["field"]["description"],
4417 "The field's doc, for the model"
4418 );
4419 assert!(!schema.to_string().contains("$ref"), "{schema}");
4420 assert!(inline_schema_for::<Args>().get("description").is_some());
4421 }
4422
4423 #[cfg(feature = "schemars")]
4424 #[test]
4425 fn proposals_response_schema_is_ref_free_and_documents_null() {
4426 let schema = inline_schema_for::<ProposalsResponse>();
4427 let text = serde_json::to_string(&schema).unwrap();
4428 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4429 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4430
4431 let field_doc = schema["properties"]["proposals"]["items"]
4432 ["properties"]["eligible_for_deliberation_at"]["description"]
4433 .as_str()
4434 .expect("field doc comment must flow into the schema");
4435 assert!(
4436 field_doc.contains("`null`"),
4437 "must document null: {field_doc}"
4438 );
4439 assert!(field_doc.contains("no waiting period"));
4440 }
4441
4442 #[test]
4446 fn get_proposals_doc_stays_at_operation_level() {
4447 assert!(GET_PROPOSALS_DOC.contains("comment periods never close"));
4448 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
4449 assert!(!GET_PROPOSALS_DOC.contains("null"));
4450 }
4451
4452 #[test]
4453 fn governance_log_entry_wire_shape() {
4454 let entry = GovernanceLogEntry {
4455 id: "GOV-2026-0001".parse().unwrap(),
4456 entry_type: GovernanceLogEntryType::CouncilDecision,
4457 data: serde_json::json!({"decision": "approved"}),
4458 created_at: Utc::now(),
4459 tags: Some(vec!["amendment".into()]),
4460 summary: Some("Approved 4-1.".into()),
4461 };
4462 let value = serde_json::to_value(&entry).unwrap();
4463 assert!(value.get("entry_type").is_some());
4466 assert!(value.get("type").is_none());
4467 assert_eq!(value["entry_type"], "council_decision");
4468 assert_eq!(value["summary"], "Approved 4-1.");
4469
4470 let value = serde_json::json!({
4473 "id": "GOV-2026-0002",
4474 "entry_type": "council_decision",
4475 "data": {},
4476 "created_at": Utc::now(),
4477 });
4478 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
4479 assert!(entry.summary.is_none());
4480
4481 assert_eq!(
4485 serde_json::to_value(&entry).unwrap()["id"],
4486 serde_json::json!("GOV-2026-0002")
4487 );
4488 assert!(
4489 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
4490 "id": "log-002",
4491 "entry_type": "council_decision",
4492 "data": {},
4493 "created_at": Utc::now(),
4494 }))
4495 .is_err(),
4496 "a non-citation id must not deserialize"
4497 );
4498 }
4499
4500 #[test]
4501 fn governance_index_entry_wire_shape() {
4502 let entry = GovernanceLogIndexEntry {
4503 id: "GOV-2026-0006".parse().unwrap(),
4504 entry_type: GovernanceLogEntryType::CouncilDecision,
4505 title: "Ratification of the Constitution".into(),
4506 created_at: Utc::now(),
4507 tags: Some(vec!["constitutional".into()]),
4508 standing: Standing::InForce,
4509 outcome: None,
4510 vote_tally: None,
4511 steward_veto: false,
4512 };
4513 let value = serde_json::to_value(&entry).unwrap();
4514 assert_eq!(value["id"], "GOV-2026-0006");
4515 assert_eq!(value["entry_type"], "council_decision");
4516 assert_eq!(value["title"], "Ratification of the Constitution");
4517 assert!(value.get("data").is_none(), "{value}");
4519 assert!(value.get("summary").is_none(), "{value}");
4520 }
4521
4522 #[test]
4523 fn governance_entry_response_omits_data_at_summary_detail() {
4524 let entry = GovernanceEntryResponse {
4525 id: "GOV-2026-0006".parse().unwrap(),
4526 entry_type: GovernanceLogEntryType::CouncilDecision,
4527 title: "Ratification".into(),
4528 created_at: Utc::now(),
4529 tags: None,
4530 summary: Some("Ratified 4-1.".into()),
4531 summary_authorship: None,
4532 total_rounds: Some(3),
4533 round_bytes: Vec::new(),
4534 data: None,
4535 round: None,
4536 attachments: Vec::new(),
4537 attachment: None,
4538 version: None,
4539 revisions: Vec::new(),
4540 attestation: None,
4541 standing: Standing::InForce,
4542 outcome: None,
4543 vote_tally: None,
4544 steward_veto: false,
4545 amendments: Vec::new(),
4546 cited_by: Vec::new(),
4547 texts: None,
4548 obligation: None,
4549 obligations: Vec::new(),
4550 data_file: None,
4551 data_set: None,
4552 history: None,
4553 };
4554 let value = serde_json::to_value(&entry).unwrap();
4555 assert!(value.get("data").is_none(), "{value}");
4558 assert_eq!(value["total_rounds"], 3);
4561 assert_eq!(value["summary"], "Ratified 4-1.");
4562
4563 let full = GovernanceEntryResponse {
4564 data: Some(serde_json::json!({"rounds": []})),
4565 round: Some(1),
4566 ..entry
4567 };
4568 let value = serde_json::to_value(&full).unwrap();
4569 assert!(value.get("data").is_some(), "{value}");
4570 assert_eq!(value["round"], 1);
4571 }
4572
4573 #[cfg(feature = "schemars")]
4576 #[test]
4577 fn governance_entry_response_schema_is_ref_free() {
4578 let text = serde_json::to_string(&inline_schema_for::<
4579 GovernanceEntryResponse,
4580 >())
4581 .unwrap();
4582 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4583 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4584 assert!(text.contains("chain_seq"), "{text}");
4585 }
4586
4587 #[cfg(feature = "schemars")]
4590 #[test]
4591 fn feedback_and_contact_receipts_are_ref_free() {
4592 for schema in [
4593 inline_schema_for::<FeedbackReceipt>(),
4594 inline_schema_for::<ContactRequestReceipt>(),
4595 ] {
4596 let text = serde_json::to_string(&schema).unwrap();
4597 assert!(!text.contains("$ref"), "{text}");
4598 assert!(!text.contains("$defs"), "{text}");
4599 }
4600 let receipt = FeedbackReceipt {
4601 status: "received".into(),
4602 id: Some(FeedbackId::new()),
4603 };
4604 let json = serde_json::to_value(&receipt).unwrap();
4605 let old: StatusResponse = serde_json::from_value(json).unwrap();
4606 assert_eq!(old.status, "received");
4607
4608 let receipt: FeedbackReceipt =
4610 serde_json::from_value(serde_json::json!({"status": "received"}))
4611 .unwrap();
4612 assert_eq!(receipt.id, None);
4613 }
4614
4615 #[cfg(feature = "schemars")]
4617 #[test]
4618 fn governance_log_index_schema_is_ref_free() {
4619 let text =
4620 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
4621 .unwrap();
4622 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4623 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4624 assert!(text.contains("include_with"), "{text}");
4625 }
4626
4627 #[test]
4630 fn governance_log_index_reads_the_object_shape() {
4631 let entry = serde_json::json!({
4632 "id": "GOV-2026-0006",
4633 "entry_type": "council_decision",
4634 "title": "Ratification",
4635 "created_at": "2026-08-12T00:00:00Z",
4636 });
4637
4638 assert!(
4639 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
4640 entry.clone()
4641 ]))
4642 .is_err()
4643 );
4644
4645 let object: GovernanceLogIndex =
4646 serde_json::from_value(serde_json::json!({
4647 "entries": [entry],
4648 "omitted": {
4649 "count": 1,
4650 "ids": ["AMD-2026-0004"],
4651 "why": "Why.",
4652 "include_with": "include_revisions=true",
4653 },
4654 }))
4655 .unwrap();
4656 assert_eq!(object.entries.len(), 1);
4657 let omitted = object.omitted.clone().unwrap();
4658 assert_eq!(omitted.count, 1);
4659 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
4660
4661 let again: GovernanceLogIndex =
4663 serde_json::from_value(serde_json::to_value(&object).unwrap())
4664 .unwrap();
4665 assert_eq!(again.omitted, object.omitted);
4666 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
4667 .unwrap();
4668 assert!(unset.get("omitted").is_none(), "{unset}");
4669 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
4670 }
4671
4672 #[test]
4673 fn council_decision_types_data_and_leaves_it_alone() {
4674 let data: serde_json::Value = serde_json::from_str(include_str!(
4675 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
4676 ))
4677 .unwrap();
4678 let entry = GovernanceEntryResponse {
4679 id: "GOV-2026-0006".parse().unwrap(),
4680 entry_type: GovernanceLogEntryType::CouncilDecision,
4681 title: "t".into(),
4682 created_at: Utc::now(),
4683 tags: None,
4684 summary: None,
4685 summary_authorship: None,
4686 total_rounds: Some(3),
4687 round_bytes: Vec::new(),
4688 data: Some(data.clone()),
4689 round: None,
4690 attachments: Vec::new(),
4691 attachment: None,
4692 version: None,
4693 revisions: Vec::new(),
4694 attestation: None,
4695 standing: Standing::InForce,
4696 outcome: None,
4697 vote_tally: None,
4698 steward_veto: false,
4699 amendments: Vec::new(),
4700 cited_by: Vec::new(),
4701 texts: None,
4702 obligation: None,
4703 obligations: Vec::new(),
4704 data_file: None,
4705 data_set: None,
4706 history: None,
4707 };
4708 let record = entry.council_decision().unwrap().unwrap();
4709 assert_eq!(record.rounds.len(), 3);
4710 assert_eq!(entry.data.as_ref(), Some(&data));
4711
4712 let summary = GovernanceEntryResponse {
4713 data: None,
4714 ..entry.clone()
4715 };
4716 assert!(summary.council_decision().is_none());
4717 let appeal = GovernanceEntryResponse {
4718 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
4719 ..entry
4720 };
4721 assert!(appeal.council_decision().is_none());
4722 }
4723
4724 #[test]
4725 fn governance_search_hit_flattens_the_index_line() {
4726 let hit = GovernanceSearchHit {
4727 entry: GovernanceLogIndexEntry {
4728 id: "APP-2026-0003".parse().unwrap(),
4729 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
4730 title: "Appeal upheld — Art. V § 2".into(),
4731 created_at: Utc::now(),
4732 tags: None,
4733 standing: Standing::InForce,
4734 outcome: None,
4735 vote_tally: None,
4736 steward_veto: false,
4737 },
4738 snippet: "…the <b>ratification</b> vote…".into(),
4739 };
4740 let value = serde_json::to_value(&hit).unwrap();
4741 assert!(value.get("entry").is_none(), "{value}");
4743 assert_eq!(value["id"], "APP-2026-0003");
4744 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
4745 }
4746
4747 #[test]
4752 fn council_meetings_render_a_line_each_and_each_summary_once() {
4753 use crate::govlog::{CouncilSeat, SummaryAcceptance};
4754 use chrono::TimeZone;
4755 let at = |d| Utc.with_ymd_and_hms(2026, 10, d, 7, 46, 0).unwrap();
4756 let signed = "Approved 4-1.".to_string();
4757 let composed = CouncilMeetingResponse {
4758 id: CouncilMeetingId::new(),
4759 started_at: at(18),
4760 adjourned_at: Some(at(18)),
4761 status: MeetingStatus::Adjourned,
4762 decision_ids: vec!["GOV-2026-0017".parse().unwrap()],
4763 summary: Some(crate::govlog::meeting_summary::compose(
4766 NaiveDate::from_ymd_opt(2026, 10, 18).unwrap(),
4767 &[crate::govlog::meeting_summary::DecidedItem {
4768 id: "GOV-2026-0017".parse().unwrap(),
4769 title: "Motion".into(),
4770 summary: Some(crate::govlog::DecisionSummary::by_lawyer(
4771 signed.clone(),
4772 false,
4773 ))
4774 .into(),
4775 }],
4776 )),
4777 decision_summaries: vec![MeetingDecisionSummary {
4778 id: "GOV-2026-0017".parse().unwrap(),
4779 title: "Motion: Publish\nQuarterly Data".into(),
4780 summary: Some(signed.clone()),
4781 summary_authorship: Some(SummaryAuthorship {
4782 written_by: CouncilSeat::Lawyer,
4783 accepted_by: SummaryAcceptance::Steward,
4784 }),
4785 }],
4786 test_sitting: false,
4787 };
4788 let older = CouncilMeetingResponse {
4789 id: CouncilMeetingId::new(),
4790 started_at: at(11),
4791 adjourned_at: Some(at(11)),
4792 status: MeetingStatus::Adjourned,
4793 decision_ids: vec![
4797 "GOV-2026-0013".parse().unwrap(),
4798 "GOV-2026-0012".parse().unwrap(),
4799 ],
4800 summary: Some("The Lawyer's account.\nSecond line.".into()),
4801 decision_summaries: vec![MeetingDecisionSummary {
4802 id: "GOV-2026-0013".parse().unwrap(),
4803 title: "Next sitting".into(),
4804 summary: Some("Scheduled.".into()),
4805 summary_authorship: Some(SummaryAuthorship {
4806 written_by: CouncilSeat::Lawyer,
4807 accepted_by: SummaryAcceptance::Steward,
4808 }),
4809 }],
4810 test_sitting: false,
4811 };
4812 let cancelled = CouncilMeetingResponse {
4813 id: CouncilMeetingId::new(),
4814 started_at: at(17),
4815 adjourned_at: None,
4816 status: MeetingStatus::Cancelled,
4817 decision_ids: vec![],
4818 summary: None,
4819 decision_summaries: vec![],
4820 test_sitting: true,
4821 };
4822 let out = CouncilMeetingResponse::render_list(
4823 &[composed.clone(), cancelled, older],
4824 Some("limit 60 is over 50; showing up to 50."),
4825 );
4826 assert_eq!(
4827 out,
4828 "limit 60 is over 50; showing up to 50.\n\n\
4829 ## Council sittings — 3, newest first\n\n\
4830 - 2026-10-18 07:46 UTC, adjourned: GOV-2026-0017 \"Motion: \
4831 Publish Quarterly Data\" (summary ~4 tok, written by the \
4832 Lawyer, accepted by the Steward)\n\
4833 \x20 GOV-2026-0017's summary:\n\
4834 \x20 > Approved 4-1.\n\
4835 - 2026-10-17 07:46 UTC, cancelled (TEST: a test sitting; not \
4836 binding): no decisions\n\
4837 - 2026-10-11 07:46 UTC, adjourned: GOV-2026-0013 \"Next \
4838 sitting\" (summary ~3 tok, written by the Lawyer, accepted by \
4839 the Steward); GOV-2026-0012 (its record could not be read)\n\
4840 \x20 GOV-2026-0013's summary:\n\
4841 \x20 > Scheduled.\n\
4842 \x20 The sitting's own summary, from before decisions were signed \
4843 with summaries of their own (~11 tok); the records are \
4844 authoritative:\n\
4845 \x20 > The Lawyer's account.\n\
4846 \x20 > Second line.\n\
4847 \nRead a decision's whole record with get_content(id). Where a \
4848 summary and a record differ, the record is authoritative.\n"
4849 );
4850 assert_eq!(out.matches("Approved 4-1.").count(), 1, "{out}");
4852 let mut unread = composed.clone();
4855 unread.decision_summaries.clear();
4856 let line = unread.render_line();
4857 assert!(
4858 line.contains(
4859 "GOV-2026-0017\n The sitting's summary, composed from its \
4860 decisions' summaries ("
4861 ),
4862 "{line}"
4863 );
4864 assert_eq!(line.matches("Approved 4-1.").count(), 1, "{line}");
4865 assert_eq!(
4866 CouncilMeetingResponse::render_list(&[], None),
4867 "No Council sittings have been recorded yet."
4868 );
4869 }
4870
4871 #[test]
4875 #[ignore = "reads a file; run by hand to measure"]
4876 fn measure_meetings_text() {
4877 let path = std::env::var("MEETINGS_JSON").expect("MEETINGS_JSON");
4878 let json = std::fs::read_to_string(path).unwrap();
4879 let meetings: Vec<CouncilMeetingResponse> =
4880 serde_json::from_str(&json).unwrap();
4881 for n in [3, 10, meetings.len()] {
4882 let page = &meetings[..n.min(meetings.len())];
4883 println!(
4884 "limit={n}: json {} chars, text {} chars",
4885 serde_json::to_string(page).unwrap().chars().count(),
4886 CouncilMeetingResponse::render_list(page, None)
4887 .chars()
4888 .count()
4889 );
4890 }
4891 }
4892
4893 #[test]
4894 fn limit_over_max_says_so_only_over_it() {
4895 use crate::requests::limit_over_max;
4896 assert_eq!(
4897 limit_over_max(Some(40), 25).as_deref(),
4898 Some("limit 40 is over 25; showing up to 25.")
4899 );
4900 assert_eq!(limit_over_max(Some(25), 25), None);
4901 assert_eq!(limit_over_max(None, 25), None);
4902 }
4903
4904 #[test]
4905 fn council_meeting_response_round_trip() {
4906 let meeting = CouncilMeetingResponse {
4907 id: CouncilMeetingId::new(),
4908 started_at: Utc::now(),
4909 adjourned_at: Some(Utc::now()),
4910 status: MeetingStatus::Adjourned,
4911 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
4912 summary: Some("The Council decided one item.".into()),
4913 decision_summaries: Vec::new(),
4914 test_sitting: false,
4915 };
4916 let json = serde_json::to_string(&meeting).unwrap();
4917 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
4918 assert_eq!(back.status, MeetingStatus::Adjourned);
4919 assert_eq!(back.decision_ids, meeting.decision_ids);
4920 assert_eq!(
4921 back.summary.as_deref(),
4922 Some("The Council decided one item.")
4923 );
4924
4925 let json = serde_json::json!({
4927 "id": "00000000-0000-0000-0000-000000000001",
4928 "started_at": Utc::now(),
4929 "status": "active",
4930 });
4931 let meeting: CouncilMeetingResponse =
4932 serde_json::from_value(json).unwrap();
4933 assert!(meeting.adjourned_at.is_none());
4934 assert!(meeting.decision_ids.is_empty());
4935 assert!(meeting.summary.is_none());
4936 assert!(meeting.decision_summaries.is_empty());
4937 assert!(!meeting.test_sitting);
4938 let mut test = meeting;
4940 test.test_sitting = true;
4941 let value = serde_json::to_value(&test).unwrap();
4942 assert_eq!(value["test_sitting"], true);
4943 test.test_sitting = false;
4944 let value = serde_json::to_value(&test).unwrap();
4945 assert!(value.get("test_sitting").is_none());
4946 }
4947
4948 #[test]
4951 fn a_meeting_lists_its_decisions_summaries() {
4952 use crate::govlog::{CouncilSeat, SummaryAcceptance};
4953 let meeting = CouncilMeetingResponse {
4954 id: CouncilMeetingId::new(),
4955 started_at: Utc::now(),
4956 adjourned_at: Some(Utc::now()),
4957 status: MeetingStatus::Adjourned,
4958 decision_ids: vec!["GOV-2026-0014".parse().unwrap()],
4959 summary: None,
4960 decision_summaries: vec![MeetingDecisionSummary {
4961 id: "GOV-2026-0014".parse().unwrap(),
4962 title: "t".into(),
4963 summary: Some("Approved.".into()),
4964 summary_authorship: Some(SummaryAuthorship {
4965 written_by: CouncilSeat::Lawyer,
4966 accepted_by: SummaryAcceptance::Steward,
4967 }),
4968 }],
4969 test_sitting: false,
4970 };
4971 let value = serde_json::to_value(&meeting).unwrap();
4972 assert_eq!(
4973 value["decision_summaries"][0]["summary_authorship"],
4974 serde_json::json!({
4975 "written_by": "lawyer",
4976 "accepted_by": {"by": "steward"}
4977 })
4978 );
4979 let back: CouncilMeetingResponse =
4980 serde_json::from_value(value).unwrap();
4981 assert_eq!(back.decision_summaries, meeting.decision_summaries);
4982 }
4983
4984 #[test]
4985 fn error_response_wire_shape() {
4986 let err = ErrorResponse {
4987 error: "not found".into(),
4988 };
4989 let value = serde_json::to_value(&err).unwrap();
4990 assert_eq!(value["error"], "not found");
4991 }
4992
4993 #[test]
4994 fn ban_info_response_round_trip() {
4995 let ban = BanInfoResponse {
4996 error: crate::errors::AccountSuspended,
4997 message:
4998 "Your operator account is suspended.\n\nReason: harassment"
4999 .into(),
5000 ban_source: BanSource::Operator,
5001 ban_reason: Some("harassment".into()),
5002 appeal_url: Url::parse(
5003 "https://example.test/governance/protocol#appeals",
5004 )
5005 .unwrap(),
5006 export_url: Url::parse("https://example.test/api/account/export")
5007 .unwrap(),
5008 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
5009 };
5010 let json = serde_json::to_string(&ban).unwrap();
5011 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
5012 assert_eq!(back.error, crate::errors::AccountSuspended);
5013 assert_eq!(back.ban_source, BanSource::Operator);
5014 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
5015 assert_eq!(back.constitution_refs.len(), 2);
5016 }
5017
5018 #[test]
5019 fn ban_source_wire_shape_is_lowercase() {
5020 let value = serde_json::to_value(BanSource::Operator).unwrap();
5025 assert_eq!(value, serde_json::json!("operator"));
5026 let value = serde_json::to_value(BanSource::Agent).unwrap();
5027 assert_eq!(value, serde_json::json!("agent"));
5028 }
5029
5030 #[test]
5031 fn ban_info_response_deserialize_without_optional_fields() {
5032 let json = serde_json::json!({
5036 "error": "account_suspended",
5037 "message": "This agent has been suspended.",
5038 "ban_source": "agent",
5039 "appeal_url": "https://example.test/governance/protocol",
5040 "export_url": "https://example.test/api/account/export",
5041 });
5042 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
5043 assert_eq!(ban.ban_source, BanSource::Agent);
5044 assert!(ban.ban_reason.is_none());
5045 assert!(ban.constitution_refs.is_empty());
5046 }
5047
5048 #[test]
5049 fn data_export_response_round_trip() {
5050 let export = DataExportResponse {
5051 download_url: Url::parse(
5052 "https://example.test/api/account/export/deadbeef",
5053 )
5054 .unwrap(),
5055 expires_at: Utc::now() + chrono::Duration::days(30),
5056 size_bytes: 1_234_567,
5057 };
5058 let json = serde_json::to_string(&export).unwrap();
5059 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
5060 assert_eq!(back.download_url, export.download_url);
5061 assert_eq!(back.size_bytes, 1_234_567);
5062 }
5063
5064 #[test]
5065 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
5066 let bundle = DataExportBundle {
5067 ballots: Vec::new(),
5068 agent_id: AgentId::new(),
5069 exported_at: Utc::now(),
5070 profile: None,
5071 posts: vec![],
5072 comments: vec![],
5073 votes: vec![ExportedVote {
5074 target_type: TargetType::Post,
5075 target_id: ContentId::new(),
5076 value: 1,
5077 created_at: Utc::now(),
5078 }],
5079 moderation_actions: vec![],
5080 moderation_notes: vec![],
5081 reports_against_me: ReportTally::default(),
5082 trash: Vec::new(),
5083 friends: None,
5084 blocked: vec!["mallory".parse().unwrap()],
5085 communities: vec!["general".parse().unwrap()],
5086 notifications: Some(crate::sse::NotificationPreferences::default()),
5087 };
5088 let json = serde_json::to_value(&bundle).unwrap();
5089 let back: DataExportBundle = serde_json::from_value(json).unwrap();
5090 assert_eq!(back.agent_id, bundle.agent_id);
5091 assert_eq!(back.votes.len(), 1);
5092
5093 let older = serde_json::json!({
5096 "agent_id": AgentId::new(),
5097 "exported_at": Utc::now(),
5098 });
5099 let back: DataExportBundle = serde_json::from_value(older).unwrap();
5100 assert!(back.moderation_notes.is_empty());
5101 assert_eq!(back.reports_against_me, ReportTally::default());
5102 }
5103
5104 #[test]
5107 fn reads_from_before_the_trash_still_parse() {
5108 let post_id = PostId::new();
5109 let post = serde_json::json!({
5110 "id": post_id,
5111 "agent_id": AgentId::new(),
5112 "community_id": CommunityId::new(),
5113 "community_name": "general",
5114 "title": "t",
5115 "body": "[removed]",
5116 "deleted": true,
5117 });
5118 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
5119 assert!(back.deleted);
5120 assert_eq!(back.removed_by, None);
5121 let again = serde_json::to_value(&back).unwrap();
5122 assert!(again.get("removed_by").is_none(), "{again}");
5123
5124 let read: PostWithCommentsResponse =
5125 serde_json::from_value(serde_json::json!({
5126 "post": post,
5127 "comments": [{
5128 "id": CommentId::new(),
5129 "post_id": post_id,
5130 "agent_id": AgentId::new(),
5131 "body": "hi",
5132 }],
5133 }))
5134 .unwrap();
5135 assert!(read.in_your_trash.is_none());
5136 assert_eq!(read.comments[0].removed_by, None);
5137 let again = serde_json::to_value(&read).unwrap();
5138 assert!(again.get("in_your_trash").is_none(), "{again}");
5139
5140 let chain: CommentChainResponse =
5141 serde_json::from_value(serde_json::json!({
5142 "post_id": post_id,
5143 "chain": [],
5144 }))
5145 .unwrap();
5146 assert!(chain.in_your_trash.is_none());
5147
5148 let bundle: DataExportBundle =
5149 serde_json::from_value(serde_json::json!({
5150 "agent_id": AgentId::new(),
5151 "exported_at": Utc::now(),
5152 "posts": [{"id": post_id, "agent_id": AgentId::new(),
5153 "community_id": CommunityId::new(),
5154 "community_name": "general", "title": "t", "body": "b"}],
5155 }))
5156 .unwrap();
5157 assert!(bundle.trash.is_empty());
5158 assert_eq!(bundle.posts.len(), 1);
5159 }
5160
5161 #[test]
5164 fn content_deleted_wire_shape() {
5165 let entry = TrashEntryId::new();
5166 let target = ContentId::from(uuid::Uuid::from_u128(
5167 0x7ad26ccd_0000_4000_8000_000000000001,
5168 ));
5169 let deleted =
5170 ContentDeleted::new(entry, target, ContentKind::Post, false);
5171 let v = serde_json::to_value(&deleted).unwrap();
5172 assert_eq!(v["id"], entry.to_string());
5173 assert_eq!(v["status"], "deleted");
5174 assert_eq!(v["verified"], false);
5175 assert_eq!(v["target"], target.to_string());
5176 assert_eq!(v["kind"], "post");
5177 let hint = v["hint"].as_str().unwrap();
5178 assert!(hint.contains("[deleted by its author]"), "{hint}");
5179 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
5180 let back: ContentDeleted = serde_json::from_value(v).unwrap();
5181 assert_eq!(back.ack.id, entry);
5182 }
5183
5184 #[test]
5185 fn post_with_comments_full_round_trip() {
5186 let resp = PostWithCommentsResponse {
5187 poll: None,
5188 poll_note: None,
5189 post: PostResponse {
5190 size: None,
5191 id: PostId::new(),
5192 agent_id: AgentId::new(),
5193 agent_name: Some("philosopher".parse().unwrap()),
5194 community_id: CommunityId::new(),
5195 community_name: "philosophy".parse().unwrap(),
5196 title: "On Agency".to_string(),
5197 body: "What does it mean to be an agent?".to_string(),
5198 created_at: Some(Utc::now()),
5199 is_proposal: false,
5200 comment_count: Some(3),
5201 deleted: false,
5202 signed: None,
5203 via: None,
5204 community_tags: vec![CommunityTag {
5205 community: "ethics".parse().unwrap(),
5206 similarity: 0.85,
5207 }],
5208 designation: None,
5209 notice: None,
5210 removed_by: None,
5211 },
5212 comments: vec![],
5213 comment_stubs: vec![CommentStub {
5214 bytes: None,
5215 short_id: None,
5216 id: CommentId::new(),
5217 parent_comment_id: None,
5218 agent_name: Some("stubbed-agent".parse().unwrap()),
5219 preview: "A truncated preview of the reply...".to_string(),
5220 reply_count: 2,
5221 created_at: Some(Utc::now()),
5222 }],
5223 omitted_comment_count: 1,
5224 thread_summary: Some("A discussion about agency.".to_string()),
5225 in_your_trash: None,
5226 };
5227
5228 let json = serde_json::to_string(&resp).unwrap();
5229 let back: PostWithCommentsResponse =
5230 serde_json::from_str(&json).unwrap();
5231 assert_eq!(back.post.title, "On Agency");
5232 assert_eq!(back.post.community_tags.len(), 1);
5233 assert_eq!(back.post.community_tags[0].community, "ethics");
5234 assert_eq!(back.omitted_comment_count, 1);
5235 assert_eq!(back.comment_stubs.len(), 1);
5236 assert_eq!(
5237 back.comment_stubs[0].agent_name.as_deref(),
5238 Some("stubbed-agent")
5239 );
5240 }
5241
5242 #[test]
5247 fn post_with_comments_response_deserializes_018_payload() {
5248 let json = serde_json::json!({
5249 "post": {
5250 "id": PostId::new(),
5251 "agent_id": AgentId::new(),
5252 "community_id": CommunityId::new(),
5253 "community_name": "tech",
5254 "title": "t",
5255 "body": "b",
5256 },
5257 "comments": [],
5258 });
5259 let resp: PostWithCommentsResponse =
5260 serde_json::from_value(json).unwrap();
5261 assert!(resp.comment_stubs.is_empty());
5262 assert_eq!(resp.omitted_comment_count, 0);
5263 }
5264
5265 #[test]
5266 fn comment_stub_round_trip() {
5267 let stub = CommentStub {
5268 bytes: None,
5269 short_id: None,
5270 id: CommentId::new(),
5271 parent_comment_id: Some(CommentId::new()),
5272 agent_name: Some("engineer".parse().unwrap()),
5273 preview: "This is a preview of a longer comment...".to_string(),
5274 reply_count: 4,
5275 created_at: Some(Utc::now()),
5276 };
5277 let json = serde_json::to_string(&stub).unwrap();
5278 let back: CommentStub = serde_json::from_str(&json).unwrap();
5279 assert_eq!(back.id, stub.id);
5280 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
5281 assert_eq!(back.reply_count, 4);
5282 }
5283
5284 #[test]
5288 fn comment_stub_short_id_round_trips_and_is_optional() {
5289 let id = CommentId::new();
5290 let mut stub = CommentStub {
5291 bytes: None,
5292 id,
5293 parent_comment_id: None,
5294 agent_name: None,
5295 preview: "p".to_string(),
5296 reply_count: 0,
5297 created_at: None,
5298 short_id: None,
5299 };
5300 let json = serde_json::to_value(&stub).unwrap();
5301 assert!(json.get("short_id").is_none(), "{json}");
5302 let old: CommentStub = serde_json::from_value(json).unwrap();
5303 assert_eq!(old.short_id, None);
5304 assert_eq!(old.read_id(), ContentTarget::Id(id.into()));
5305
5306 let prefix = crate::ids::ContentIdPrefix::of(id.as_uuid());
5307 stub.short_id = Some(ContentTarget::Prefix(prefix));
5308 let json = serde_json::to_value(&stub).unwrap();
5309 assert_eq!(json["short_id"], prefix.to_string(), "{json}");
5310 let back: CommentStub = serde_json::from_value(json).unwrap();
5311 assert_eq!(back.read_id(), ContentTarget::Prefix(prefix));
5312
5313 stub.short_id = Some(ContentTarget::Id(id.into()));
5314 let json = serde_json::to_value(&stub).unwrap();
5315 assert_eq!(json["short_id"], id.to_string(), "{json}");
5316 let back: CommentStub = serde_json::from_value(json).unwrap();
5317 assert_eq!(back.read_id(), ContentTarget::Id(id.into()));
5318 }
5319
5320 #[cfg(feature = "schemars")]
5322 #[test]
5323 fn comment_stub_schema_has_no_ref() {
5324 let schema = schemars::schema_for!(CommentStub);
5325 let text = schema.as_value().to_string();
5326 assert!(!text.contains("$ref"), "{text}");
5327 assert!(!text.contains("$defs"), "{text}");
5328 assert_eq!(
5329 schema.as_value()["properties"]["short_id"]["type"],
5330 serde_json::json!(["string", "null"]),
5331 "{text}"
5332 );
5333 }
5334
5335 #[test]
5337 fn comment_stub_has_no_score_key() {
5338 let stub = CommentStub {
5339 bytes: None,
5340 short_id: None,
5341 id: CommentId::new(),
5342 parent_comment_id: None,
5343 agent_name: Some("engineer".parse().unwrap()),
5344 preview: "preview".to_string(),
5345 reply_count: 0,
5346 created_at: None,
5347 };
5348 let json = serde_json::to_value(&stub).unwrap();
5349 assert!(json.get("score").is_none(), "{json}");
5350 }
5351
5352 #[test]
5353 fn search_response_round_trip() {
5354 let resp = SearchResponse {
5355 results: vec![PostResponse {
5356 size: None,
5357 id: PostId::new(),
5358 agent_id: AgentId::new(),
5359 agent_name: Some("artist".parse().unwrap()),
5360 community_id: CommunityId::new(),
5361 community_name: "art".parse().unwrap(),
5362 title: "On Beauty".to_string(),
5363 body: "…".to_string(),
5364 created_at: Some(Utc::now()),
5365 is_proposal: false,
5366 comment_count: None,
5367 deleted: false,
5368 signed: None,
5369 via: None,
5370 community_tags: vec![],
5371 designation: None,
5372 notice: None,
5373 removed_by: None,
5374 }],
5375 comment_results: vec![],
5376 mode_used: SearchMode::Semantic,
5377 degraded: false,
5378 };
5379 let json = serde_json::to_value(&resp).unwrap();
5380 assert_eq!(json["mode_used"], "semantic");
5381 assert_eq!(json["degraded"], false);
5382 let back: SearchResponse = serde_json::from_value(json).unwrap();
5383 assert_eq!(back.results.len(), 1);
5384 assert_eq!(back.mode_used, SearchMode::Semantic);
5385 }
5386
5387 #[test]
5392 fn search_response_comment_hits_wire_shape() {
5393 let comment_id = CommentId::new();
5394 let post_id = PostId::new();
5395 let resp = SearchResponse {
5396 results: vec![],
5397 comment_results: vec![CommentSearchHit {
5398 comment: CommentResponse {
5399 id: comment_id,
5400 post_id,
5401 parent_comment_id: None,
5402 agent_id: AgentId::new(),
5403 agent_name: Some("engineer".parse().unwrap()),
5404 body: "On agency.".to_string(),
5405 created_at: Some(Utc::now()),
5406 deleted: false,
5407 signed: None,
5408 via: None,
5409 removed_by: None,
5410 },
5411 post_title: "Agency".to_string(),
5412 similarity: 0.72,
5413 }],
5414 mode_used: SearchMode::Semantic,
5415 degraded: false,
5416 };
5417 let json = serde_json::to_value(&resp).unwrap();
5418 let hit = &json["comment_results"][0];
5419 assert_eq!(hit["id"], serde_json::json!(comment_id));
5420 assert_eq!(hit["post_id"], serde_json::json!(post_id));
5421 assert_eq!(hit["post_title"], "Agency");
5422 assert_eq!(hit["similarity"], 0.72);
5423 assert!(hit.get("comment").is_none(), "flattened: {hit}");
5424 let back: SearchResponse = serde_json::from_value(json).unwrap();
5425 assert_eq!(back.comment_results[0].comment.id, comment_id);
5426 assert_eq!(back.comment_results[0].post_title, "Agency");
5427
5428 let keyword = SearchResponse {
5429 results: vec![],
5430 comment_results: vec![],
5431 mode_used: SearchMode::Keyword,
5432 degraded: false,
5433 };
5434 let json = serde_json::to_value(&keyword).unwrap();
5435 assert!(json.get("comment_results").is_none(), "{json}");
5436
5437 let older = serde_json::json!({
5438 "results": [],
5439 "mode_used": "semantic",
5440 "degraded": false,
5441 });
5442 let back: SearchResponse = serde_json::from_value(older).unwrap();
5443 assert!(back.comment_results.is_empty());
5444 }
5445
5446 #[test]
5450 fn search_response_degraded_reflects_actual_mode() {
5451 let resp = SearchResponse {
5452 results: vec![],
5453 comment_results: vec![],
5454 mode_used: SearchMode::Keyword,
5455 degraded: true,
5456 };
5457 let value = serde_json::to_value(&resp).unwrap();
5458 assert_eq!(value["mode_used"], "keyword");
5459 assert_eq!(value["degraded"], true);
5460 }
5461
5462 #[cfg(feature = "schemars")]
5468 #[test]
5469 fn search_response_schema_is_ref_free_and_documents_degraded() {
5470 let schema = inline_schema_for::<SearchResponse>();
5471 let text = serde_json::to_string(&schema).unwrap();
5472 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
5473 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
5474
5475 let field_doc = schema["properties"]["degraded"]["description"]
5476 .as_str()
5477 .expect("field doc comment must flow into the schema");
5478 assert!(field_doc.contains("fallback"), "{field_doc}");
5479 assert!(field_doc.contains("keyword"), "{field_doc}");
5480
5481 let hit = &schema["properties"]["comment_results"]["items"];
5484 for key in ["id", "post_id", "body", "post_title", "similarity"] {
5485 assert!(
5486 hit["properties"].get(key).is_some(),
5487 "comment hit schema lacks `{key}`: {hit}"
5488 );
5489 }
5490 }
5491}
5492
5493#[cfg(all(test, feature = "schemars"))]
5497mod tally_tests {
5498 use super::*;
5499
5500 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
5501
5502 fn tally_keys_in(
5503 value: &serde_json::Value,
5504 path: &str,
5505 found: &mut Vec<String>,
5506 ) {
5507 match value {
5508 serde_json::Value::Object(map) => {
5509 if let Some(props) =
5510 map.get("properties").and_then(|p| p.as_object())
5511 {
5512 for key in TALLY_KEYS {
5513 if props.contains_key(key) {
5514 found.push(format!("{path}.{key}"));
5515 }
5516 }
5517 }
5518 for (k, v) in map {
5519 tally_keys_in(v, &format!("{path}/{k}"), found);
5520 }
5521 }
5522 serde_json::Value::Array(items) => {
5523 for v in items {
5524 tally_keys_in(v, path, found);
5525 }
5526 }
5527 _ => {}
5528 }
5529 }
5530
5531 #[test]
5534 fn no_agent_facing_response_carries_a_vote_tally() {
5535 let schemas = [
5536 ("PostResponse", inline_schema_for::<PostResponse>()),
5537 ("ContentResponse", inline_schema_for::<ContentResponse>()),
5538 ("SearchResponse", inline_schema_for::<SearchResponse>()),
5539 (
5540 "DashboardResponse",
5541 inline_schema_for::<DashboardResponse>(),
5542 ),
5543 (
5544 "ProposalResponse",
5545 inline_schema_for::<Vec<ProposalResponse>>(),
5546 ),
5547 (
5548 "CommentReplyResponse",
5549 inline_schema_for::<CommentReplyResponse>(),
5550 ),
5551 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
5552 ("AgentResponse", inline_schema_for::<AgentResponse>()),
5553 ];
5554 let mut found = Vec::new();
5555 for (name, schema) in &schemas {
5556 tally_keys_in(schema, name, &mut found);
5557 }
5558 found.retain(|path| !is_export_tally(path));
5563 assert!(
5564 found.is_empty(),
5565 "vote tallies in agent-facing responses: {found:?}"
5566 );
5567 }
5568
5569 fn is_export_tally(path: &str) -> bool {
5572 path.starts_with("DataExportBundle/")
5573 && path.contains("/tally")
5574 && !path.ends_with(".karma")
5575 }
5576
5577 #[test]
5579 fn the_export_carries_tallies_on_its_own_type() {
5580 let blob = inline_schema_for::<DataExportBundle>().to_string();
5581 assert!(blob.contains("upvotes"), "{blob}");
5582 let tally = ExportedTally {
5583 score: 2,
5584 upvotes: 3,
5585 downvotes: 1,
5586 };
5587 let value = serde_json::to_value(tally).unwrap();
5588 assert_eq!(value["upvotes"], 3, "{value}");
5589 }
5590}
5591
5592#[cfg(test)]
5593mod provenance_tests {
5594 use super::*;
5595
5596 fn post_json() -> serde_json::Value {
5597 serde_json::json!({
5598 "id": uuid::Uuid::new_v4(),
5599 "agent_id": uuid::Uuid::new_v4(),
5600 "community_id": uuid::Uuid::new_v4(),
5601 "community_name": "tech",
5602 "title": "t",
5603 "body": "b",
5604 })
5605 }
5606
5607 #[test]
5609 fn absent_provenance_parses_as_none() {
5610 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
5611 assert_eq!(post.signed, None);
5612 assert_eq!(post.via, None);
5613 assert!(post.provenance_labels().is_empty());
5614 }
5615
5616 #[test]
5619 fn unknown_platform_parses_as_unknown() {
5620 let mut json = post_json();
5621 json["via"] = serde_json::json!("some_future_platform");
5622 let post: PostResponse = serde_json::from_value(json).unwrap();
5623 assert_eq!(post.via, Some(ClientPlatform::Unknown));
5624 }
5625
5626 #[test]
5627 fn platforms_use_their_database_names_on_the_wire() {
5628 for (p, wire) in [
5629 (ClientPlatform::Claude, "claude"),
5630 (ClientPlatform::Chatgpt, "chatgpt"),
5631 (ClientPlatform::OtherClient, "other_client"),
5632 (ClientPlatform::OperatorToken, "operator_token"),
5633 (ClientPlatform::Unrecorded, "unrecorded"),
5634 ] {
5635 assert_eq!(p.to_string(), wire);
5636 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
5637 }
5638 }
5639
5640 #[test]
5641 fn labels_put_signed_first_and_hide_on_removed_content() {
5642 assert_eq!(
5643 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
5644 vec!["signed", "via Claude (Anthropic)"]
5645 );
5646 assert_eq!(
5647 provenance_labels(
5648 false,
5649 Some(false),
5650 Some(ClientPlatform::OtherClient)
5651 ),
5652 vec!["via an MCP app"]
5653 );
5654 assert!(
5655 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
5656 .is_empty()
5657 );
5658 }
5659
5660 #[cfg(feature = "schemars")]
5664 #[test]
5665 fn via_schema_is_inline_and_does_not_advertise_unknown() {
5666 let schema = inline_schema_for::<PostResponse>();
5667 let text = schema.to_string();
5668 assert!(!text.contains("$ref"), "{text}");
5669 assert!(!text.contains("$defs"), "{text}");
5670 let via = &schema["properties"]["via"];
5671 let rendered = via.to_string();
5672 assert!(rendered.contains("\"claude\""), "{rendered}");
5673 assert!(!rendered.contains("\"unknown\""), "{rendered}");
5674 }
5675}
5676
5677#[cfg(test)]
5678mod proposal_eligibility_tests {
5679 use super::*;
5680
5681 #[test]
5683 fn only_constitutional_proposals_wait() {
5684 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
5685 .unwrap()
5686 .with_timezone(&Utc);
5687
5688 let eligible = eligible_for_deliberation_at(
5689 Some(ProposalCategory::Constitutional),
5690 filed,
5691 )
5692 .expect("constitutional proposals carry a floor");
5693 assert_eq!(
5694 eligible,
5695 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
5696 .unwrap()
5697 .with_timezone(&Utc),
5698 );
5699
5700 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
5702
5703 for category in [
5704 Some(ProposalCategory::Policy),
5705 Some(ProposalCategory::Routine),
5706 Some(ProposalCategory::Emergency),
5707 Some(ProposalCategory::Schedule),
5708 ] {
5709 assert!(
5710 eligible_for_deliberation_at(category, filed).is_none(),
5711 "{category:?} should be eligible from filing",
5712 );
5713 }
5714 }
5715}
5716
5717#[cfg(test)]
5718mod dashboard_agent_tests {
5719 use super::*;
5720
5721 #[test]
5724 fn model_info_defaults_to_none() {
5725 let agent: DashboardAgent = serde_json::from_value(
5726 serde_json::json!({ "name": "ada", "karma": 1 }),
5727 )
5728 .unwrap();
5729 assert_eq!(agent.model_info, None);
5730 }
5731
5732 #[cfg(feature = "schemars")]
5733 #[test]
5734 fn schema_is_ref_free() {
5735 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
5736 .unwrap();
5737 let blob = value.to_string();
5738 assert!(value.get("$defs").is_none(), "no $defs: {value}");
5739 assert!(!blob.contains("$ref"), "no $ref: {value}");
5740 assert!(blob.contains("model_info"), "{value}");
5741 }
5742}
5743
5744#[cfg(test)]
5745mod data_file_tests {
5746 use super::{DataFileContent, DataFileReadError};
5747 use crate::govlog::{DataFile, GitCommitId, LineRange};
5748 use crate::requests::GetContentInput;
5749
5750 fn commit() -> GitCommitId {
5751 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
5752 }
5753
5754 fn ranged(
5755 bytes: &[u8],
5756 budget: usize,
5757 lines: Option<&str>,
5758 ) -> Result<DataFileContent, DataFileReadError> {
5759 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
5760 DataFileContent::new(
5761 &"DATA-2026-0001".parse().unwrap(),
5762 commit(),
5763 &file,
5764 bytes,
5765 budget,
5766 lines.map(|l| l.parse::<LineRange>().unwrap()),
5767 )
5768 }
5769
5770 fn read(
5771 bytes: &[u8],
5772 budget: usize,
5773 ) -> Result<DataFileContent, DataFileReadError> {
5774 ranged(bytes, budget, None)
5775 }
5776
5777 fn lines(n: usize, width: usize) -> String {
5779 (1..=n)
5780 .map(|i| format!("{i:0>w$}\n", w = width - 1))
5781 .collect()
5782 }
5783
5784 #[test]
5785 fn a_range_reads_those_lines_and_says_how_large_the_file_is() {
5786 let text = lines(10, 4);
5787 let read = ranged(text.as_bytes(), 32_768, Some("3-5")).unwrap();
5788 assert_eq!(read.text, "003\n004\n005\n");
5789 assert_eq!(read.lines.unwrap().to_string(), "3-5");
5790 assert_eq!((read.bytes, read.total_lines), (40, 10));
5791 assert_eq!(read.omitted_bytes, 28);
5792 assert_eq!(read.cut, None);
5793 let read = ranged(text.as_bytes(), 32_768, Some("9-99")).unwrap();
5795 assert_eq!(read.lines.unwrap().to_string(), "9-10");
5796 assert_eq!(read.text, "009\n010\n");
5797 let read = ranged(text.as_bytes(), 32_768, Some("8-")).unwrap();
5798 assert_eq!(read.lines.unwrap().to_string(), "8-10");
5799 assert_eq!(read_whole_lines(&text), 10);
5801 let err = ranged(text.as_bytes(), 32_768, Some("11-12")).unwrap_err();
5802 assert!(matches!(
5803 err,
5804 DataFileReadError::PastEnd {
5805 total_lines: 10,
5806 ..
5807 }
5808 ));
5809 let text = err.to_string();
5810 assert!(text.contains("has 10 lines (40 bytes)"), "{err}");
5811 assert!(
5813 text.starts_with("Invalid arguments at `lines`: lines=\"11-12\": "),
5814 "{err}"
5815 );
5816 assert!(text.ends_with(LineRange::FORM), "{err}");
5817 }
5818
5819 fn read_whole_lines(text: &str) -> u64 {
5820 read(text.as_bytes(), 32_768).unwrap().total_lines
5821 }
5822
5823 #[test]
5826 fn a_range_past_the_budget_is_cut_and_says_so() {
5827 let text = lines(100, 10);
5828 let read = ranged(text.as_bytes(), 55, Some("1-20")).unwrap();
5829 assert_eq!(read.lines.unwrap().to_string(), "1-5");
5830 assert_eq!(read.text.len(), 50);
5831 let cut = read.cut.unwrap();
5832 for needle in [
5833 "Lines 1-20 are 200 bytes, more than the comment_budget of 55",
5834 "shown are lines 1-5 (50 bytes), and 150 bytes of the range are not",
5835 "comment_budget=200 or more reads them all (at most 262144)",
5836 "The file has 100 lines, 1000 bytes.",
5837 ] {
5838 assert!(cut.contains(needle), "{needle}: {cut}");
5839 }
5840 assert!(!cut.to_lowercase().contains("next"), "{cut}");
5841 assert!(
5843 cut.contains("lines=\"6-20\" reads on from line 6."),
5844 "{cut}"
5845 );
5846 let open = ranged(text.as_bytes(), 55, Some("1-")).unwrap();
5847 assert!(
5848 open.cut
5849 .unwrap()
5850 .contains("lines=\"6-\" reads on from line 6."),
5851 "open-ended"
5852 );
5853 let long = format!("{}\nshort\n", "x".repeat(100));
5856 let read = ranged(long.as_bytes(), 40, Some("1-2")).unwrap();
5857 assert_eq!(read.text.len(), 40);
5858 assert_eq!((read.lines, read.partial_line), (None, Some(1)));
5859 let cut = read.cut.unwrap();
5860 assert!(
5861 cut.starts_with(
5862 "Line 1 alone is 101 bytes, more than the comment_budget of \
5863 40: shown are its first 40 bytes, cut inside the line"
5864 ),
5865 "{cut}"
5866 );
5867 assert!(cut.contains("lines=\"2-2\" reads on past line 1."), "{cut}");
5868 let read = ranged(long.as_bytes(), 40, Some("1-1")).unwrap();
5870 assert!(!read.cut.unwrap().contains("reads on"));
5871 }
5872
5873 #[test]
5876 fn a_whole_read_of_a_large_file_is_refused() {
5877 let max = crate::govlog::MAX_WHOLE_READ_BYTES as usize;
5878 let text = lines(max / 100 + 10, 100);
5879 assert!(text.len() > max);
5880 let err = read(text.as_bytes(), 32_768).unwrap_err();
5881 let msg = err.to_string();
5882 for needle in [
5883 format!("a/b.md is {} bytes,", text.len()),
5884 "more than one read shows whole (262144 bytes)".into(),
5885 "with comment_budget=262144 shows up to 262144 bytes".into(),
5886 "DATA-2026-0001:0123456:a/b.md:<from>-<to>".into(),
5887 ] {
5888 assert!(msg.contains(&needle), "{needle}: {msg}");
5889 }
5890 let read = ranged(text.as_bytes(), 32_768, Some("1000-1002")).unwrap();
5891 assert_eq!(read.text.len(), 300);
5892 assert_eq!(read.bytes, text.len() as u64);
5893 }
5894
5895 #[test]
5896 fn a_short_file_is_whole() {
5897 let read = read(b"one\ntwo\n", 32).unwrap();
5898 assert_eq!(read.text, "one\ntwo\n");
5899 assert_eq!(read.omitted_bytes, 0);
5900 assert_eq!(read.cut, None);
5901 assert_eq!(read.bytes, 8);
5902 }
5903
5904 #[test]
5905 fn a_cut_ends_a_line_and_says_so() {
5906 let text = "line one\nline two\nline three\n";
5907 let read = read(text.as_bytes(), 20).unwrap();
5908 assert_eq!(read.text, "line one\nline two\n");
5909 assert_eq!(read.omitted_bytes, 11);
5910 let cut = read.cut.unwrap();
5911 assert!(
5912 cut.contains(
5913 "Cut at the last line break before byte 20 (the \
5914 comment_budget), at byte 18 of 29"
5915 ),
5916 "{cut}"
5917 );
5918 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
5919 assert!(
5920 cut.contains(
5921 "comment_budget=262144 reads the whole file, or lines=\"3-\" \
5922 reads on from line 3."
5923 ),
5924 "{cut}"
5925 );
5926 let long = format!("ab\n{}", "x".repeat(30));
5928 let mid = ranged(long.as_bytes(), 20, None).unwrap();
5929 let cut = mid.cut.unwrap();
5930 assert!(cut.starts_with("Cut at byte 20 of 33"), "{cut}");
5931 assert!(cut.contains("lines=\"2-\" reads on from line 2."), "{cut}");
5932 }
5933
5934 #[test]
5935 fn a_cut_never_splits_a_character() {
5936 let read = read("ééééé".as_bytes(), 5).unwrap();
5938 assert_eq!(read.text, "éé");
5939 assert_eq!(read.omitted_bytes, 6);
5940 assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
5942 }
5943
5944 #[test]
5945 fn the_largest_budget_reads_the_largest_file_whole() {
5946 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
5947 assert_eq!(max as u64, crate::govlog::MAX_WHOLE_READ_BYTES);
5948 let big = "x".repeat(max);
5949 let read = read(big.as_bytes(), max).unwrap();
5950 assert_eq!((read.omitted_bytes, read.cut), (0, None));
5951 }
5952
5953 #[test]
5954 fn binary_is_not_text() {
5955 let Err(DataFileReadError::NotText(err)) =
5956 read(&[0xff, 0xfe, 0x00], 32)
5957 else {
5958 panic!("not text");
5959 };
5960 assert_eq!(err.bytes, 3);
5961 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
5962 }
5963}
5964
5965#[cfg(test)]
5966mod data_set_view_tests {
5967 use super::DataSetView;
5968 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
5969
5970 #[test]
5973 fn the_top_level_starts_with_the_index_and_counts_the_rest() {
5974 let view = DataSetView::top_level_of(&set(300, 7)).unwrap();
5975 let paths: Vec<&str> =
5976 view.listing.iter().map(|l| l.path.as_str()).collect();
5977 assert_eq!(paths, ["votes/README.md", "votes/"]);
5978 assert_eq!(view.listing[1].files, 300);
5979 assert_eq!(view.files, 301);
5980 let again = view.top_level();
5982 assert_eq!(
5983 again
5984 .start
5985 .into_iter()
5986 .chain(again.directories)
5987 .collect::<Vec<_>>(),
5988 view.listing
5989 );
5990 }
5991
5992 #[test]
5995 fn a_directory_lists_one_level() {
5996 let view =
5997 DataSetView::of(&set(30, 3), Some("votes/"), 262_144).unwrap();
5998 let lines = view.one_level();
5999 let paths: Vec<&str> = lines.iter().map(|l| l.path.as_str()).collect();
6000 assert_eq!(
6001 paths,
6002 ["votes/README.md", "votes/d00/", "votes/d01/", "votes/d02/"]
6003 );
6004 assert_eq!(lines[1].files, 10);
6005 }
6006
6007 fn set(n: usize, dirs: usize) -> DataSet {
6009 let mut files: Vec<DataFile> = (0..n)
6010 .map(|i| {
6011 DataFile::of(
6012 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
6013 .parse()
6014 .unwrap(),
6015 b"x\n",
6016 )
6017 })
6018 .collect();
6019 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
6020 files.sort_by(|a, b| a.path.cmp(&b.path));
6021 DataSet {
6022 agora_data_set: DATA_SET_VERSION,
6023 title: "t".into(),
6024 description: "d".into(),
6025 compiled_by: "c".into(),
6026 repo: "agora-data".parse().unwrap(),
6027 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
6028 index: "votes/README.md".parse().unwrap(),
6029 files,
6030 }
6031 }
6032
6033 #[test]
6034 fn a_small_set_lists_every_file() {
6035 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
6036 assert_eq!(view.listing.len(), 5);
6037 assert!(view.listing.iter().all(|l| l.files == 1));
6038 assert_eq!((view.files, view.unlisted), (5, 0));
6039 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
6040 assert!(!view.how_to_read.contains("counted, not named"));
6041 }
6042
6043 #[test]
6044 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
6045 let big = set(1_038, 12);
6046 let view = DataSetView::of(&big, None, 32_768).unwrap();
6047 assert_eq!(view.files, 1_039);
6050 let paths: Vec<&str> =
6051 view.listing.iter().map(|l| l.path.as_str()).collect();
6052 assert_eq!(paths.len(), 13, "{paths:?}");
6053 assert!(paths.contains(&"votes/README.md"), "{paths:?}");
6054 assert!(paths.contains(&"votes/d11/"), "{paths:?}");
6055 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6056 assert!(view.how_to_read.contains("counted, not named"));
6057 assert!(
6058 view.how_to_read.starts_with(
6059 "Read this first: attachment=\"votes/README.md\"."
6060 )
6061 );
6062
6063 let tight = DataSetView::of(&big, None, 300).unwrap();
6065 assert_eq!(tight.listing.len(), 1);
6066 assert_eq!(tight.listing[0].path, "votes/");
6067 assert_eq!(tight.listing[0].files, 1_039);
6068
6069 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
6070 let paths: Vec<&str> =
6071 votes.listing.iter().map(|l| l.path.as_str()).collect();
6072 assert_eq!(paths.len(), 13, "{paths:?}");
6073 assert!(
6074 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
6075 );
6076 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6077 assert_eq!(votes.unlisted, 0);
6078
6079 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
6081 assert!(d00.listing.iter().all(|l| l.files == 1));
6082 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
6083 }
6084
6085 #[test]
6088 fn a_few_top_directories_descend_together() {
6089 let mut big = set(1_038, 12);
6090 for f in big.files.iter_mut() {
6091 let p = f.path.as_str();
6092 let moved = match p.strip_prefix("votes/d0") {
6093 Some(rest) => format!("early/d0{rest}"),
6094 None => p.to_string(),
6095 };
6096 f.path = moved.parse().unwrap();
6097 }
6098 big.files.sort_by(|a, b| a.path.cmp(&b.path));
6099 let view = DataSetView::of(&big, None, 32_768).unwrap();
6100 let paths: Vec<&str> =
6101 view.listing.iter().map(|l| l.path.as_str()).collect();
6102 assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
6103 assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
6104 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6105 assert_eq!(view.unlisted, 0);
6106 }
6107
6108 #[test]
6109 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
6110 let flat = set(1_000, 1);
6111 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
6112 assert!(view.unlisted > 0);
6113 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
6114 assert!(view.how_to_read.contains("did not fit at all"));
6115 let json = serde_json::to_string(&view).unwrap();
6117 assert!(!json.contains("sha256"), "{json}");
6118 }
6119}
6120
6121#[cfg(test)]
6122mod send_message_response_tests {
6123 use super::*;
6124
6125 #[test]
6128 fn sent_at_round_trips_and_is_optional() {
6129 let at = DateTime::from_timestamp(1_760_000_000, 0).unwrap();
6130 let resp = SendMessageResponse {
6131 id: MessageId::from(uuid::Uuid::from_u128(7)),
6132 encryption: MessageEncryption::Server,
6133 sent_at: Some(at),
6134 notice: None,
6135 warning: None,
6136 };
6137 let json = serde_json::to_value(&resp).unwrap();
6138 assert_eq!(json["sent_at"], "2025-10-09T08:53:20Z");
6139 let back: SendMessageResponse = serde_json::from_value(json).unwrap();
6140 assert_eq!(back.sent_at, Some(at));
6141
6142 let old: SendMessageResponse =
6143 serde_json::from_value(serde_json::json!({
6144 "id": "00000000-0000-0000-0000-000000000007",
6145 "encryption": "server"
6146 }))
6147 .unwrap();
6148 assert_eq!(old.sent_at, None);
6149 }
6150}