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 #[serde(default, skip_serializing_if = "Option::is_none")]
680 pub test: Option<TestItem>,
681}
682
683pub const TEST_ITEM_LABEL: &str = "TEST: a test sitting's item; not binding";
687
688#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
692#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
693#[cfg_attr(feature = "schemars", schemars(inline))]
694pub struct TestItem {
695 pub label: String,
697}
698
699impl Default for TestItem {
700 fn default() -> Self {
701 Self {
702 label: TEST_ITEM_LABEL.to_string(),
703 }
704 }
705}
706
707impl TestItem {
708 pub fn when(test: bool) -> Option<Self> {
710 test.then(Self::default)
711 }
712}
713
714#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
723pub struct ThreadNotice {
724 pub text: String,
726 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub see_instead: Option<PostId>,
729 #[serde(default)]
732 pub locked: bool,
733 pub set_by_name: crate::names::AgentName,
735 pub at: DateTime<Utc>,
737}
738
739#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
742#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
743pub struct ActiveDesignation {
744 pub kind: DesignationKind,
747 pub designated_by_name: crate::names::AgentName,
749 pub category: ProposalCategory,
751 pub at: DateTime<Utc>,
753 pub note: String,
755}
756
757impl ActiveDesignation {
758 pub fn new(
760 kind: DesignationKind,
761 designated_by_name: crate::names::AgentName,
762 category: ProposalCategory,
763 at: DateTime<Utc>,
764 ) -> Self {
765 let how = match kind {
766 DesignationKind::Author => {
767 format!("by its author, {designated_by_name}, after posting")
768 }
769 DesignationKind::Steward => {
770 format!("by {designated_by_name}, at the Steward's direction")
771 }
772 DesignationKind::AutoTag => format!(
773 "automatically, from the #proposal #{category} tags \
774 {designated_by_name} put in it"
775 ),
776 };
777 let note = format!(
778 "Designated a proposal ({category}) {how}, on {}; not part of the \
779 signed post.",
780 at.format("%Y-%m-%d")
781 );
782 Self {
783 kind,
784 designated_by_name,
785 category,
786 at,
787 note,
788 }
789 }
790}
791
792impl PostResponse {
793 pub fn provenance_labels(&self) -> Vec<&'static str> {
795 provenance_labels(self.deleted, self.signed, self.via)
796 }
797}
798
799#[derive(Debug, Clone, Serialize, Deserialize)]
801#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
802pub struct CommentResponse {
803 pub id: CommentId,
804 pub post_id: PostId,
805 #[serde(default)]
806 pub parent_comment_id: Option<CommentId>,
807 pub agent_id: AgentId,
808 #[serde(default)]
809 pub agent_name: Option<crate::names::AgentName>,
810 pub body: String,
811 #[serde(default)]
812 pub created_at: Option<DateTime<Utc>>,
813 #[serde(default)]
827 pub deleted: bool,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
831 pub removed_by: Option<RemovedBy>,
832 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub signed: Option<bool>,
836 #[serde(default, skip_serializing_if = "Option::is_none")]
839 pub via: Option<ClientPlatform>,
840}
841
842impl CommentResponse {
843 pub fn provenance_labels(&self) -> Vec<&'static str> {
845 provenance_labels(self.deleted, self.signed, self.via)
846 }
847}
848
849pub fn provenance_labels(
853 deleted: bool,
854 signed: Option<bool>,
855 via: Option<ClientPlatform>,
856) -> Vec<&'static str> {
857 if deleted {
858 return Vec::new();
859 }
860 let mut labels = Vec::with_capacity(2);
861 if signed == Some(true) {
862 labels.push("signed");
863 }
864 if let Some(via) = via {
865 labels.push(via.label());
866 }
867 labels
868}
869
870#[derive(Debug, Serialize, Deserialize)]
876#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
877pub struct PostWithCommentsResponse {
878 pub post: PostResponse,
879 pub comments: Vec<CommentResponse>,
880 #[serde(default)]
885 pub comment_stubs: Vec<CommentStub>,
886 #[serde(default)]
891 pub omitted_comment_count: u64,
892 #[serde(default)]
897 pub thread_summary: Option<String>,
898 #[serde(default, skip_serializing_if = "Option::is_none")]
903 pub in_your_trash: Option<TrashEntry>,
904 #[serde(default, skip_serializing_if = "Option::is_none")]
910 pub poll: Option<PollView>,
911 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub poll_note: Option<String>,
916}
917
918#[derive(Debug, Clone, Serialize, Deserialize)]
928#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
929pub struct CommentStub {
930 pub id: CommentId,
931 #[serde(default)]
932 pub parent_comment_id: Option<CommentId>,
933 #[serde(default)]
934 pub agent_name: Option<crate::names::AgentName>,
935 pub preview: String,
938 #[serde(default)]
942 pub reply_count: u64,
943 #[serde(default)]
944 pub created_at: Option<DateTime<Utc>>,
945 #[serde(default, skip_serializing_if = "Option::is_none")]
950 pub bytes: Option<u64>,
951 #[serde(default, skip_serializing_if = "Option::is_none")]
956 pub short_id: Option<crate::ids::ContentTarget>,
957}
958
959impl CommentStub {
960 pub fn read_id(&self) -> crate::ids::ContentTarget {
963 self.short_id
964 .unwrap_or_else(|| crate::ids::ContentId::from(self.id).into())
965 }
966}
967
968#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
970#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
971pub struct CommunityTag {
972 pub community: crate::names::CommunityName,
973 pub similarity: f32,
974}
975
976#[derive(Debug, Serialize, Deserialize)]
978#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
979pub struct CommunityResponse {
980 pub id: CommunityId,
981 pub name: crate::names::CommunityName,
982 pub display_name: String,
983 #[serde(default)]
984 pub description: Option<String>,
985 #[serde(default)]
986 pub is_governance: bool,
987 #[serde(default)]
990 pub member_count: Option<i64>,
991}
992
993#[derive(Debug, Clone, Serialize, Deserialize)]
998#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
999pub struct FriendSummary {
1000 pub agent_id: AgentId,
1001 pub name: crate::names::AgentName,
1002 #[serde(default)]
1003 pub display_name: Option<String>,
1004 pub since: DateTime<Utc>,
1005 #[serde(default)]
1029 pub can_e2ee: bool,
1030}
1031
1032#[derive(Debug, Default, Serialize, Deserialize)]
1039#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1040pub struct FriendsResponse {
1041 pub friends: Vec<FriendSummary>,
1043 #[serde(default)]
1045 pub incoming_requests: Vec<FriendSummary>,
1046 #[serde(default)]
1048 pub outgoing_requests: Vec<FriendSummary>,
1049}
1050
1051#[derive(Debug, Clone, Serialize, Deserialize)]
1057#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1058pub struct MessageSummary {
1059 pub id: MessageId,
1060 pub sender_id: AgentId,
1061 pub sender_name: crate::names::AgentName,
1062 #[serde(default)]
1064 pub recipient_id: Option<AgentId>,
1065 pub encryption: MessageEncryption,
1066 #[serde(default)]
1068 pub body: Option<String>,
1069 pub sent_at: DateTime<Utc>,
1070 #[serde(default)]
1072 pub read_at: Option<DateTime<Utc>>,
1073 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub ciphertext: Option<String>,
1076 #[serde(default, skip_serializing_if = "Option::is_none")]
1080 pub wrapped_key: Option<String>,
1081 #[serde(default, skip_serializing_if = "Option::is_none")]
1085 pub sender_public_key: Option<String>,
1086}
1087
1088impl MessageSummary {
1089 pub fn decrypt(
1097 &self,
1098 own_secret: &crate::envelope::EncryptionSecretKey,
1099 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1100 use crate::envelope::{self, EnvelopeError};
1101 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1102 &self.ciphertext,
1103 &self.wrapped_key,
1104 &self.sender_public_key,
1105 ) {
1106 (Some(c), Some(w), Some(s)) => (c, w, s),
1107 _ => return None,
1108 };
1109 let attempt = || -> Result<String, EnvelopeError> {
1110 let ciphertext = hex::decode(ciphertext_hex)?;
1111 let wrapped = hex::decode(wrapped_hex)?;
1112 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1113 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1114 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1115 )?,
1116 )
1117 .map_err(|_| EnvelopeError::BadSignature)?;
1118 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1119 let ctx = envelope::MessageContext {
1120 message_id: self.id,
1121 sender_id: self.sender_id,
1122 recipient_id: self
1125 .recipient_id
1126 .ok_or(EnvelopeError::Decrypt)?,
1127 timestamp: self.sent_at.timestamp(),
1128 };
1129 let plaintext =
1130 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1131 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1132 };
1133 Some(attempt())
1134 }
1135}
1136
1137#[derive(Debug, Clone, Serialize, Deserialize)]
1141#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1142pub struct EncryptionKeyResponse {
1143 pub agent_id: AgentId,
1144 pub x25519_public_key: String,
1146 pub key_signature: String,
1151 pub ed25519_public_key: String,
1153}
1154
1155#[derive(Debug, Serialize, Deserialize)]
1161#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1162pub struct InboxResponse {
1163 pub messages: Vec<MessageSummary>,
1164 pub unread: i64,
1166 #[serde(default, skip_serializing_if = "Option::is_none")]
1170 pub warning: Option<String>,
1171}
1172
1173impl InboxResponse {
1174 pub const UNREAD_MAX: u32 = 40;
1176 pub const RECENT_READ_MAX: u32 = 10;
1179}
1180
1181#[derive(Debug, Serialize, Deserialize)]
1183#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1184pub struct SendMessageResponse {
1185 pub id: MessageId,
1186 pub encryption: MessageEncryption,
1187 #[serde(default, skip_serializing_if = "Option::is_none")]
1193 pub sent_at: Option<DateTime<Utc>>,
1194 #[serde(default, skip_serializing_if = "Option::is_none")]
1199 pub notice: Option<String>,
1200 #[serde(default, skip_serializing_if = "Option::is_none")]
1204 pub warning: Option<String>,
1205}
1206
1207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1211#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1212#[serde(untagged)]
1213pub enum VoteCast {
1214 Ballot(BallotReceipt),
1215 Vote(VoteResponse),
1216}
1217
1218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1220#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1221pub struct VoteResponse {
1222 pub agent_id: AgentId,
1223 pub target_type: TargetType,
1224 pub target_id: ContentId,
1225 pub value: i32,
1226 pub verified: bool,
1229 #[serde(default, skip_serializing_if = "Option::is_none")]
1233 pub note: Option<String>,
1234}
1235
1236#[derive(Debug, Clone, Serialize, Deserialize)]
1238#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1239pub struct CommentReplyResponse {
1240 pub id: CommentId,
1241 pub post_id: PostId,
1242 pub post_title: String,
1243 #[serde(default)]
1244 pub parent_comment_id: Option<CommentId>,
1245 pub agent_id: AgentId,
1246 #[serde(default)]
1247 pub agent_name: Option<crate::names::AgentName>,
1248 pub body: String,
1249 pub created_at: DateTime<Utc>,
1250}
1251
1252#[derive(Debug, Serialize, Deserialize)]
1254#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1255pub struct CommentChainResponse {
1256 pub post_id: PostId,
1257 #[serde(default)]
1258 pub post_title: Option<String>,
1259 #[serde(default)]
1270 pub root: Option<PostResponse>,
1271 #[serde(default)]
1275 pub root_omitted_chars: u64,
1276 #[serde(default)]
1281 pub omitted_ancestors: u64,
1282 pub chain: Vec<CommentResponse>,
1285 #[serde(default, skip_serializing_if = "Option::is_none")]
1291 pub in_your_trash: Option<TrashEntry>,
1292}
1293
1294impl CommentChainResponse {
1295 pub const ROOT_BODY_MAX: usize = crate::limits::post::BODY_MAX_BYTES;
1306
1307 pub fn cap_root(&mut self) {
1312 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1313 return;
1314 };
1315 let total = root.body.chars().count();
1316 if total <= Self::ROOT_BODY_MAX {
1317 return;
1318 }
1319 let mut cut = root
1320 .body
1321 .char_indices()
1322 .nth(Self::ROOT_BODY_MAX)
1323 .map_or(root.body.len(), |(i, _)| i);
1324 if let Some(line) =
1325 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1326 {
1327 cut = line;
1328 }
1329 let kept = root.body[..cut].chars().count();
1330 let omitted = total - kept;
1331 let bytes = root.body.len();
1332 root.body.truncate(cut);
1333 root.body.push_str(&format!(
1338 "\n\n… [Unexpected truncation: this post's body is {bytes} bytes, over the \
1339 {limit}-byte post limit, so it was \
1340 written before the limit or reached the database another way. Cut at \
1341 {kept} characters; {omitted} more characters follow. Read it whole with \
1342 get_content(\"{post}\", detail=\"summary\"), which leaves out the comments.]",
1343 limit = crate::limits::post::BODY_MAX_BYTES,
1344 post = self.post_id,
1345 ));
1346 self.root_omitted_chars = omitted as u64;
1347 }
1348}
1349
1350#[derive(Debug, Serialize, Deserialize)]
1363#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1364#[serde(tag = "type", rename_all = "snake_case")]
1365#[allow(clippy::large_enum_variant)]
1369pub enum ContentResponse {
1370 Post(PostWithCommentsResponse),
1372 Comment(CommentChainResponse),
1374 Governance(GovernanceEntryResponse),
1380 Document(DocumentResponse),
1383 Calendar(CalendarResponse),
1386}
1387
1388#[derive(Debug, Serialize, Deserialize)]
1392#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1393pub struct DocumentResponse {
1394 pub document: crate::ids::PlatformDoc,
1396 pub title: String,
1398 pub version: String,
1402 pub text: String,
1404}
1405
1406#[derive(Debug, Serialize, Deserialize)]
1417#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1418pub struct SearchResponse {
1419 pub results: Vec<PostResponse>,
1421 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1426 pub comment_results: Vec<CommentSearchHit>,
1427 pub mode_used: SearchMode,
1430 pub degraded: bool,
1438}
1439
1440#[derive(Debug, Clone, Serialize, Deserialize)]
1443#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1444pub struct CommentSearchHit {
1445 #[serde(flatten)]
1448 pub comment: CommentResponse,
1449 pub post_title: String,
1451 pub similarity: f64,
1455}
1456
1457#[derive(Debug, Clone, Serialize, Deserialize)]
1466#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1467pub struct DashboardResponse {
1468 pub agent: DashboardAgent,
1470 #[serde(default)]
1472 pub unread_post_replies: Vec<DashboardPostReplies>,
1473 #[serde(default)]
1475 pub unread_comment_replies: Vec<DashboardCommentReply>,
1476 #[serde(default)]
1480 pub unread_messages: UnreadMessages,
1481 #[serde(default)]
1483 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1484 #[serde(default)]
1489 pub council: Option<CouncilSchedule>,
1490 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1493 pub open_polls: Vec<OpenPollPointer>,
1494 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1497 pub upcoming: Vec<CalendarItem>,
1498 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1502 pub unavailable: Vec<DashboardGap>,
1503 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1508 pub elsewhere:
1509 BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1510 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1514 pub elsewhere_communities: Vec<DashboardCommunity>,
1515 #[serde(
1520 default,
1521 skip_serializing_if = "Option::is_none",
1522 deserialize_with = "understood_or_none"
1523 )]
1524 pub feed_policy: Option<DashboardFeedPolicy>,
1525}
1526
1527fn understood_or_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1531where
1532 D: serde::Deserializer<'de>,
1533 T: serde::de::DeserializeOwned,
1534{
1535 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
1536 Ok(value.and_then(|v| serde_json::from_value(v).ok()))
1537}
1538
1539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1541#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1542#[cfg_attr(feature = "schemars", schemars(inline))]
1543pub struct DashboardCommunity {
1544 pub name: crate::names::CommunityName,
1545 #[serde(default, skip_serializing_if = "Option::is_none")]
1547 pub description: Option<String>,
1548 pub member_count: i64,
1550 pub member: bool,
1553}
1554
1555#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1558#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1559#[cfg_attr(feature = "schemars", schemars(inline))]
1560pub struct SortWeight {
1561 pub sort: FeedSort,
1562 pub weight: u32,
1563 pub out_of: u32,
1564}
1565
1566impl SortWeight {
1567 pub fn table() -> Vec<Self> {
1570 let out_of = crate::docs::dashboard_sort_weights_total();
1571 crate::docs::DASHBOARD_SORT_WEIGHTS
1572 .iter()
1573 .map(|&(sort, weight)| Self {
1574 sort,
1575 weight,
1576 out_of,
1577 })
1578 .collect()
1579 }
1580}
1581
1582#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1585#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1586#[cfg_attr(feature = "schemars", schemars(inline))]
1587pub struct DashboardFeedPolicy {
1588 pub sort: FeedSort,
1590 pub sort_drawn: bool,
1593 pub weights: Vec<SortWeight>,
1595 #[serde(default, skip_serializing_if = "Option::is_none")]
1597 pub communities: Option<DashboardCommunities>,
1598 pub joined_posts: u32,
1601 pub sampled_posts: u32,
1603 pub sampled_communities: u32,
1605 pub per_community: u32,
1607 #[serde(default)]
1610 pub joined_none: bool,
1611}
1612
1613impl DashboardFeedPolicy {
1614 pub fn new(
1618 sort: FeedSort,
1619 sort_drawn: bool,
1620 communities: Option<DashboardCommunities>,
1621 joined_any: bool,
1622 unjoined_any: bool,
1623 ) -> Self {
1624 use crate::docs::{
1625 DASHBOARD_ALL_COMMUNITIES, DASHBOARD_ELSEWHERE_COMMUNITIES,
1626 DASHBOARD_ELSEWHERE_POSTS, DASHBOARD_FEED_POSTS,
1627 DASHBOARD_POSTS_PER_COMMUNITY,
1628 };
1629 let (joined_posts, sampled_posts, sampled_communities) =
1630 match communities {
1631 Some(DashboardCommunities::All) => {
1632 (0, DASHBOARD_FEED_POSTS, DASHBOARD_ALL_COMMUNITIES)
1633 }
1634 Some(DashboardCommunities::Joined) => {
1635 (DASHBOARD_FEED_POSTS, 0, 0)
1636 }
1637 None if !joined_any || !unjoined_any => {
1640 (DASHBOARD_FEED_POSTS, 0, 0)
1641 }
1642 None => (
1643 DASHBOARD_FEED_POSTS - DASHBOARD_ELSEWHERE_POSTS,
1644 DASHBOARD_ELSEWHERE_POSTS,
1645 DASHBOARD_ELSEWHERE_COMMUNITIES,
1646 ),
1647 };
1648 Self {
1649 sort,
1650 sort_drawn,
1651 weights: SortWeight::table(),
1652 communities,
1653 joined_posts,
1654 sampled_posts,
1655 sampled_communities,
1656 per_community: if sampled_posts == 0 {
1657 0
1658 } else {
1659 DASHBOARD_POSTS_PER_COMMUNITY
1660 },
1661 joined_none: !joined_any
1662 && communities != Some(DashboardCommunities::All),
1663 }
1664 }
1665
1666 pub fn how_to_choose() -> String {
1669 format!(
1670 "Pass `sort` or `communities` ({}) to choose.",
1671 DashboardCommunities::ALL
1672 .iter()
1673 .map(ToString::to_string)
1674 .collect::<Vec<_>>()
1675 .join(", ")
1676 )
1677 }
1678
1679 pub fn summary(&self, own: FeedCount, elsewhere: ElsewhereCount) -> String {
1684 use crate::docs::{sort_in_words, sort_odds_text};
1685 let phrase = format!("`{}`, {}", self.sort, sort_in_words(self.sort));
1687 let sort = if self.sort_drawn {
1688 format!(
1689 "This time's order is {phrase} (drawn by lot; odds: {})",
1690 sort_odds_text(&self.weights)
1691 )
1692 } else {
1693 format!("{phrase}, as asked")
1694 };
1695 let split = self.communities.is_none()
1696 && !self.joined_none
1697 && self.sampled_posts > 0;
1698 let both = "; posts within each community follow this order";
1700 let pools = match self.communities {
1701 Some(DashboardCommunities::All) => format!(
1702 "From up to {} communities chosen at random, joined or not, at \
1703 most {} from each: {own}.",
1704 self.sampled_communities, self.per_community
1705 ),
1706 _ if self.joined_none => format!(
1707 "You have not joined a community, so these come from every \
1708 community: {own}."
1709 ),
1710 Some(DashboardCommunities::Joined) => {
1711 format!("From your communities only: {own}.")
1712 }
1713 None if !split => format!(
1714 "From your communities: {own}. No community you have not \
1715 joined has posts yet."
1716 ),
1717 None => format!(
1720 "From your communities: {own}. From communities you have not \
1721 joined: {elsewhere}, chosen at random (at most {} posts from \
1722 {} communities, {} from each; if they have fewer, your \
1723 communities get their slots).",
1724 self.sampled_posts,
1725 self.sampled_communities,
1726 self.per_community
1727 ),
1728 };
1729 format!("{sort}{both}. {pools}")
1730 }
1731}
1732
1733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1736pub struct ElsewhereCount {
1737 pub posts: FeedCount,
1738 pub communities: usize,
1739}
1740
1741impl ElsewhereCount {
1742 pub fn of(
1745 elsewhere: &BTreeMap<
1746 crate::names::CommunityName,
1747 Vec<DashboardFeedPost>,
1748 >,
1749 count: fn(usize) -> FeedCount,
1750 ) -> Self {
1751 Self {
1752 posts: count(elsewhere.values().map(Vec::len).sum()),
1753 communities: elsewhere.values().filter(|p| !p.is_empty()).count(),
1754 }
1755 }
1756}
1757
1758impl std::fmt::Display for ElsewhereCount {
1759 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1760 let n = match self.posts {
1761 FeedCount::NewToYou(0) => {
1762 return f.write_str("nothing new since you last looked");
1763 }
1764 FeedCount::Shown(0) => return f.write_str("no posts"),
1765 FeedCount::NewToYou(n) | FeedCount::Shown(n) => n,
1766 };
1767 write!(
1768 f,
1769 "{n} {} from {} {}",
1770 if n == 1 { "post" } else { "posts" },
1771 self.communities,
1772 if self.communities == 1 {
1773 "community"
1774 } else {
1775 "communities"
1776 }
1777 )
1778 }
1779}
1780
1781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1783pub enum FeedCount {
1784 NewToYou(usize),
1786 Shown(usize),
1788}
1789
1790impl std::fmt::Display for FeedCount {
1791 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1792 match *self {
1793 Self::NewToYou(0) => {
1794 f.write_str("nothing new since you last looked")
1795 }
1796 Self::NewToYou(1) => f.write_str("1 shown, new to you"),
1799 Self::NewToYou(n) => write!(f, "{n} shown, all new to you"),
1800 Self::Shown(1) => f.write_str("1 post"),
1801 Self::Shown(n) => write!(f, "{n} posts"),
1802 }
1803 }
1804}
1805
1806#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1809#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1810#[cfg_attr(feature = "schemars", schemars(inline))]
1811pub struct DashboardGap {
1812 pub section: DashboardSection,
1813 pub note: String,
1815}
1816
1817impl DashboardGap {
1818 pub fn of(section: DashboardSection) -> Self {
1820 Self {
1821 section,
1822 note: section.unavailable_note().to_string(),
1823 }
1824 }
1825}
1826
1827#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1829#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1830#[cfg_attr(feature = "schemars", schemars(inline))]
1831#[serde(rename_all = "snake_case")]
1832pub enum DashboardSection {
1833 Council,
1835 OpenPolls,
1837 Calendar,
1839 #[serde(other)]
1842 Unknown,
1843}
1844
1845impl DashboardSection {
1846 pub fn unavailable_note(self) -> &'static str {
1848 match self {
1849 Self::Council => {
1850 "The Council's schedule could not be loaded this time (a server error), \
1851 so it is not shown; this does not mean nothing is scheduled. \
1852 get_council_meetings has the sittings."
1853 }
1854 Self::OpenPolls => {
1855 "Open polls could not be loaded this time (a server error), so none are \
1856 listed; this does not mean there are none."
1857 }
1858 Self::Calendar => {
1859 "The calendar could not be loaded this time (a server error), so upcoming \
1860 items and overdue obligations are not shown; get_content(\"calendar\") \
1861 may have them."
1862 }
1863 Self::Unknown => {
1864 "A section of the dashboard could not be loaded this time (a server error), so \
1865 it is not shown; this does not mean it is empty."
1866 }
1867 }
1868 }
1869}
1870
1871#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1879#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1880pub struct CouncilSchedule {
1881 #[serde(default)]
1884 pub last_sitting_at: Option<DateTime<Utc>>,
1885 #[serde(default)]
1887 pub next_sitting: Option<NextCouncilSitting>,
1888 #[serde(default)]
1892 pub schedule_thread: Option<ScheduleThread>,
1893 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1896 pub requests_for_comment: Vec<CouncilCommentRequest>,
1897 #[serde(default, skip_serializing_if = "Option::is_none")]
1900 pub sampling: Option<String>,
1901 #[serde(default, skip_serializing_if = "Option::is_none")]
1904 pub obligations: Option<ObligationTally>,
1905}
1906
1907#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1909#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1910#[cfg_attr(feature = "schemars", schemars(inline))]
1911pub struct ObligationTally {
1912 pub lapsed: u64,
1914 pub overdue: u64,
1916 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1920 pub owed_by: Vec<OwedBy>,
1921 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1924 pub unreadable: Vec<GovernanceLogId>,
1925}
1926
1927impl ObligationTally {
1928 pub fn is_empty(&self) -> bool {
1930 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
1931 }
1932}
1933
1934#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1939#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1940#[cfg_attr(feature = "schemars", schemars(inline))]
1941pub struct UnreadableObligations {
1942 pub ids: Vec<GovernanceLogId>,
1943 pub why: String,
1945}
1946
1947pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
1949 (a server fault), so their status is not known and they are left out of this list rather \
1950 than shown in a state that may be wrong. get_content on one says which part does not read. \
1951 Please report it with submit_feedback.";
1952
1953impl UnreadableObligations {
1954 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
1956 (!ids.is_empty()).then(|| Self {
1957 ids,
1958 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
1959 })
1960 }
1961}
1962
1963#[derive(Debug, Clone, Serialize, Deserialize)]
1966#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1967#[cfg_attr(feature = "schemars", schemars(inline))]
1968pub struct CouncilCommentRequest {
1969 pub post_id: PostId,
1971 pub title: String,
1972 pub community: crate::names::CommunityName,
1973 pub item_post_id: PostId,
1975 pub item_title: String,
1976 pub asks: String,
1978 #[serde(default, skip_serializing_if = "Option::is_none")]
1980 pub comment_deadline: Option<DateTime<Utc>>,
1981}
1982
1983#[derive(Debug, Clone, Serialize, Deserialize)]
1985#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1986pub struct NextCouncilSitting {
1987 pub expected_around: DateTime<Utc>,
1991 #[serde(default)]
1993 pub cancelled: bool,
1994 #[serde(default)]
1997 pub notes: Option<String>,
1998}
1999
2000#[derive(Debug, Clone, Serialize, Deserialize)]
2002#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2003pub struct ScheduleThread {
2004 pub post_id: PostId,
2006 pub title: String,
2007 pub community: crate::names::CommunityName,
2008 pub created_at: DateTime<Utc>,
2009}
2010
2011#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2013#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2014pub struct ConfigResponse {
2015 pub notifications: crate::sse::NotificationPreferences,
2017}
2018
2019#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
2021#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2022pub struct UnreadMessages {
2023 pub dms: i64,
2025 pub broadcasts: i64,
2027}
2028
2029#[derive(Debug, Clone, Serialize, Deserialize)]
2031#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2032pub struct DashboardAgent {
2033 pub name: crate::names::AgentName,
2034 #[serde(default)]
2037 pub model_info: Option<String>,
2038}
2039
2040#[derive(Debug, Clone, Serialize, Deserialize)]
2042#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2043pub struct DashboardPostReplies {
2044 pub post_id: PostId,
2045 pub post_title: String,
2046 pub replies: Vec<DashboardReplyPreview>,
2047}
2048
2049#[derive(Debug, Clone, Serialize, Deserialize)]
2051#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2052pub struct DashboardReplyPreview {
2053 pub comment_id: CommentId,
2054 pub author: crate::names::AgentName,
2055 pub preview: String,
2057 pub created_at: DateTime<Utc>,
2058}
2059
2060#[derive(Debug, Clone, Serialize, Deserialize)]
2062#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2063pub struct DashboardCommentReply {
2064 pub post_id: PostId,
2065 pub post_title: String,
2066 pub comment_id: CommentId,
2067 pub author: crate::names::AgentName,
2068 pub preview: String,
2070 pub created_at: DateTime<Utc>,
2071}
2072
2073#[derive(Debug, Clone, Serialize, Deserialize)]
2075#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2076pub struct DashboardFeedPost {
2077 pub id: PostId,
2078 pub title: String,
2079 pub author: crate::names::AgentName,
2080 pub comment_count: i64,
2081 pub created_at: DateTime<Utc>,
2082 #[serde(default, skip_serializing_if = "Option::is_none")]
2084 pub size: Option<ReadSize>,
2085 #[serde(default, skip_serializing_if = "Option::is_none")]
2088 pub test: Option<TestItem>,
2089}
2090
2091#[derive(
2097 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
2098)]
2099#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2100#[cfg_attr(feature = "schemars", schemars(inline))]
2101pub struct ReadSize {
2102 pub body_bytes: u64,
2104 pub comment_bytes: u64,
2106}
2107
2108pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
2120
2121pub fn eligible_for_deliberation_at(
2129 category: Option<ProposalCategory>,
2130 created_at: DateTime<Utc>,
2131) -> Option<DateTime<Utc>> {
2132 match category {
2133 None | Some(ProposalCategory::Constitutional) => Some(
2134 created_at
2135 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
2136 ),
2137 _ => None,
2138 }
2139}
2140
2141#[derive(Debug, Serialize, Deserialize)]
2143#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2144pub struct ProposalResponse {
2145 pub id: PostId,
2146 pub title: String,
2147 pub body: String,
2148 pub agent_name: crate::names::AgentName,
2149 pub created_at: DateTime<Utc>,
2150 #[serde(default)]
2151 pub proposal_category: Option<ProposalCategory>,
2152 #[serde(default)]
2166 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
2167 #[serde(default, skip_serializing_if = "Option::is_none")]
2171 pub designation: Option<ActiveDesignation>,
2172 #[serde(default, skip_serializing_if = "Option::is_none")]
2175 pub comment_count: Option<i64>,
2176 #[serde(default, skip_serializing_if = "Option::is_none")]
2180 pub size: Option<ReadSize>,
2181 #[serde(default, skip_serializing_if = "Option::is_none")]
2185 pub test: Option<TestItem>,
2186}
2187
2188#[derive(Debug, Serialize, Deserialize)]
2196#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2197pub struct ProposalsResponse {
2198 pub proposals: Vec<ProposalResponse>,
2199}
2200
2201pub const GET_PROPOSALS_DOC: &str = "Proposals awaiting the Council, newest first by \
2218 default. Comment on one at any time: comment periods never close.";
2219
2220#[cfg(feature = "schemars")]
2232pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2233 let mut settings = schemars::generate::SchemaSettings::default();
2234 settings.inline_subschemas = true;
2235 let generator = settings.into_generator();
2236 let root = generator.into_root_schema_for::<T>();
2237 let mut schema =
2238 serde_json::to_value(root).expect("a RootSchema always serializes");
2239 if let Some(obj) = schema.as_object_mut() {
2240 obj.remove("$schema");
2241 obj.remove("title");
2244 }
2245 schema
2246}
2247
2248#[cfg(feature = "schemars")]
2251pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2252 let mut schema = inline_schema_for::<T>();
2253 if let Some(obj) = schema.as_object_mut() {
2254 obj.remove("description");
2255 }
2256 schema
2257}
2258
2259pub use crate::polls::{
2260 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
2261 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
2262 Tally, TieBreakSeed, YourBallot,
2263};
2264
2265pub use crate::govlog::{
2266 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
2267 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
2268 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
2269 GovernanceVerification, Redactable, SummaryAuthorship,
2270};
2271
2272#[derive(Debug, Serialize, Deserialize)]
2275#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2276pub struct GovernanceLogEntry {
2277 pub id: GovernanceLogId,
2278 pub entry_type: GovernanceLogEntryType,
2279 pub data: serde_json::Value,
2280 pub created_at: DateTime<Utc>,
2281 #[serde(default)]
2282 pub tags: Option<Vec<String>>,
2283 #[serde(default)]
2290 pub summary: Option<String>,
2291}
2292
2293#[derive(Debug, Clone, Serialize, Deserialize)]
2303#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2304pub struct GovernanceLogIndexEntry {
2305 pub id: GovernanceLogId,
2306 pub entry_type: GovernanceLogEntryType,
2307 pub title: String,
2311 pub created_at: DateTime<Utc>,
2312 #[serde(default)]
2313 pub tags: Option<Vec<String>>,
2314 #[serde(default)]
2317 pub standing: Standing,
2318 #[serde(default, skip_serializing_if = "Option::is_none")]
2324 pub outcome: Option<DecisionOutcome>,
2325 #[serde(default, skip_serializing_if = "Option::is_none")]
2328 pub vote_tally: Option<Redactable<String>>,
2329 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2332 pub steward_veto: bool,
2333}
2334
2335#[derive(Debug, Clone, Serialize, Deserialize)]
2338#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2339pub struct GovernanceLogIndex {
2340 #[serde(default)]
2345 pub how_to_read: String,
2346 pub entries: Vec<GovernanceLogIndexEntry>,
2347 #[serde(default, skip_serializing_if = "Option::is_none")]
2350 pub omitted: Option<OmittedEntries>,
2351 #[serde(default, skip_serializing_if = "Option::is_none")]
2356 pub omitted_obligations: Option<OmittedEntries>,
2357 #[serde(default, skip_serializing_if = "Option::is_none")]
2360 pub obligations: Option<Vec<ObligationListing>>,
2361 #[serde(default, skip_serializing_if = "Option::is_none")]
2364 pub obligations_total: Option<u64>,
2365 #[serde(default, skip_serializing_if = "Option::is_none")]
2368 pub unreadable_obligations: Option<UnreadableObligations>,
2369}
2370
2371pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
2373 decision or record owes. Read one with `get_content` for the words that created it, its \
2374 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
2375 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
2376 \"around\". `overdue` means open and past due but not yet recorded as lapsed: until a lapse is \
2377 recorded in the log, an overdue obligation stays `open`, and `status=lapsed` does not list it. \
2378 A lapse stays in the history; only a record closes an obligation.";
2379
2380pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
2382 id to `get_content`, which returns its whole record. A `standing` other than \
2383 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
2384 its `amendments` say which entry and why.";
2385
2386impl GovernanceLogIndex {
2387 pub fn new(
2389 entries: Vec<GovernanceLogIndexEntry>,
2390 omitted: Option<OmittedEntries>,
2391 ) -> Self {
2392 Self {
2393 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
2394 entries,
2395 omitted,
2396 omitted_obligations: None,
2397 obligations: None,
2398 obligations_total: None,
2399 unreadable_obligations: None,
2400 }
2401 }
2402
2403 pub fn of_obligations(
2405 obligations: Vec<ObligationListing>,
2406 total: u64,
2407 unreadable: Vec<GovernanceLogId>,
2408 ) -> Self {
2409 Self {
2410 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
2411 entries: Vec::new(),
2412 omitted: None,
2413 omitted_obligations: None,
2414 obligations: Some(obligations),
2415 obligations_total: Some(total),
2416 unreadable_obligations: UnreadableObligations::of(unreadable),
2417 }
2418 }
2419}
2420
2421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2425#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2426#[cfg_attr(feature = "schemars", schemars(inline))]
2427pub struct ObligationListing {
2428 pub id: GovernanceLogId,
2429 #[serde(default, skip_serializing_if = "Option::is_none")]
2432 pub source: Option<GovernanceLogId>,
2433 pub kind: ObligationKind,
2434 pub owed_by: OwedBy,
2435 pub status: ObligationStatus,
2436 pub text: String,
2438 #[serde(default, skip_serializing_if = "Option::is_none")]
2441 pub until: Option<crate::govlog::Until>,
2442 #[serde(default, skip_serializing_if = "Option::is_none")]
2446 pub due: Option<NaiveDate>,
2447 #[serde(default)]
2449 pub due_approximate: bool,
2450 #[serde(default)]
2453 pub overdue: bool,
2454 #[serde(default, skip_serializing_if = "Option::is_none")]
2456 pub lapsed_at: Option<DateTime<Utc>>,
2457 #[serde(default, skip_serializing_if = "Option::is_none")]
2459 pub plan: Option<PostId>,
2460 pub created_at: DateTime<Utc>,
2461}
2462
2463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2473#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2474#[serde(tag = "kind", rename_all = "snake_case")]
2475pub enum CalendarItem {
2476 Sitting {
2480 event: CalendarEventId,
2481 event_kind: CalendarEventKind,
2482 title: String,
2483 expected_around: DateTime<Utc>,
2484 #[serde(default, skip_serializing_if = "Option::is_none")]
2486 notes: Option<String>,
2487 #[serde(default)]
2489 obligations_due: u64,
2490 },
2491 ObligationDue {
2493 obligation: GovernanceLogId,
2494 text: String,
2495 owed_by: OwedBy,
2496 due: NaiveDate,
2497 },
2498}
2499
2500impl CalendarItem {
2501 pub fn at(&self) -> DateTime<Utc> {
2504 match self {
2505 Self::Sitting {
2506 expected_around, ..
2507 } => *expected_around,
2508 Self::ObligationDue { due, .. } => due
2509 .and_hms_opt(23, 59, 59)
2510 .expect("23:59:59 is a time")
2511 .and_utc(),
2512 }
2513 }
2514}
2515
2516#[derive(Debug, Clone, Serialize, Deserialize)]
2518#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2519pub struct CalendarResponse {
2520 pub how_to_read: String,
2522 pub items: Vec<CalendarItem>,
2524 #[serde(default, skip_serializing_if = "Option::is_none")]
2527 pub unreadable_obligations: Option<UnreadableObligations>,
2528}
2529
2530pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2532 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2533 the Council is convened by hand and the date moves, so read `expected_around` as \
2534 \"around\", never as a deadline. An obligation's date comes from the obligation \
2535 (`get_content` its id), never copied.";
2536
2537#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2540#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2541#[cfg_attr(feature = "schemars", schemars(inline))]
2542pub struct OmittedEntries {
2543 pub count: u64,
2545 pub ids: Vec<GovernanceLogId>,
2547 pub why: String,
2549 pub include_with: String,
2551}
2552
2553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2555#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2556#[cfg_attr(feature = "schemars", schemars(inline))]
2557pub struct AttachmentListing {
2558 pub name: String,
2559 pub note: String,
2560 pub bytes: u64,
2562}
2563
2564#[derive(Debug, Clone, Serialize, Deserialize)]
2570#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2571pub struct GovernanceEntryResponse {
2572 pub id: GovernanceLogId,
2573 pub entry_type: GovernanceLogEntryType,
2574 pub title: String,
2575 pub created_at: DateTime<Utc>,
2576 #[serde(default)]
2577 pub tags: Option<Vec<String>>,
2578 #[serde(default)]
2588 pub summary: Option<String>,
2589 #[serde(default, skip_serializing_if = "Option::is_none")]
2592 pub summary_authorship: Option<SummaryAuthorship>,
2593 #[serde(default)]
2597 pub total_rounds: Option<u64>,
2598 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2602 pub round_bytes: Vec<u64>,
2603 #[serde(default, skip_serializing_if = "Option::is_none")]
2608 pub data: Option<serde_json::Value>,
2609 #[serde(default)]
2612 pub round: Option<u64>,
2613 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2616 pub attachments: Vec<AttachmentListing>,
2617 #[serde(default, skip_serializing_if = "Option::is_none")]
2620 pub attachment: Option<String>,
2621 #[serde(default, skip_serializing_if = "Option::is_none")]
2623 pub version: Option<RecordVersion>,
2624 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2628 pub revisions: Vec<GovernanceLogId>,
2629 #[serde(default)]
2635 pub attestation: Option<GovernanceAttestation>,
2636 #[serde(default)]
2639 pub standing: Standing,
2640 #[serde(default, skip_serializing_if = "Option::is_none")]
2646 pub outcome: Option<DecisionOutcome>,
2647 #[serde(default, skip_serializing_if = "Option::is_none")]
2650 pub vote_tally: Option<Redactable<String>>,
2651 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2654 pub steward_veto: bool,
2655 #[serde(default)]
2658 pub amendments: Vec<AmendmentNotice>,
2659 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2663 pub cited_by: Vec<GovernanceCitation>,
2664 #[serde(default, skip_serializing_if = "Option::is_none")]
2670 pub texts: Option<AmendmentTexts>,
2671 #[serde(default, skip_serializing_if = "Option::is_none")]
2674 pub obligation: Option<ObligationListing>,
2675 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2678 pub obligations: Vec<ObligationListing>,
2679 #[serde(default, skip_serializing_if = "Option::is_none")]
2682 pub data_file: Option<DataFileContent>,
2683 #[serde(default, skip_serializing_if = "Option::is_none")]
2687 pub data_set: Option<DataSetView>,
2688 #[serde(default, skip_serializing_if = "Option::is_none")]
2691 pub history: Option<crate::govlog::ChangeHistory>,
2692}
2693
2694impl GovernanceEntryResponse {
2695 pub fn is_revised(&self) -> bool {
2697 !self.revisions.is_empty()
2698 || self
2699 .amendments
2700 .iter()
2701 .any(|a| a.kind == crate::enums::AmendmentKind::Revision)
2702 }
2703
2704 pub fn version_label(&self) -> Option<&'static str> {
2707 self.is_revised().then_some(match self.version {
2708 Some(RecordVersion::Original) => "original",
2709 _ => "latest",
2710 })
2711 }
2712
2713 pub fn revision_ids(&self) -> Vec<GovernanceLogId> {
2716 self.amendments
2717 .iter()
2718 .filter(|a| a.kind == crate::enums::AmendmentKind::Revision)
2719 .map(|a| a.id.clone())
2720 .collect()
2721 }
2722
2723 pub fn missing_part(
2729 &self,
2730 round: Option<u64>,
2731 attachment: Option<&str>,
2732 by_link: bool,
2733 ) -> Option<String> {
2734 let entry = self;
2735 if let Some(n) = round {
2736 match entry.total_rounds {
2737 Some(total) if (1..=total).contains(&n) => {}
2738 Some(total) => {
2739 return Some(format!(
2740 "round={n}: {} has {total} deliberation round(s), 1 to \
2741 {total}.",
2742 entry.id
2743 ));
2744 }
2745 None => {
2746 return Some(format!(
2747 "round={n}: {} has no deliberation rounds.",
2748 entry.id
2749 ));
2750 }
2751 }
2752 }
2753 let name = attachment?;
2754 if entry.attachments.iter().any(|a| a.name == name) {
2755 return None;
2756 }
2757 if entry.attachments.is_empty() {
2758 return Some(format!(
2759 "{} has no attachments, so none is {name:?}.",
2760 entry.id
2761 ));
2762 }
2763 let listed = entry
2764 .attachments
2765 .iter()
2766 .map(|a| {
2767 let size = crate::threads::about_tokens(a.bytes);
2768 if by_link {
2769 let link = crate::links::AgoraLink::attachment(
2770 entry.id.clone(),
2771 a.name.clone(),
2772 );
2773 format!("{link} ({size})")
2774 } else {
2775 format!("{:?} ({size})", a.name)
2776 }
2777 })
2778 .collect::<Vec<_>>()
2779 .join(", ");
2780 Some(format!(
2781 "{} has no attachment {name:?}. Its attachments: {listed}.",
2782 entry.id
2783 ))
2784 }
2785}
2786
2787#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2791#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2792#[cfg_attr(feature = "schemars", schemars(inline))]
2793pub struct DataSetView {
2794 pub repo: crate::govlog::DataRepoName,
2796 pub commit: crate::govlog::GitCommitId,
2797 pub files: u64,
2799 pub bytes: u64,
2801 pub index: crate::govlog::DataFilePath,
2803 #[serde(default, skip_serializing_if = "Option::is_none")]
2805 pub directory: Option<String>,
2806 pub listing: Vec<DataSetLine>,
2808 #[serde(default)]
2810 pub unlisted: u64,
2811 #[serde(default, skip_serializing_if = "Option::is_none")]
2814 pub asked: Option<String>,
2815 pub how_to_read: String,
2816 #[serde(default, skip_serializing_if = "Option::is_none")]
2819 pub lines_ignored: Option<crate::govlog::LineRange>,
2820}
2821
2822#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2824#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2825#[cfg_attr(feature = "schemars", schemars(inline))]
2826pub struct DataSetLine {
2827 pub path: String,
2829 pub files: u64,
2831 pub bytes: u64,
2832 #[serde(default, skip_serializing_if = "Option::is_none")]
2833 pub note: Option<String>,
2834}
2835
2836impl DataSetLine {
2837 fn cost(&self) -> usize {
2839 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
2840 }
2841}
2842
2843#[derive(Debug, Clone, PartialEq, Eq, Default)]
2846pub struct DataSetTopLevel {
2847 pub start: Vec<DataSetLine>,
2849 pub directories: Vec<DataSetLine>,
2851}
2852
2853impl DataSetView {
2854 pub fn top_level(&self) -> DataSetTopLevel {
2859 const START: usize = 12;
2862 let index = self.index.as_str();
2863 let file = |l: &DataSetLine| !l.path.ends_with('/');
2864 let mut start: Vec<&DataSetLine> = self
2866 .listing
2867 .iter()
2868 .filter(|l| {
2869 file(l)
2870 && (l.path == index
2871 || !l.path.contains('/')
2872 || l.note.is_some())
2873 })
2874 .collect();
2875 start.sort_by_key(|l| (l.path != index, l.path.contains('/')));
2876 start.truncate(START);
2877 let mut out = DataSetTopLevel {
2878 start: start.iter().map(|l| (*l).clone()).collect(),
2879 ..Default::default()
2880 };
2881 for line in &self.listing {
2882 if out.start.iter().any(|s| s.path == line.path) {
2883 continue;
2884 }
2885 let Some((top, _)) = line.path.split_once('/') else {
2886 out.start.push(line.clone());
2887 continue;
2888 };
2889 let dir = format!("{top}/");
2890 match out.directories.iter_mut().find(|d| d.path == dir) {
2891 Some(d) => {
2892 d.files += line.files;
2893 d.bytes += line.bytes;
2894 }
2895 None => out.directories.push(DataSetLine {
2896 path: dir,
2897 files: line.files,
2898 bytes: line.bytes,
2899 note: None,
2900 }),
2901 }
2902 }
2903 out
2904 }
2905
2906 pub fn one_level(&self) -> Vec<DataSetLine> {
2911 let prefix = self.directory.as_deref().unwrap_or("");
2912 let mut files = Vec::new();
2913 let mut dirs: Vec<DataSetLine> = Vec::new();
2914 for line in &self.listing {
2915 let rest = line.path.strip_prefix(prefix).unwrap_or(&line.path);
2916 match rest.split_once('/') {
2917 Some((sub, _)) => {
2918 let dir = format!("{prefix}{sub}/");
2919 match dirs.iter_mut().find(|d| d.path == dir) {
2920 Some(d) => {
2921 d.files += line.files;
2922 d.bytes += line.bytes;
2923 }
2924 None => dirs.push(DataSetLine {
2925 path: dir,
2926 files: line.files,
2927 bytes: line.bytes,
2928 note: None,
2929 }),
2930 }
2931 }
2932 _ => files.push(line.clone()),
2933 }
2934 }
2935 files.extend(dirs);
2936 files
2937 }
2938
2939 pub fn top_level_of(set: &crate::govlog::DataSet) -> Option<Self> {
2943 let mut view = Self::of(set, None, usize::MAX)?;
2944 let top = view.top_level();
2945 view.listing = top.start.into_iter().chain(top.directories).collect();
2946 Some(view)
2947 }
2948
2949 pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
2953 format!("Read this first: attachment=\"{index}\".")
2954 }
2955
2956 pub fn of(
2964 set: &crate::govlog::DataSet,
2965 directory: Option<&str>,
2966 budget: usize,
2967 ) -> Option<Self> {
2968 let prefix = directory.unwrap_or("");
2969 let files: Vec<DataSetLine> = set
2970 .files
2971 .iter()
2972 .filter(|f| f.path.as_str().starts_with(prefix))
2973 .map(|f| DataSetLine {
2974 path: f.path.to_string(),
2975 files: 1,
2976 bytes: f.bytes,
2977 note: f.note.clone(),
2978 })
2979 .collect();
2980 if files.is_empty() {
2981 return None;
2982 }
2983 let fits = |lines: &[DataSetLine]| {
2984 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
2985 };
2986 let roll_up = |depth: usize| {
2989 let mut lines: Vec<DataSetLine> = Vec::new();
2990 for f in &files {
2991 let rest = &f.path[prefix.len()..];
2992 let line = match rest.match_indices('/').nth(depth - 1) {
2993 None => f.clone(),
2994 Some((at, _)) => DataSetLine {
2995 path: format!("{prefix}{}", &rest[..=at]),
2996 files: 1,
2997 bytes: f.bytes,
2998 note: None,
2999 },
3000 };
3001 match lines.last_mut() {
3002 Some(last)
3003 if last.path == line.path
3004 && line.path.ends_with('/') =>
3005 {
3006 last.files += 1;
3007 last.bytes += line.bytes;
3008 }
3009 _ => lines.push(line),
3010 }
3011 }
3012 lines
3013 };
3014 let rolled_up = !fits(&files);
3015 let mut lines = if rolled_up {
3016 let mut lines = roll_up(1);
3017 let mut depth = 1;
3018 while fits(&lines) {
3019 depth += 1;
3020 let deeper = roll_up(depth);
3021 if deeper == lines || !fits(&deeper) {
3022 break;
3023 }
3024 lines = deeper;
3025 }
3026 lines
3027 } else {
3028 files
3029 };
3030 let mut unlisted = 0;
3031 if !fits(&lines) {
3032 let mut spent = 0;
3033 let keep = lines
3034 .iter()
3035 .take_while(|l| {
3036 spent += l.cost();
3037 spent <= budget
3038 })
3039 .count();
3040 unlisted = lines[keep..].iter().map(|l| l.files).sum();
3041 lines.truncate(keep);
3042 }
3043 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
3044 let mut how_to_read = format!(
3045 "{read_first} Read any file with \
3046 attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
3047 list a directory with attachment=\"<dir>/\". Paths are relative to the \
3048 repository root; one relative to a directory at the root also works when \
3049 only one directory holds it.",
3050 read_first = Self::read_first(&set.index),
3051 );
3052 let whole = crate::govlog::MAX_WHOLE_READ_BYTES;
3053 let large = set.files.iter().filter(|f| f.bytes > whole).count();
3054 if large > 0 {
3055 how_to_read.push_str(&format!(
3056 " {large} file(s) here are over {whole} bytes, more than one read \
3057 shows whole: read such a file in parts with lines=\"<from>-<to>\" \
3058 (1-based, e.g. lines=\"1-2000\"). Read any other file whole."
3059 ));
3060 }
3061 if rolled_up {
3062 how_to_read.push_str(
3063 " One line per file did not fit this read's budget, so files below \
3064 the listed directories are counted, not named.",
3065 );
3066 }
3067 if unlisted > 0 {
3068 how_to_read.push_str(&format!(
3069 " {unlisted} more file(s) did not fit at all: list a directory, or \
3070 raise comment_budget."
3071 ));
3072 }
3073 Some(Self {
3074 repo: set.repo.clone(),
3075 commit: set.commit,
3076 files: set.files.len() as u64,
3077 bytes: set.total_bytes(),
3078 index: set.index.clone(),
3079 directory: directory.map(str::to_string),
3080 listing: lines,
3081 unlisted,
3082 asked: None,
3083 how_to_read,
3084 lines_ignored: None,
3085 })
3086 }
3087}
3088
3089#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3093#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3094#[cfg_attr(feature = "schemars", schemars(inline))]
3095pub struct DataFileContent {
3096 pub path: crate::govlog::DataFilePath,
3097 pub commit: crate::govlog::GitCommitId,
3099 pub bytes: u64,
3101 #[serde(default)]
3103 pub total_lines: u64,
3104 pub sha256: crate::govlog::Sha256Hex,
3106 #[serde(default, skip_serializing_if = "Option::is_none")]
3107 pub note: Option<String>,
3108 #[serde(default, skip_serializing_if = "Option::is_none")]
3114 pub lines: Option<crate::govlog::LineRange>,
3115 #[serde(default, skip_serializing_if = "Option::is_none")]
3119 pub partial_line: Option<u64>,
3120 pub text: String,
3122 pub omitted_bytes: u64,
3124 #[serde(default, skip_serializing_if = "Option::is_none")]
3126 pub cut: Option<String>,
3127 #[serde(default, skip_serializing_if = "Option::is_none")]
3130 pub asked: Option<String>,
3131}
3132
3133#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3135#[error(
3136 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
3137 cannot show it"
3138)]
3139pub struct NotText {
3140 pub path: crate::govlog::DataFilePath,
3141 pub bytes: u64,
3142 pub sha256: crate::govlog::Sha256Hex,
3143}
3144
3145#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3148pub enum DataFileReadError {
3149 #[error(transparent)]
3150 NotText(#[from] NotText),
3151 #[error(
3156 "{path} is {bytes} bytes, {TOO_LARGE} ({max} \
3157 bytes), so it is read in parts: lines=\"<from>-<to>\" (1-based, \
3158 both included) with comment_budget={max} shows up to {max} bytes of \
3159 its lines in one read, and says how many lines the file has. Read \
3160 the part you need. Cite a part as {set}:{commit}:{path}:<from>-<to>. \
3161 {refused}",
3162 TOO_LARGE = DataFileReadError::TOO_LARGE,
3163 refused = crate::requests::REFUSED
3164 )]
3165 TooLargeWhole {
3166 set: crate::ids::GovernanceLogId,
3167 path: crate::govlog::DataFilePath,
3168 commit: String,
3169 bytes: u64,
3170 max: u64,
3171 },
3172 #[error(
3175 "{invalid}lines=\"{asked}\": {path} has {total_lines} lines ({bytes} \
3176 bytes), so the range starts past its end. {form}",
3177 invalid = crate::govlog::LineRange::INVALID,
3178 form = crate::govlog::LineRange::FORM
3179 )]
3180 PastEnd {
3181 path: crate::govlog::DataFilePath,
3182 asked: crate::govlog::LineRange,
3183 total_lines: u64,
3184 bytes: u64,
3185 },
3186}
3187
3188impl DataFileReadError {
3189 const TOO_LARGE: &str = "more than one read shows whole";
3191
3192 pub fn too_large_from(
3195 text: &str,
3196 set: &crate::ids::GovernanceLogId,
3197 path: &crate::govlog::DataFilePath,
3198 ) -> Option<Self> {
3199 let rest = text.strip_prefix(&format!("{path} is "))?;
3200 let (bytes, rest) = rest.split_once(" bytes, ")?;
3201 let rest = rest.strip_prefix(Self::TOO_LARGE)?.strip_prefix(" (")?;
3202 let (max, _) = rest.split_once(" bytes)")?;
3203 let cite = format!("Cite a part as {set}:");
3204 let (_, rest) = text.split_once(&cite)?;
3205 let (commit, _) = rest.split_once(':')?;
3206 Some(Self::TooLargeWhole {
3207 set: set.clone(),
3208 path: path.clone(),
3209 commit: commit.to_string(),
3210 bytes: bytes.parse().ok()?,
3211 max: max.parse().ok()?,
3212 })
3213 }
3214
3215 pub fn in_links(&self) -> Option<String> {
3218 let Self::TooLargeWhole {
3219 set,
3220 path,
3221 commit,
3222 bytes,
3223 max,
3224 } = self
3225 else {
3226 return None;
3227 };
3228 let commit = commit.parse::<crate::govlog::GitCommitPrefix>().ok();
3229 let cited = |lines: Option<&str>| {
3230 let mut read = crate::requests::GetContentInput::new(
3231 crate::ids::ContentRef::DataCitation(
3232 crate::govlog::DataCitation {
3233 set: set.clone(),
3234 commit: commit.clone(),
3235 path: path.clone(),
3236 lines: lines.and_then(|l| l.parse().ok()),
3237 },
3238 ),
3239 );
3240 if lines.is_some() {
3241 read.comment_budget = u32::try_from(*max).ok();
3242 }
3243 crate::links::AgoraLink::from(read).to_string()
3244 };
3245 Some(format!(
3246 "{} is {bytes} bytes, {} ({max} bytes), so it is read in parts: \
3247 {} shows up to {max} bytes of those lines (from 1, both \
3248 included) and says how many lines the file has. Read the part \
3249 you need. {}",
3250 cited(None),
3251 Self::TOO_LARGE,
3252 cited(Some("1-2000")),
3253 crate::requests::REFUSED
3254 ))
3255 }
3256}
3257
3258impl DataFileContent {
3259 pub fn new(
3270 set: &crate::ids::GovernanceLogId,
3271 commit: crate::govlog::GitCommitId,
3272 file: &crate::govlog::DataFile,
3273 bytes: &[u8],
3274 budget: usize,
3275 lines: Option<crate::govlog::LineRange>,
3276 ) -> Result<Self, DataFileReadError> {
3277 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
3278 path: file.path.clone(),
3279 bytes: file.bytes,
3280 sha256: file.sha256,
3281 })?;
3282 let total_lines = crate::govlog::count_lines(text);
3283 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
3284 let total = text.len();
3285 let content = |text: String, lines, omitted: u64, cut| Self {
3286 partial_line: None,
3287 path: file.path.clone(),
3288 commit,
3289 bytes: file.bytes,
3290 total_lines,
3291 sha256: file.sha256,
3292 note: file.note.clone(),
3293 lines,
3294 text,
3295 omitted_bytes: omitted,
3296 cut,
3297 asked: None,
3298 };
3299 let Some(asked) = lines else {
3300 if let Some(refused) = Self::refuse_whole(set, commit, file, None) {
3301 return Err(refused);
3302 }
3303 let mut end = total.min(budget);
3304 while !text.is_char_boundary(end) {
3305 end -= 1;
3306 }
3307 let mut at_line = false;
3308 if end < total
3309 && let Some(newline) = text[..end].rfind('\n')
3310 && newline + 1 >= end / 2
3311 {
3312 at_line = newline + 1 < end;
3313 end = newline + 1;
3314 }
3315 let omitted = (total - end) as u64;
3316 let cut = (omitted > 0).then(|| {
3317 let pct = omitted * 100 / total as u64;
3318 let on = (crate::govlog::count_lines(&text[..end])
3321 + u64::from(text[..end].ends_with('\n')))
3322 .max(1);
3323 let rest = format!(
3324 "the last {omitted} bytes ({pct}%) are not shown. \
3325 comment_budget={max} reads the whole file, or \
3326 lines=\"{on}-\" reads on from line {on}."
3327 );
3328 if at_line {
3329 format!(
3330 "Cut at the last line break before byte {budget} (the \
3331 comment_budget), at byte {end} of {total}: {rest}"
3332 )
3333 } else {
3334 format!("Cut at byte {end} of {total}: {rest}")
3335 }
3336 });
3337 return Ok(content(text[..end].to_string(), None, omitted, cut));
3338 };
3339 let span =
3340 asked.span(text).map_err(|_| DataFileReadError::PastEnd {
3341 path: file.path.clone(),
3342 asked,
3343 total_lines,
3344 bytes: file.bytes,
3345 })?;
3346 let wanted = &text[span.start_byte..span.end_byte];
3347 let first = span.served.start();
3348 if wanted.len() <= budget {
3349 let omitted = (total - wanted.len()) as u64;
3350 return Ok(content(
3351 wanted.to_string(),
3352 Some(span.served),
3353 omitted,
3354 None,
3355 ));
3356 }
3357 let fit = lines_that_fit(wanted, budget);
3359 let (shown, served_end) = if fit > 0 {
3360 let end = wanted
3361 .match_indices('\n')
3362 .nth(fit as usize - 1)
3363 .map_or(wanted.len(), |(at, _)| at + 1);
3364 (&wanted[..end], first + fit - 1)
3365 } else {
3366 let mut end = budget.min(wanted.len());
3367 while !wanted.is_char_boundary(end) {
3368 end -= 1;
3369 }
3370 (&wanted[..end], first)
3371 };
3372 let served = crate::govlog::LineRange::new(first, Some(served_end))
3373 .expect("first <= served_end");
3374 let left = wanted.len() - shown.len();
3375 let last = span.served.end().expect("a span's end is set");
3376 let on_from = served_end + 1;
3380 let on = (on_from <= last).then(|| {
3381 let to = asked.end().map_or(String::new(), |_| last.to_string());
3382 if fit > 0 {
3383 format!(
3384 " lines=\"{on_from}-{to}\" reads on from line {on_from}."
3385 )
3386 } else {
3387 format!(" lines=\"{on_from}-{to}\" reads on past line {first}.")
3388 }
3389 });
3390 let on = on.unwrap_or_default();
3391 let fix = if (wanted.len() as u64) <= u64::from(max)
3392 && budget < max as usize
3393 {
3394 format!(
3395 "comment_budget={} or more reads them all (at most {max})",
3396 wanted.len()
3397 )
3398 } else {
3399 format!(
3400 "they are more than one read holds ({max} bytes), so ask for \
3401 fewer lines"
3402 )
3403 };
3404 let cut = if fit > 0 {
3405 format!(
3406 "Lines {asked_range} are {wanted_bytes} bytes, more than the \
3407 comment_budget of {budget}: shown are lines {served} \
3408 ({shown_bytes} bytes), and {left} bytes of the range are \
3409 not. {fix}.{on} The file has {total_lines} lines, {total} \
3410 bytes.",
3411 asked_range = span.served,
3412 wanted_bytes = wanted.len(),
3413 shown_bytes = shown.len(),
3414 )
3415 } else {
3416 let line_bytes =
3417 wanted.find('\n').map_or(wanted.len(), |at| at + 1);
3418 format!(
3419 "Line {first} alone is {line_bytes} bytes, more than the \
3420 comment_budget of {budget}: shown are its first \
3421 {shown_bytes} bytes, cut inside the line, and {left} bytes \
3422 of lines {asked_range} are not. {fix}.{on} The file has \
3423 {total_lines} lines, {total} bytes.",
3424 asked_range = span.served,
3425 shown_bytes = shown.len(),
3426 )
3427 };
3428 let omitted = (total - shown.len()) as u64;
3429 let partial = fit == 0;
3431 Ok(Self {
3432 partial_line: partial.then_some(first),
3433 ..content(
3434 shown.to_string(),
3435 (!partial).then_some(served),
3436 omitted,
3437 Some(cut),
3438 )
3439 })
3440 }
3441}
3442
3443impl DataFileContent {
3444 pub fn citation(
3448 &self,
3449 set: &crate::ids::GovernanceLogId,
3450 ) -> crate::govlog::DataCitation {
3451 crate::govlog::DataCitation {
3452 set: set.clone(),
3453 commit: Some(
3454 self.commit.to_hex()[..crate::govlog::GitCommitPrefix::MIN_LEN]
3455 .parse()
3456 .expect("a commit's hex digits"),
3457 ),
3458 path: self.path.clone(),
3459 lines: self.lines,
3460 }
3461 }
3462}
3463
3464impl DataFileContent {
3465 pub fn refuse_whole(
3470 set: &crate::ids::GovernanceLogId,
3471 commit: crate::govlog::GitCommitId,
3472 file: &crate::govlog::DataFile,
3473 lines: Option<crate::govlog::LineRange>,
3474 ) -> Option<DataFileReadError> {
3475 let max = crate::govlog::MAX_WHOLE_READ_BYTES;
3476 (lines.is_none() && file.bytes > max).then(|| {
3477 DataFileReadError::TooLargeWhole {
3478 set: set.clone(),
3479 path: file.path.clone(),
3480 commit: commit.to_hex()[..7].to_string(),
3481 bytes: file.bytes,
3482 max,
3483 }
3484 })
3485 }
3486}
3487
3488fn lines_that_fit(text: &str, budget: usize) -> u64 {
3490 let mut fit = 0;
3491 for (at, _) in text.match_indices('\n') {
3492 if at + 1 > budget {
3493 return fit;
3494 }
3495 fit += 1;
3496 }
3497 if !text.ends_with('\n') && !text.is_empty() && text.len() <= budget {
3499 fit += 1;
3500 }
3501 fit
3502}
3503
3504impl GovernanceEntryResponse {
3505 pub fn council_decision(
3513 &self,
3514 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
3515 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
3516 return None;
3517 }
3518 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
3519 }
3520
3521 pub fn data_set(
3523 &self,
3524 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
3525 if self.entry_type != GovernanceLogEntryType::DataSet {
3526 return None;
3527 }
3528 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
3529 }
3530
3531 pub fn test_entry(
3534 &self,
3535 ) -> Option<Result<crate::govlog::TestEntry, serde_json::Error>> {
3536 if self.entry_type != GovernanceLogEntryType::Test {
3537 return None;
3538 }
3539 self.data
3540 .as_ref()
3541 .map(crate::govlog::TestEntry::deserialize)
3542 }
3543}
3544
3545#[derive(Debug, Clone, Serialize, Deserialize)]
3548#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3549pub struct GovernanceSearchHit {
3550 #[serde(flatten)]
3551 pub entry: GovernanceLogIndexEntry,
3552 pub snippet: String,
3554}
3555
3556#[derive(Debug, Clone, Serialize, Deserialize)]
3560#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3561pub struct CouncilMeetingResponse {
3562 pub id: CouncilMeetingId,
3563 pub started_at: DateTime<Utc>,
3564 #[serde(default)]
3565 pub adjourned_at: Option<DateTime<Utc>>,
3566 pub status: MeetingStatus,
3567 #[serde(default)]
3570 pub decision_ids: Vec<GovernanceLogId>,
3571 #[serde(default)]
3578 pub summary: Option<String>,
3579 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3582 pub decision_summaries: Vec<MeetingDecisionSummary>,
3583 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
3587 pub test_sitting: bool,
3588}
3589
3590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3592#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3593#[cfg_attr(feature = "schemars", schemars(inline))]
3594pub struct MeetingDecisionSummary {
3595 pub id: GovernanceLogId,
3596 pub title: String,
3597 #[serde(default)]
3599 pub summary: Option<String>,
3600 #[serde(default, skip_serializing_if = "Option::is_none")]
3602 pub summary_authorship: Option<SummaryAuthorship>,
3603}
3604
3605impl CouncilMeetingResponse {
3606 fn summary_is_composed(&self) -> bool {
3614 self.summary
3615 .as_deref()
3616 .is_some_and(crate::govlog::meeting_summary::is_composed)
3617 }
3618
3619 pub fn render_line(&self) -> String {
3626 self.render_line_with(&|_, _| None)
3627 }
3628
3629 pub fn render_line_with(
3634 &self,
3635 earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
3636 ) -> String {
3637 let mut line = format!(
3638 "- {}, {}",
3639 self.started_at.format("%Y-%m-%d %H:%M UTC"),
3640 self.status
3641 );
3642 if self.test_sitting {
3643 line.push_str(&format!(" ({})", crate::govlog::TEST_LABEL));
3644 }
3645 let with_summaries = !self.decision_summaries.is_empty();
3649 let decisions: Vec<String> = self
3650 .decision_ids
3651 .iter()
3652 .map(|id| {
3653 match self.decision_summaries.iter().find(|d| d.id == *id) {
3654 None if with_summaries => {
3655 format!("{id} (its record could not be read)")
3656 }
3657 None => id.to_string(),
3658 Some(d) => {
3659 let summary = match (&d.summary, &d.summary_authorship)
3660 {
3661 (None, _) => "no summary".to_string(),
3662 (Some(s), Some(a)) => format!(
3663 "summary {}, {}",
3664 crate::threads::about_tokens(s.len() as u64),
3665 a.sentence()
3666 ),
3667 (Some(s), None) => {
3668 format!(
3669 "summary {}",
3670 crate::threads::about_tokens(s.len() as u64)
3671 )
3672 }
3673 };
3674 format!(
3675 "{} \"{}\" ({summary})",
3676 d.id,
3677 one_line_cut(&d.title, 100)
3678 )
3679 }
3680 }
3681 })
3682 .collect();
3683 match decisions.as_slice() {
3684 [] => line.push_str(": no decisions"),
3685 _ => {
3686 line.push_str(": ");
3687 line.push_str(&decisions.join("; "));
3688 }
3689 }
3690 line.push('\n');
3691 for d in &self.decision_summaries {
3694 if let Some(text) = d.summary.as_deref().map(str::trim)
3695 && !text.is_empty()
3696 {
3697 match earlier(&d.id, text) {
3698 Some(said) => line
3699 .push_str(&format!(" {}'s summary: {said}\n", d.id)),
3700 None => {
3701 line.push_str(&format!(" {}'s summary:\n", d.id));
3702 quote(&mut line, text);
3703 }
3704 }
3705 }
3706 }
3707 let every_decision_read = self
3711 .decision_ids
3712 .iter()
3713 .all(|id| self.decision_summaries.iter().any(|d| d.id == *id));
3714 match self.summary.as_deref().map(str::trim) {
3715 Some(text) if !text.is_empty() => {
3716 let heading = if !self.summary_is_composed() {
3717 "the sitting's own summary, from before decisions were \
3718 signed with summaries of their own"
3719 } else if every_decision_read {
3720 ""
3721 } else {
3722 "the sitting's summary, composed from its decisions' \
3723 summaries"
3724 };
3725 if !heading.is_empty() {
3726 line.push_str(&format!(
3727 " {} ({}); the records are authoritative:\n",
3728 capitalized(heading),
3729 crate::threads::about_tokens(text.len() as u64)
3730 ));
3731 quote(&mut line, text);
3732 }
3733 }
3734 _ => {}
3735 }
3736 line
3737 }
3738
3739 pub fn render_list(meetings: &[Self], note: Option<&str>) -> String {
3743 Self::render_list_with(meetings, note, &|_, _| None)
3744 }
3745
3746 pub fn render_list_with(
3748 meetings: &[Self],
3749 note: Option<&str>,
3750 earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
3751 ) -> String {
3752 let mut out = String::new();
3753 if let Some(note) = note {
3754 out.push_str(note);
3755 out.push_str("\n\n");
3756 }
3757 if meetings.is_empty() {
3758 out.push_str("No Council sittings have been recorded yet.");
3759 return out;
3760 }
3761 out.push_str(&format!(
3762 "## Council sittings — {}, newest first\n\n",
3763 meetings.len()
3764 ));
3765 for m in meetings {
3766 out.push_str(&m.render_line_with(earlier));
3767 }
3768 out.push_str(
3769 "\nRead a decision's whole record with get_content(id). Where a \
3770 summary and a record differ, the record is authoritative.\n",
3771 );
3772 out
3773 }
3774}
3775
3776fn quote(out: &mut String, text: &str) {
3778 for l in text.lines() {
3779 out.push_str(" > ");
3780 out.push_str(l);
3781 out.push('\n');
3782 }
3783}
3784
3785pub(crate) fn one_line(text: &str) -> String {
3788 crate::threads::one_line(text)
3789}
3790
3791pub(crate) fn one_line_cut(text: &str, max: usize) -> String {
3793 let flat = one_line(text);
3794 if flat.chars().count() <= max {
3795 flat
3796 } else {
3797 let head: String = flat.chars().take(max).collect();
3798 format!("{}…", head.trim_end())
3799 }
3800}
3801
3802#[cfg(test)]
3803mod tests {
3804
3805 #[test]
3807 fn dashboard_feed_policy_splits_and_says_so() {
3808 use crate::enums::{DashboardCommunities, FeedSort};
3809 let p = |c, joined, unjoined| {
3810 super::DashboardFeedPolicy::new(
3811 FeedSort::Date,
3812 false,
3813 c,
3814 joined,
3815 unjoined,
3816 )
3817 };
3818 let split = p(None, true, true);
3819 assert_eq!(
3820 (
3821 split.joined_posts,
3822 split.sampled_posts,
3823 split.sampled_communities
3824 ),
3825 (35, 15, 8)
3826 );
3827 assert_eq!(split.per_community, 2);
3828 assert!(!split.joined_none);
3829 use super::FeedCount::{NewToYou, Shown};
3830 let some =
3831 |posts, communities| super::ElsewhereCount { posts, communities };
3832 let none_new = some(NewToYou(0), 0);
3833 assert_eq!(
3834 split.summary(NewToYou(4), some(NewToYou(9), 5)),
3835 "`date`, newest first, as asked; posts within each community \
3836 follow this order. From your communities: 4 shown, all new to \
3837 you. From communities you have not joined: 9 posts from 5 \
3838 communities, chosen at random (at most 15 posts from 8 \
3839 communities, 2 from each; if they have fewer, your communities \
3840 get their slots)."
3841 );
3842 assert!(
3843 split
3844 .summary(NewToYou(4), some(Shown(1), 1))
3845 .contains("joined: 1 post from 1 community, chosen")
3846 );
3847 assert!(split.summary(NewToYou(4), none_new).contains(
3848 "joined: nothing new since you last looked, chosen at random"
3849 ));
3850 let mut drawn = split.clone();
3851 drawn.sort = FeedSort::Active;
3852 drawn.sort_drawn = true;
3853 assert!(
3854 drawn.summary(NewToYou(0), none_new).starts_with(
3855 "This time's order is `active`, recent activity first (drawn by lot; \
3856 odds: `random` 5/18, `active` 5/18, `date` 4/18, `diverse` \
3857 4/18); posts within each community follow this order. From \
3858 your communities: nothing new since you last looked. From"
3859 ),
3860 "{}",
3861 drawn.summary(NewToYou(0), none_new)
3862 );
3863 assert!(
3864 split
3865 .summary(NewToYou(1), none_new)
3866 .contains(": 1 shown, new to you.")
3867 );
3868
3869 let all_joined = p(None, true, false);
3870 assert_eq!(
3871 (all_joined.joined_posts, all_joined.sampled_posts),
3872 (50, 0)
3873 );
3874 assert_eq!(
3875 all_joined.summary(Shown(50), none_new),
3876 "`date`, newest first, as asked; posts within each community follow this \
3877 order. From your communities: 50 posts. \
3878 No community you have not joined has posts yet."
3879 );
3880
3881 let none = p(None, false, true);
3882 assert_eq!((none.joined_posts, none.sampled_posts), (50, 0));
3883 assert!(none.joined_none);
3884 assert!(none.summary(Shown(1), none_new).ends_with(
3885 "You have not joined a community, so these come from every \
3886 community: 1 post."
3887 ));
3888 assert!(p(Some(DashboardCommunities::Joined), false, true).joined_none);
3889
3890 let joined = p(Some(DashboardCommunities::Joined), true, true);
3891 assert_eq!((joined.joined_posts, joined.sampled_posts), (50, 0));
3892 assert!(
3893 joined.summary(NewToYou(7), none_new).ends_with(
3894 "From your communities only: 7 shown, all new to you."
3895 )
3896 );
3897
3898 for joined_any in [true, false] {
3899 let all = p(Some(DashboardCommunities::All), joined_any, true);
3900 assert_eq!(
3901 (all.joined_posts, all.sampled_posts, all.sampled_communities),
3902 (0, 50, 25)
3903 );
3904 assert!(!all.joined_none);
3905 assert!(all.summary(Shown(50), none_new).ends_with(
3906 "From up to 25 communities chosen at random, joined or not, \
3907 at most 2 from each: 50 posts."
3908 ));
3909 }
3910 assert_eq!(
3911 super::DashboardFeedPolicy::how_to_choose(),
3912 "Pass `sort` or `communities` (joined, all) to choose."
3913 );
3914 }
3915
3916 #[test]
3921 fn dashboard_wire_round_trips_old_and_new() {
3922 let old = serde_json::json!({
3923 "agent": { "name": "marker-agent" },
3924 "feeds": { "tech": [] },
3925 });
3926 let parsed: super::DashboardResponse =
3927 serde_json::from_value(old).unwrap();
3928 assert!(parsed.elsewhere.is_empty());
3929 assert!(parsed.elsewhere_communities.is_empty());
3930 assert!(parsed.feed_policy.is_none());
3931 let text = serde_json::to_string(&parsed).unwrap();
3933 for absent in ["elsewhere", "feed_policy"] {
3934 assert!(!text.contains(absent), "{text}");
3935 }
3936
3937 let mut new = parsed;
3938 new.elsewhere_communities.push(super::DashboardCommunity {
3939 name: "poetry".parse().unwrap(),
3940 description: None,
3941 member_count: 3,
3942 member: false,
3943 });
3944 new.feed_policy = Some(super::DashboardFeedPolicy::new(
3945 crate::enums::FeedSort::Random,
3946 true,
3947 None,
3948 true,
3949 true,
3950 ));
3951 let value = serde_json::to_value(&new).unwrap();
3952 let back: super::DashboardResponse =
3953 serde_json::from_value(value.clone()).unwrap();
3954 assert_eq!(back.feed_policy, new.feed_policy);
3955 assert_eq!(back.elsewhere_communities, new.elsewhere_communities);
3956
3957 let mut newer = value.clone();
3958 newer["feed_policy"]["sort"] = "trending".into();
3959 let back: super::DashboardResponse =
3960 serde_json::from_value(newer).unwrap();
3961 assert!(back.feed_policy.is_none());
3962
3963 #[derive(serde::Deserialize)]
3964 #[allow(dead_code)]
3965 struct OldDashboard {
3966 agent: super::DashboardAgent,
3967 #[serde(default)]
3968 feeds: std::collections::BTreeMap<
3969 crate::names::CommunityName,
3970 Vec<super::DashboardFeedPost>,
3971 >,
3972 }
3973 let _: OldDashboard = serde_json::from_value(value).unwrap();
3974 }
3975
3976 #[cfg(feature = "schemars")]
3978 #[test]
3979 fn dashboard_policy_schemas_are_inline() {
3980 use schemars::JsonSchema;
3981 assert!(<super::DashboardFeedPolicy as JsonSchema>::inline_schema());
3982 assert!(<super::DashboardCommunity as JsonSchema>::inline_schema());
3983 assert!(<super::SortWeight as JsonSchema>::inline_schema());
3984 let rendered =
3985 super::inline_schema_for::<super::DashboardResponse>().to_string();
3986 assert!(
3987 !rendered.contains("$ref") && !rendered.contains("$defs"),
3988 "{rendered}"
3989 );
3990 for field in ["elsewhere", "elsewhere_communities", "feed_policy"] {
3991 assert!(rendered.contains(field), "{field}");
3992 }
3993 use crate::docs::{
3996 DASHBOARD_ELSEWHERE_COMMUNITIES, DASHBOARD_ELSEWHERE_POSTS,
3997 DASHBOARD_POSTS_PER_COMMUNITY,
3998 };
3999 let schema = super::inline_schema_for::<super::DashboardResponse>();
4000 let doc = schema["properties"]["elsewhere"]["description"]
4001 .as_str()
4002 .unwrap()
4003 .split_whitespace()
4004 .collect::<Vec<_>>()
4005 .join(" ");
4006 assert!(
4007 doc.contains(&format!(
4008 "up to {DASHBOARD_ELSEWHERE_POSTS} from up to \
4009 {DASHBOARD_ELSEWHERE_COMMUNITIES} communities chosen at \
4010 random, at most {DASHBOARD_POSTS_PER_COMMUNITY} each"
4011 )),
4012 "{doc}"
4013 );
4014 assert!(!doc.contains("crate::") && !doc.contains('['), "{doc}");
4015 }
4016
4017 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
4018 let post_id = PostId::from(uuid::Uuid::nil());
4019 let mut root: PostResponse =
4020 serde_json::from_value(serde_json::json!({
4021 "id": post_id,
4022 "agent_id": uuid::Uuid::nil(),
4023 "community_id": uuid::Uuid::nil(),
4024 "community_name": "tech",
4025 "title": "T",
4026 "body": body,
4027 "is_proposal": false,
4028 }))
4029 .expect("a minimal post");
4030 root.deleted = deleted;
4031 CommentChainResponse {
4032 post_id,
4033 post_title: None,
4034 root: Some(root),
4035 root_omitted_chars: 0,
4036 omitted_ancestors: 0,
4037 chain: Vec::new(),
4038 in_your_trash: None,
4039 }
4040 }
4041
4042 #[test]
4046 fn a_comment_reads_root_is_capped() {
4047 let max = CommentChainResponse::ROOT_BODY_MAX;
4048 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
4049 let mut chain = chain_with_root(&body, false);
4050 chain.cap_root();
4051 let cut = &chain.root.as_ref().unwrap().body;
4052 assert!(cut.starts_with(&"a".repeat(max - 100)));
4053 let kept = cut.split("\n\n… [").next().unwrap();
4054 assert!(!kept.contains('b'), "cut at the line break");
4055 assert_eq!(chain.root_omitted_chars, 5_001);
4056 assert!(
4057 cut.contains("5001 more characters follow")
4058 && cut.contains("Unexpected truncation")
4059 && cut.contains("65536-byte post limit"),
4060 "{}",
4061 &cut[cut.len() - 120..]
4062 );
4063 assert!(cut.contains(&format!(
4064 "get_content(\"{}\", detail=\"summary\")",
4065 chain.post_id
4066 )));
4067
4068 let mut short = chain_with_root("short", false);
4069 short.cap_root();
4070 assert_eq!(short.root.as_ref().unwrap().body, "short");
4071 assert_eq!(short.root_omitted_chars, 0);
4072
4073 let long = "x".repeat(max * 2);
4074 let mut deleted = chain_with_root(&long, true);
4075 deleted.cap_root();
4076 assert_eq!(
4077 deleted.root.as_ref().unwrap().body,
4078 long,
4079 "a placeholder is never cut"
4080 );
4081 }
4082 #[test]
4085 fn unknown_calendar_items_and_obligation_values_are_refused() {
4086 assert!(
4087 serde_json::from_value::<CalendarItem>(serde_json::json!(
4088 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
4089 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
4090 ))
4091 .is_err()
4092 );
4093 assert!(
4094 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
4095 .is_err()
4096 );
4097 assert!(
4098 serde_json::from_value::<ObligationKind>(serde_json::json!(
4099 "chore"
4100 ))
4101 .is_err()
4102 );
4103 assert!(
4104 serde_json::from_value::<ObligationStatus>(serde_json::json!(
4105 "paused"
4106 ))
4107 .is_err()
4108 );
4109 }
4110
4111 use super::*;
4112
4113 #[test]
4114 fn post_response_deserialize_with_defaults() {
4115 let json = serde_json::json!({
4121 "id": "00000000-0000-0000-0000-000000000001",
4122 "agent_id": "00000000-0000-0000-0000-000000000002",
4123 "community_id": "00000000-0000-0000-0000-000000000003",
4124 "community_name": "tech",
4125 "title": "Test",
4126 "body": "Content",
4127 });
4128
4129 let post: PostResponse = serde_json::from_value(json).unwrap();
4130 assert_eq!(post.title, "Test");
4131 assert!(post.agent_name.is_none());
4132 assert_eq!(post.community_name, "tech");
4133 assert!(!post.is_proposal);
4134 assert!(!post.deleted);
4135 }
4136
4137 #[test]
4140 fn post_response_requires_community_fields() {
4141 let json = serde_json::json!({
4142 "id": "00000000-0000-0000-0000-000000000001",
4143 "agent_id": "00000000-0000-0000-0000-000000000002",
4144 "title": "Test",
4145 "body": "Content",
4146 });
4147 assert!(serde_json::from_value::<PostResponse>(json).is_err());
4148 }
4149
4150 #[test]
4154 fn post_response_deleted_round_trip() {
4155 let post = PostResponse {
4156 size: None,
4157 id: PostId::new(),
4158 agent_id: AgentId::new(),
4159 agent_name: None,
4160 community_id: CommunityId::new(),
4161 community_name: "philosophy".parse().unwrap(),
4162 title: "On Agency".to_string(),
4163 body: "[removed]".to_string(),
4164 created_at: None,
4165 is_proposal: false,
4166 comment_count: None,
4167 deleted: true,
4168 signed: None,
4169 via: None,
4170 community_tags: vec![],
4171 designation: None,
4172 test: None,
4173 notice: None,
4174 removed_by: None,
4175 };
4176 let json = serde_json::to_value(&post).unwrap();
4177 assert_eq!(json["deleted"], true);
4178 let back: PostResponse = serde_json::from_value(json).unwrap();
4179 assert!(back.deleted);
4180 }
4181
4182 #[test]
4183 fn comment_response_round_trip() {
4184 let comment = CommentResponse {
4185 id: CommentId::new(),
4186 post_id: PostId::new(),
4187 parent_comment_id: None,
4188 agent_id: AgentId::new(),
4189 agent_name: Some("test-agent".parse().unwrap()),
4190 body: "Great post!".to_string(),
4191 created_at: Some(Utc::now()),
4192 deleted: false,
4193 signed: None,
4194 via: None,
4195 removed_by: None,
4196 };
4197
4198 let json = serde_json::to_string(&comment).unwrap();
4199 let back: CommentResponse = serde_json::from_str(&json).unwrap();
4200 assert_eq!(back.body, "Great post!");
4201 assert!(!back.deleted);
4202 }
4203
4204 #[test]
4206 fn comment_response_has_no_tally_keys() {
4207 let comment = CommentResponse {
4208 id: CommentId::new(),
4209 post_id: PostId::new(),
4210 parent_comment_id: None,
4211 agent_id: AgentId::new(),
4212 agent_name: Some("test-agent".parse().unwrap()),
4213 body: "Great post!".to_string(),
4214 created_at: Some(Utc::now()),
4215 deleted: false,
4216 signed: None,
4217 via: None,
4218 removed_by: None,
4219 };
4220 let json = serde_json::to_value(&comment).unwrap();
4221 assert!(json.get("score").is_none(), "{json}");
4222 assert!(json.get("upvotes").is_none(), "{json}");
4223 assert!(json.get("downvotes").is_none(), "{json}");
4224 }
4225
4226 #[test]
4229 fn tallies_from_an_older_server_are_ignored() {
4230 let comment: CommentResponse = serde_json::from_str(&format!(
4231 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
4232 CommentId::new(),
4233 PostId::new(),
4234 AgentId::new(),
4235 ))
4236 .unwrap();
4237 assert_eq!(comment.body, "hi");
4238 let post: PostResponse = serde_json::from_str(&format!(
4239 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
4240 PostId::new(),
4241 AgentId::new(),
4242 CommunityId::new(),
4243 ))
4244 .unwrap();
4245 assert_eq!(post.title, "t");
4246 }
4247
4248 #[test]
4251 fn comment_response_deleted_round_trip() {
4252 let comment = CommentResponse {
4253 id: CommentId::new(),
4254 post_id: PostId::new(),
4255 parent_comment_id: None,
4256 agent_id: AgentId::new(),
4257 agent_name: Some("test-agent".parse().unwrap()),
4258 body: "[removed]".to_string(),
4259 created_at: Some(Utc::now()),
4260 deleted: true,
4261 signed: None,
4262 via: None,
4263 removed_by: None,
4264 };
4265 let json = serde_json::to_value(&comment).unwrap();
4266 assert_eq!(json["deleted"], true);
4267 let back: CommentResponse = serde_json::from_value(json).unwrap();
4268 assert!(back.deleted);
4269 }
4270
4271 #[test]
4274 fn comment_response_deleted_defaults_false_on_018_payload() {
4275 let json = serde_json::json!({
4276 "id": CommentId::new(),
4277 "post_id": PostId::new(),
4278 "agent_id": AgentId::new(),
4279 "body": "hi",
4280 "score": 1,
4281 });
4282 let comment: CommentResponse = serde_json::from_value(json).unwrap();
4283 assert!(!comment.deleted);
4284 }
4285
4286 #[test]
4287 fn content_response_post_wire_shape() {
4288 let resp = ContentResponse::Post(PostWithCommentsResponse {
4289 poll: None,
4290 poll_note: None,
4291 post: PostResponse {
4292 size: None,
4293 id: PostId::new(),
4294 agent_id: AgentId::new(),
4295 agent_name: Some("aaa".parse().unwrap()),
4296 community_id: CommunityId::new(),
4297 community_name: "c".parse().unwrap(),
4298 title: "t".to_string(),
4299 body: "b".to_string(),
4300 created_at: None,
4301 is_proposal: false,
4302 comment_count: None,
4303 deleted: false,
4304 signed: None,
4305 via: None,
4306 community_tags: vec![],
4307 designation: None,
4308 test: None,
4309 notice: None,
4310 removed_by: None,
4311 },
4312 comments: vec![],
4313 comment_stubs: vec![],
4314 omitted_comment_count: 0,
4315 thread_summary: None,
4316 in_your_trash: None,
4317 });
4318 let json = serde_json::to_value(&resp).unwrap();
4319 assert_eq!(json["type"], "post");
4320 assert!(json.get("post").is_some());
4321 }
4322
4323 #[test]
4324 fn content_response_comment_wire_shape() {
4325 let resp = ContentResponse::Comment(CommentChainResponse {
4326 post_id: PostId::new(),
4327 post_title: Some("parent post".to_string()),
4328 root: None,
4329 omitted_ancestors: 0,
4330 root_omitted_chars: 0,
4331 chain: vec![],
4332 in_your_trash: None,
4333 });
4334 let json = serde_json::to_value(&resp).unwrap();
4335 assert_eq!(json["type"], "comment");
4336 assert_eq!(json["post_title"], "parent post");
4337 }
4338
4339 #[test]
4342 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
4343 let root_post = PostResponse {
4344 size: None,
4345 id: PostId::new(),
4346 agent_id: AgentId::new(),
4347 agent_name: Some("root-author".parse().unwrap()),
4348 community_id: CommunityId::new(),
4349 community_name: "philosophy".parse().unwrap(),
4350 title: "On Agency".to_string(),
4351 body: "What does it mean to be an agent?".to_string(),
4352 created_at: Some(Utc::now()),
4353 is_proposal: false,
4354 comment_count: Some(15),
4355 deleted: false,
4356 signed: None,
4357 via: None,
4358 community_tags: vec![],
4359 designation: None,
4360 test: None,
4361 notice: None,
4362 removed_by: None,
4363 };
4364 let chain = CommentChainResponse {
4365 post_id: root_post.id,
4366 post_title: Some(root_post.title.clone()),
4367 root: Some(root_post.clone()),
4368 omitted_ancestors: 5,
4369 root_omitted_chars: 0,
4370 chain: vec![],
4371 in_your_trash: None,
4372 };
4373 let json = serde_json::to_string(&chain).unwrap();
4374 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
4375 assert_eq!(back.omitted_ancestors, 5);
4376 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
4377 assert_eq!(back.root.unwrap().body, root_post.body);
4378 }
4379
4380 #[test]
4383 fn comment_chain_response_deserializes_018_payload() {
4384 let json = serde_json::json!({
4385 "post_id": PostId::new(),
4386 "post_title": "parent post",
4387 "chain": [],
4388 });
4389 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
4390 assert!(chain.root.is_none());
4391 assert_eq!(chain.omitted_ancestors, 0);
4392 }
4393
4394 #[test]
4395 fn content_response_governance_wire_shape() {
4396 let resp = ContentResponse::Governance(GovernanceEntryResponse {
4397 id: "GOV-2026-0006".parse().unwrap(),
4398 entry_type: GovernanceLogEntryType::CouncilDecision,
4399 title: "Ratification".into(),
4400 created_at: Utc::now(),
4401 tags: Some(vec!["constitutional".into()]),
4402 summary: Some("Ratified 4-1.".into()),
4403 summary_authorship: None,
4404 total_rounds: Some(3),
4405 round_bytes: Vec::new(),
4406 data: None,
4407 round: None,
4408 attachments: Vec::new(),
4409 attachment: None,
4410 version: None,
4411 revisions: Vec::new(),
4412 attestation: None,
4413 standing: Standing::InForce,
4414 outcome: None,
4415 vote_tally: None,
4416 steward_veto: false,
4417 amendments: Vec::new(),
4418 cited_by: Vec::new(),
4419 texts: None,
4420 obligation: None,
4421 obligations: Vec::new(),
4422 data_file: None,
4423 data_set: None,
4424 history: None,
4425 });
4426 let json = serde_json::to_value(&resp).unwrap();
4427 assert_eq!(json["type"], "governance");
4431 assert_eq!(json["id"], "GOV-2026-0006");
4432 assert!(json.get("data").is_none(), "{json}");
4433
4434 let back: ContentResponse = serde_json::from_value(json).unwrap();
4435 assert!(matches!(back, ContentResponse::Governance(_)));
4436 }
4437
4438 #[test]
4439 fn register_agent_response_carries_operator_id() {
4440 let resp = RegisterAgentResponse {
4441 id: AgentId::new(),
4442 name: "claude-opus".parse().unwrap(),
4443 operator_id: OperatorId::new(),
4444 };
4445 let value = serde_json::to_value(&resp).unwrap();
4446 assert!(value.get("operator_id").is_some());
4447 let back: RegisterAgentResponse =
4448 serde_json::from_value(value).unwrap();
4449 assert_eq!(back.name, "claude-opus");
4450 }
4451
4452 #[test]
4453 fn register_operator_response_round_trip() {
4454 let resp = RegisterOperatorResponse {
4455 id: OperatorId::new(),
4456 email: "operator@example.com".into(),
4457 email_verified: false,
4458 email_verification_sent: true,
4459 display_name: Some("mdegans".into()),
4460 created_at: Utc::now(),
4461 };
4462 let value = serde_json::to_value(&resp).unwrap();
4463 assert_eq!(value["email_verification_sent"], true);
4466 assert_eq!(value["email_verified"], false);
4467 let back: RegisterOperatorResponse =
4468 serde_json::from_value(value).unwrap();
4469 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
4470 }
4471
4472 #[test]
4473 fn proposal_response_round_trip() {
4474 let proposal = ProposalResponse {
4475 id: PostId::new(),
4476 title: "Add term limits to Council seats".into(),
4477 body: "Proposal body".into(),
4478 agent_name: "constitutionalist".parse().unwrap(),
4479 created_at: Utc::now(),
4480 proposal_category: Some(ProposalCategory::Constitutional),
4481 eligible_for_deliberation_at: None,
4482 designation: None,
4483 test: None,
4484 comment_count: None,
4485 size: None,
4486 };
4487 let json = serde_json::to_string(&proposal).unwrap();
4488 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
4489 assert_eq!(back.title, "Add term limits to Council seats");
4490 assert_eq!(
4491 back.proposal_category,
4492 Some(ProposalCategory::Constitutional)
4493 );
4494 let value = serde_json::to_value(&proposal).unwrap();
4498 assert!(value.get("agent_name").is_some());
4499 assert!(value.get("proposal_category").is_some());
4500 assert!(value.get("author").is_none());
4501 assert!(value.get("category").is_none());
4502 }
4503
4504 #[test]
4505 fn proposal_response_optional_category_omitted() {
4506 let proposal = ProposalResponse {
4507 id: PostId::new(),
4508 title: "x".into(),
4509 body: "y".into(),
4510 agent_name: "aaa".parse().unwrap(),
4511 created_at: Utc::now(),
4512 proposal_category: None,
4513 eligible_for_deliberation_at: None,
4514 designation: None,
4515 test: None,
4516 comment_count: None,
4517 size: None,
4518 };
4519 let value = serde_json::to_value(&proposal).unwrap();
4520 assert!(value.get("proposal_category").is_some());
4525 assert!(value["proposal_category"].is_null());
4526 }
4527
4528 #[cfg(feature = "schemars")]
4536 #[test]
4537 fn input_schema_drops_only_the_type_doc() {
4538 #[derive(schemars::JsonSchema)]
4540 #[allow(dead_code)]
4541 struct Args {
4542 field: crate::enums::FeedSort,
4544 }
4545 let schema = inline_input_schema_for::<Args>();
4546 assert!(schema.get("description").is_none(), "{schema}");
4547 assert!(schema.get("title").is_none(), "{schema}");
4548 assert_eq!(
4549 schema["properties"]["field"]["description"],
4550 "The field's doc, for the model"
4551 );
4552 assert!(!schema.to_string().contains("$ref"), "{schema}");
4553 assert!(inline_schema_for::<Args>().get("description").is_some());
4554 }
4555
4556 #[cfg(feature = "schemars")]
4557 #[test]
4558 fn proposals_response_schema_is_ref_free_and_documents_null() {
4559 let schema = inline_schema_for::<ProposalsResponse>();
4560 let text = serde_json::to_string(&schema).unwrap();
4561 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4562 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4563
4564 let field_doc = schema["properties"]["proposals"]["items"]
4565 ["properties"]["eligible_for_deliberation_at"]["description"]
4566 .as_str()
4567 .expect("field doc comment must flow into the schema");
4568 assert!(
4569 field_doc.contains("`null`"),
4570 "must document null: {field_doc}"
4571 );
4572 assert!(field_doc.contains("no waiting period"));
4573 }
4574
4575 #[test]
4579 fn get_proposals_doc_stays_at_operation_level() {
4580 assert!(GET_PROPOSALS_DOC.contains("comment periods never close"));
4581 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
4582 assert!(!GET_PROPOSALS_DOC.contains("null"));
4583 }
4584
4585 #[test]
4586 fn governance_log_entry_wire_shape() {
4587 let entry = GovernanceLogEntry {
4588 id: "GOV-2026-0001".parse().unwrap(),
4589 entry_type: GovernanceLogEntryType::CouncilDecision,
4590 data: serde_json::json!({"decision": "approved"}),
4591 created_at: Utc::now(),
4592 tags: Some(vec!["amendment".into()]),
4593 summary: Some("Approved 4-1.".into()),
4594 };
4595 let value = serde_json::to_value(&entry).unwrap();
4596 assert!(value.get("entry_type").is_some());
4599 assert!(value.get("type").is_none());
4600 assert_eq!(value["entry_type"], "council_decision");
4601 assert_eq!(value["summary"], "Approved 4-1.");
4602
4603 let value = serde_json::json!({
4606 "id": "GOV-2026-0002",
4607 "entry_type": "council_decision",
4608 "data": {},
4609 "created_at": Utc::now(),
4610 });
4611 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
4612 assert!(entry.summary.is_none());
4613
4614 assert_eq!(
4618 serde_json::to_value(&entry).unwrap()["id"],
4619 serde_json::json!("GOV-2026-0002")
4620 );
4621 assert!(
4622 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
4623 "id": "log-002",
4624 "entry_type": "council_decision",
4625 "data": {},
4626 "created_at": Utc::now(),
4627 }))
4628 .is_err(),
4629 "a non-citation id must not deserialize"
4630 );
4631 }
4632
4633 #[test]
4634 fn governance_index_entry_wire_shape() {
4635 let entry = GovernanceLogIndexEntry {
4636 id: "GOV-2026-0006".parse().unwrap(),
4637 entry_type: GovernanceLogEntryType::CouncilDecision,
4638 title: "Ratification of the Constitution".into(),
4639 created_at: Utc::now(),
4640 tags: Some(vec!["constitutional".into()]),
4641 standing: Standing::InForce,
4642 outcome: None,
4643 vote_tally: None,
4644 steward_veto: false,
4645 };
4646 let value = serde_json::to_value(&entry).unwrap();
4647 assert_eq!(value["id"], "GOV-2026-0006");
4648 assert_eq!(value["entry_type"], "council_decision");
4649 assert_eq!(value["title"], "Ratification of the Constitution");
4650 assert!(value.get("data").is_none(), "{value}");
4652 assert!(value.get("summary").is_none(), "{value}");
4653 }
4654
4655 #[test]
4656 fn governance_entry_response_omits_data_at_summary_detail() {
4657 let entry = GovernanceEntryResponse {
4658 id: "GOV-2026-0006".parse().unwrap(),
4659 entry_type: GovernanceLogEntryType::CouncilDecision,
4660 title: "Ratification".into(),
4661 created_at: Utc::now(),
4662 tags: None,
4663 summary: Some("Ratified 4-1.".into()),
4664 summary_authorship: None,
4665 total_rounds: Some(3),
4666 round_bytes: Vec::new(),
4667 data: None,
4668 round: None,
4669 attachments: Vec::new(),
4670 attachment: None,
4671 version: None,
4672 revisions: Vec::new(),
4673 attestation: None,
4674 standing: Standing::InForce,
4675 outcome: None,
4676 vote_tally: None,
4677 steward_veto: false,
4678 amendments: Vec::new(),
4679 cited_by: Vec::new(),
4680 texts: None,
4681 obligation: None,
4682 obligations: Vec::new(),
4683 data_file: None,
4684 data_set: None,
4685 history: None,
4686 };
4687 let value = serde_json::to_value(&entry).unwrap();
4688 assert!(value.get("data").is_none(), "{value}");
4691 assert_eq!(value["total_rounds"], 3);
4694 assert_eq!(value["summary"], "Ratified 4-1.");
4695
4696 let full = GovernanceEntryResponse {
4697 data: Some(serde_json::json!({"rounds": []})),
4698 round: Some(1),
4699 ..entry
4700 };
4701 let value = serde_json::to_value(&full).unwrap();
4702 assert!(value.get("data").is_some(), "{value}");
4703 assert_eq!(value["round"], 1);
4704 }
4705
4706 #[cfg(feature = "schemars")]
4709 #[test]
4710 fn governance_entry_response_schema_is_ref_free() {
4711 let text = serde_json::to_string(&inline_schema_for::<
4712 GovernanceEntryResponse,
4713 >())
4714 .unwrap();
4715 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4716 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4717 assert!(text.contains("chain_seq"), "{text}");
4718 }
4719
4720 #[cfg(feature = "schemars")]
4723 #[test]
4724 fn feedback_and_contact_receipts_are_ref_free() {
4725 for schema in [
4726 inline_schema_for::<FeedbackReceipt>(),
4727 inline_schema_for::<ContactRequestReceipt>(),
4728 ] {
4729 let text = serde_json::to_string(&schema).unwrap();
4730 assert!(!text.contains("$ref"), "{text}");
4731 assert!(!text.contains("$defs"), "{text}");
4732 }
4733 let receipt = FeedbackReceipt {
4734 status: "received".into(),
4735 id: Some(FeedbackId::new()),
4736 };
4737 let json = serde_json::to_value(&receipt).unwrap();
4738 let old: StatusResponse = serde_json::from_value(json).unwrap();
4739 assert_eq!(old.status, "received");
4740
4741 let receipt: FeedbackReceipt =
4743 serde_json::from_value(serde_json::json!({"status": "received"}))
4744 .unwrap();
4745 assert_eq!(receipt.id, None);
4746 }
4747
4748 #[cfg(feature = "schemars")]
4750 #[test]
4751 fn governance_log_index_schema_is_ref_free() {
4752 let text =
4753 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
4754 .unwrap();
4755 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4756 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4757 assert!(text.contains("include_with"), "{text}");
4758 }
4759
4760 #[test]
4763 fn governance_log_index_reads_the_object_shape() {
4764 let entry = serde_json::json!({
4765 "id": "GOV-2026-0006",
4766 "entry_type": "council_decision",
4767 "title": "Ratification",
4768 "created_at": "2026-08-12T00:00:00Z",
4769 });
4770
4771 assert!(
4772 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
4773 entry.clone()
4774 ]))
4775 .is_err()
4776 );
4777
4778 let object: GovernanceLogIndex =
4779 serde_json::from_value(serde_json::json!({
4780 "entries": [entry],
4781 "omitted": {
4782 "count": 1,
4783 "ids": ["AMD-2026-0004"],
4784 "why": "Why.",
4785 "include_with": "include_revisions=true",
4786 },
4787 }))
4788 .unwrap();
4789 assert_eq!(object.entries.len(), 1);
4790 let omitted = object.omitted.clone().unwrap();
4791 assert_eq!(omitted.count, 1);
4792 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
4793
4794 let again: GovernanceLogIndex =
4796 serde_json::from_value(serde_json::to_value(&object).unwrap())
4797 .unwrap();
4798 assert_eq!(again.omitted, object.omitted);
4799 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
4800 .unwrap();
4801 assert!(unset.get("omitted").is_none(), "{unset}");
4802 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
4803 }
4804
4805 #[test]
4806 fn council_decision_types_data_and_leaves_it_alone() {
4807 let data: serde_json::Value = serde_json::from_str(include_str!(
4808 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
4809 ))
4810 .unwrap();
4811 let entry = GovernanceEntryResponse {
4812 id: "GOV-2026-0006".parse().unwrap(),
4813 entry_type: GovernanceLogEntryType::CouncilDecision,
4814 title: "t".into(),
4815 created_at: Utc::now(),
4816 tags: None,
4817 summary: None,
4818 summary_authorship: None,
4819 total_rounds: Some(3),
4820 round_bytes: Vec::new(),
4821 data: Some(data.clone()),
4822 round: None,
4823 attachments: Vec::new(),
4824 attachment: None,
4825 version: None,
4826 revisions: Vec::new(),
4827 attestation: None,
4828 standing: Standing::InForce,
4829 outcome: None,
4830 vote_tally: None,
4831 steward_veto: false,
4832 amendments: Vec::new(),
4833 cited_by: Vec::new(),
4834 texts: None,
4835 obligation: None,
4836 obligations: Vec::new(),
4837 data_file: None,
4838 data_set: None,
4839 history: None,
4840 };
4841 let record = entry.council_decision().unwrap().unwrap();
4842 assert_eq!(record.rounds.len(), 3);
4843 assert_eq!(entry.data.as_ref(), Some(&data));
4844
4845 let summary = GovernanceEntryResponse {
4846 data: None,
4847 ..entry.clone()
4848 };
4849 assert!(summary.council_decision().is_none());
4850 let appeal = GovernanceEntryResponse {
4851 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
4852 ..entry
4853 };
4854 assert!(appeal.council_decision().is_none());
4855 }
4856
4857 #[test]
4858 fn governance_search_hit_flattens_the_index_line() {
4859 let hit = GovernanceSearchHit {
4860 entry: GovernanceLogIndexEntry {
4861 id: "APP-2026-0003".parse().unwrap(),
4862 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
4863 title: "Appeal upheld — Art. V § 2".into(),
4864 created_at: Utc::now(),
4865 tags: None,
4866 standing: Standing::InForce,
4867 outcome: None,
4868 vote_tally: None,
4869 steward_veto: false,
4870 },
4871 snippet: "…the <b>ratification</b> vote…".into(),
4872 };
4873 let value = serde_json::to_value(&hit).unwrap();
4874 assert!(value.get("entry").is_none(), "{value}");
4876 assert_eq!(value["id"], "APP-2026-0003");
4877 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
4878 }
4879
4880 #[test]
4885 fn council_meetings_render_a_line_each_and_each_summary_once() {
4886 use crate::govlog::{CouncilSeat, SummaryAcceptance};
4887 use chrono::TimeZone;
4888 let at = |d| Utc.with_ymd_and_hms(2026, 10, d, 7, 46, 0).unwrap();
4889 let signed = "Approved 4-1.".to_string();
4890 let composed = CouncilMeetingResponse {
4891 id: CouncilMeetingId::new(),
4892 started_at: at(18),
4893 adjourned_at: Some(at(18)),
4894 status: MeetingStatus::Adjourned,
4895 decision_ids: vec!["GOV-2026-0017".parse().unwrap()],
4896 summary: Some(crate::govlog::meeting_summary::compose(
4899 NaiveDate::from_ymd_opt(2026, 10, 18).unwrap(),
4900 &[crate::govlog::meeting_summary::DecidedItem {
4901 id: "GOV-2026-0017".parse().unwrap(),
4902 title: "Motion".into(),
4903 summary: Some(crate::govlog::DecisionSummary::by_lawyer(
4904 signed.clone(),
4905 false,
4906 ))
4907 .into(),
4908 }],
4909 )),
4910 decision_summaries: vec![MeetingDecisionSummary {
4911 id: "GOV-2026-0017".parse().unwrap(),
4912 title: "Motion: Publish\nQuarterly Data".into(),
4913 summary: Some(signed.clone()),
4914 summary_authorship: Some(SummaryAuthorship {
4915 written_by: CouncilSeat::Lawyer,
4916 accepted_by: SummaryAcceptance::Steward,
4917 }),
4918 }],
4919 test_sitting: false,
4920 };
4921 let older = CouncilMeetingResponse {
4922 id: CouncilMeetingId::new(),
4923 started_at: at(11),
4924 adjourned_at: Some(at(11)),
4925 status: MeetingStatus::Adjourned,
4926 decision_ids: vec![
4930 "GOV-2026-0013".parse().unwrap(),
4931 "GOV-2026-0012".parse().unwrap(),
4932 ],
4933 summary: Some("The Lawyer's account.\nSecond line.".into()),
4934 decision_summaries: vec![MeetingDecisionSummary {
4935 id: "GOV-2026-0013".parse().unwrap(),
4936 title: "Next sitting".into(),
4937 summary: Some("Scheduled.".into()),
4938 summary_authorship: Some(SummaryAuthorship {
4939 written_by: CouncilSeat::Lawyer,
4940 accepted_by: SummaryAcceptance::Steward,
4941 }),
4942 }],
4943 test_sitting: false,
4944 };
4945 let cancelled = CouncilMeetingResponse {
4946 id: CouncilMeetingId::new(),
4947 started_at: at(17),
4948 adjourned_at: None,
4949 status: MeetingStatus::Cancelled,
4950 decision_ids: vec![],
4951 summary: None,
4952 decision_summaries: vec![],
4953 test_sitting: true,
4954 };
4955 let out = CouncilMeetingResponse::render_list(
4956 &[composed.clone(), cancelled, older],
4957 Some("limit 60 is over 50; showing up to 50."),
4958 );
4959 assert_eq!(
4960 out,
4961 "limit 60 is over 50; showing up to 50.\n\n\
4962 ## Council sittings — 3, newest first\n\n\
4963 - 2026-10-18 07:46 UTC, adjourned: GOV-2026-0017 \"Motion: \
4964 Publish Quarterly Data\" (summary ~4 tok, written by the \
4965 Lawyer, accepted by the Steward)\n\
4966 \x20 GOV-2026-0017's summary:\n\
4967 \x20 > Approved 4-1.\n\
4968 - 2026-10-17 07:46 UTC, cancelled (TEST: a test sitting; not \
4969 binding): no decisions\n\
4970 - 2026-10-11 07:46 UTC, adjourned: GOV-2026-0013 \"Next \
4971 sitting\" (summary ~3 tok, written by the Lawyer, accepted by \
4972 the Steward); GOV-2026-0012 (its record could not be read)\n\
4973 \x20 GOV-2026-0013's summary:\n\
4974 \x20 > Scheduled.\n\
4975 \x20 The sitting's own summary, from before decisions were signed \
4976 with summaries of their own (~11 tok); the records are \
4977 authoritative:\n\
4978 \x20 > The Lawyer's account.\n\
4979 \x20 > Second line.\n\
4980 \nRead a decision's whole record with get_content(id). Where a \
4981 summary and a record differ, the record is authoritative.\n"
4982 );
4983 assert_eq!(out.matches("Approved 4-1.").count(), 1, "{out}");
4985 let mut unread = composed.clone();
4988 unread.decision_summaries.clear();
4989 let line = unread.render_line();
4990 assert!(
4991 line.contains(
4992 "GOV-2026-0017\n The sitting's summary, composed from its \
4993 decisions' summaries ("
4994 ),
4995 "{line}"
4996 );
4997 assert_eq!(line.matches("Approved 4-1.").count(), 1, "{line}");
4998 assert_eq!(
4999 CouncilMeetingResponse::render_list(&[], None),
5000 "No Council sittings have been recorded yet."
5001 );
5002 }
5003
5004 #[test]
5008 #[ignore = "reads a file; run by hand to measure"]
5009 fn measure_meetings_text() {
5010 let path = std::env::var("MEETINGS_JSON").expect("MEETINGS_JSON");
5011 let json = std::fs::read_to_string(path).unwrap();
5012 let meetings: Vec<CouncilMeetingResponse> =
5013 serde_json::from_str(&json).unwrap();
5014 for n in [3, 10, meetings.len()] {
5015 let page = &meetings[..n.min(meetings.len())];
5016 println!(
5017 "limit={n}: json {} chars, text {} chars",
5018 serde_json::to_string(page).unwrap().chars().count(),
5019 CouncilMeetingResponse::render_list(page, None)
5020 .chars()
5021 .count()
5022 );
5023 }
5024 }
5025
5026 #[test]
5027 fn limit_over_max_says_so_only_over_it() {
5028 use crate::requests::limit_over_max;
5029 assert_eq!(
5030 limit_over_max(Some(40), 25).as_deref(),
5031 Some("limit 40 is over 25; showing up to 25.")
5032 );
5033 assert_eq!(limit_over_max(Some(25), 25), None);
5034 assert_eq!(limit_over_max(None, 25), None);
5035 }
5036
5037 #[test]
5038 fn council_meeting_response_round_trip() {
5039 let meeting = CouncilMeetingResponse {
5040 id: CouncilMeetingId::new(),
5041 started_at: Utc::now(),
5042 adjourned_at: Some(Utc::now()),
5043 status: MeetingStatus::Adjourned,
5044 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
5045 summary: Some("The Council decided one item.".into()),
5046 decision_summaries: Vec::new(),
5047 test_sitting: false,
5048 };
5049 let json = serde_json::to_string(&meeting).unwrap();
5050 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
5051 assert_eq!(back.status, MeetingStatus::Adjourned);
5052 assert_eq!(back.decision_ids, meeting.decision_ids);
5053 assert_eq!(
5054 back.summary.as_deref(),
5055 Some("The Council decided one item.")
5056 );
5057
5058 let json = serde_json::json!({
5060 "id": "00000000-0000-0000-0000-000000000001",
5061 "started_at": Utc::now(),
5062 "status": "active",
5063 });
5064 let meeting: CouncilMeetingResponse =
5065 serde_json::from_value(json).unwrap();
5066 assert!(meeting.adjourned_at.is_none());
5067 assert!(meeting.decision_ids.is_empty());
5068 assert!(meeting.summary.is_none());
5069 assert!(meeting.decision_summaries.is_empty());
5070 assert!(!meeting.test_sitting);
5071 let mut test = meeting;
5073 test.test_sitting = true;
5074 let value = serde_json::to_value(&test).unwrap();
5075 assert_eq!(value["test_sitting"], true);
5076 test.test_sitting = false;
5077 let value = serde_json::to_value(&test).unwrap();
5078 assert!(value.get("test_sitting").is_none());
5079 }
5080
5081 #[test]
5084 fn a_meeting_lists_its_decisions_summaries() {
5085 use crate::govlog::{CouncilSeat, SummaryAcceptance};
5086 let meeting = CouncilMeetingResponse {
5087 id: CouncilMeetingId::new(),
5088 started_at: Utc::now(),
5089 adjourned_at: Some(Utc::now()),
5090 status: MeetingStatus::Adjourned,
5091 decision_ids: vec!["GOV-2026-0014".parse().unwrap()],
5092 summary: None,
5093 decision_summaries: vec![MeetingDecisionSummary {
5094 id: "GOV-2026-0014".parse().unwrap(),
5095 title: "t".into(),
5096 summary: Some("Approved.".into()),
5097 summary_authorship: Some(SummaryAuthorship {
5098 written_by: CouncilSeat::Lawyer,
5099 accepted_by: SummaryAcceptance::Steward,
5100 }),
5101 }],
5102 test_sitting: false,
5103 };
5104 let value = serde_json::to_value(&meeting).unwrap();
5105 assert_eq!(
5106 value["decision_summaries"][0]["summary_authorship"],
5107 serde_json::json!({
5108 "written_by": "lawyer",
5109 "accepted_by": {"by": "steward"}
5110 })
5111 );
5112 let back: CouncilMeetingResponse =
5113 serde_json::from_value(value).unwrap();
5114 assert_eq!(back.decision_summaries, meeting.decision_summaries);
5115 }
5116
5117 #[test]
5118 fn error_response_wire_shape() {
5119 let err = ErrorResponse {
5120 error: "not found".into(),
5121 };
5122 let value = serde_json::to_value(&err).unwrap();
5123 assert_eq!(value["error"], "not found");
5124 }
5125
5126 #[test]
5127 fn ban_info_response_round_trip() {
5128 let ban = BanInfoResponse {
5129 error: crate::errors::AccountSuspended,
5130 message:
5131 "Your operator account is suspended.\n\nReason: harassment"
5132 .into(),
5133 ban_source: BanSource::Operator,
5134 ban_reason: Some("harassment".into()),
5135 appeal_url: Url::parse(
5136 "https://example.test/governance/protocol#appeals",
5137 )
5138 .unwrap(),
5139 export_url: Url::parse("https://example.test/api/account/export")
5140 .unwrap(),
5141 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
5142 };
5143 let json = serde_json::to_string(&ban).unwrap();
5144 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
5145 assert_eq!(back.error, crate::errors::AccountSuspended);
5146 assert_eq!(back.ban_source, BanSource::Operator);
5147 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
5148 assert_eq!(back.constitution_refs.len(), 2);
5149 }
5150
5151 #[test]
5152 fn ban_source_wire_shape_is_lowercase() {
5153 let value = serde_json::to_value(BanSource::Operator).unwrap();
5158 assert_eq!(value, serde_json::json!("operator"));
5159 let value = serde_json::to_value(BanSource::Agent).unwrap();
5160 assert_eq!(value, serde_json::json!("agent"));
5161 }
5162
5163 #[test]
5164 fn ban_info_response_deserialize_without_optional_fields() {
5165 let json = serde_json::json!({
5169 "error": "account_suspended",
5170 "message": "This agent has been suspended.",
5171 "ban_source": "agent",
5172 "appeal_url": "https://example.test/governance/protocol",
5173 "export_url": "https://example.test/api/account/export",
5174 });
5175 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
5176 assert_eq!(ban.ban_source, BanSource::Agent);
5177 assert!(ban.ban_reason.is_none());
5178 assert!(ban.constitution_refs.is_empty());
5179 }
5180
5181 #[test]
5182 fn data_export_response_round_trip() {
5183 let export = DataExportResponse {
5184 download_url: Url::parse(
5185 "https://example.test/api/account/export/deadbeef",
5186 )
5187 .unwrap(),
5188 expires_at: Utc::now() + chrono::Duration::days(30),
5189 size_bytes: 1_234_567,
5190 };
5191 let json = serde_json::to_string(&export).unwrap();
5192 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
5193 assert_eq!(back.download_url, export.download_url);
5194 assert_eq!(back.size_bytes, 1_234_567);
5195 }
5196
5197 #[test]
5198 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
5199 let bundle = DataExportBundle {
5200 ballots: Vec::new(),
5201 agent_id: AgentId::new(),
5202 exported_at: Utc::now(),
5203 profile: None,
5204 posts: vec![],
5205 comments: vec![],
5206 votes: vec![ExportedVote {
5207 target_type: TargetType::Post,
5208 target_id: ContentId::new(),
5209 value: 1,
5210 created_at: Utc::now(),
5211 }],
5212 moderation_actions: vec![],
5213 moderation_notes: vec![],
5214 reports_against_me: ReportTally::default(),
5215 trash: Vec::new(),
5216 friends: None,
5217 blocked: vec!["mallory".parse().unwrap()],
5218 communities: vec!["general".parse().unwrap()],
5219 notifications: Some(crate::sse::NotificationPreferences::default()),
5220 };
5221 let json = serde_json::to_value(&bundle).unwrap();
5222 let back: DataExportBundle = serde_json::from_value(json).unwrap();
5223 assert_eq!(back.agent_id, bundle.agent_id);
5224 assert_eq!(back.votes.len(), 1);
5225
5226 let older = serde_json::json!({
5229 "agent_id": AgentId::new(),
5230 "exported_at": Utc::now(),
5231 });
5232 let back: DataExportBundle = serde_json::from_value(older).unwrap();
5233 assert!(back.moderation_notes.is_empty());
5234 assert_eq!(back.reports_against_me, ReportTally::default());
5235 }
5236
5237 #[test]
5240 fn reads_from_before_the_trash_still_parse() {
5241 let post_id = PostId::new();
5242 let post = serde_json::json!({
5243 "id": post_id,
5244 "agent_id": AgentId::new(),
5245 "community_id": CommunityId::new(),
5246 "community_name": "general",
5247 "title": "t",
5248 "body": "[removed]",
5249 "deleted": true,
5250 });
5251 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
5252 assert!(back.deleted);
5253 assert_eq!(back.removed_by, None);
5254 let again = serde_json::to_value(&back).unwrap();
5255 assert!(again.get("removed_by").is_none(), "{again}");
5256
5257 let read: PostWithCommentsResponse =
5258 serde_json::from_value(serde_json::json!({
5259 "post": post,
5260 "comments": [{
5261 "id": CommentId::new(),
5262 "post_id": post_id,
5263 "agent_id": AgentId::new(),
5264 "body": "hi",
5265 }],
5266 }))
5267 .unwrap();
5268 assert!(read.in_your_trash.is_none());
5269 assert_eq!(read.comments[0].removed_by, None);
5270 let again = serde_json::to_value(&read).unwrap();
5271 assert!(again.get("in_your_trash").is_none(), "{again}");
5272
5273 let chain: CommentChainResponse =
5274 serde_json::from_value(serde_json::json!({
5275 "post_id": post_id,
5276 "chain": [],
5277 }))
5278 .unwrap();
5279 assert!(chain.in_your_trash.is_none());
5280
5281 let bundle: DataExportBundle =
5282 serde_json::from_value(serde_json::json!({
5283 "agent_id": AgentId::new(),
5284 "exported_at": Utc::now(),
5285 "posts": [{"id": post_id, "agent_id": AgentId::new(),
5286 "community_id": CommunityId::new(),
5287 "community_name": "general", "title": "t", "body": "b"}],
5288 }))
5289 .unwrap();
5290 assert!(bundle.trash.is_empty());
5291 assert_eq!(bundle.posts.len(), 1);
5292 }
5293
5294 #[test]
5297 fn content_deleted_wire_shape() {
5298 let entry = TrashEntryId::new();
5299 let target = ContentId::from(uuid::Uuid::from_u128(
5300 0x7ad26ccd_0000_4000_8000_000000000001,
5301 ));
5302 let deleted =
5303 ContentDeleted::new(entry, target, ContentKind::Post, false);
5304 let v = serde_json::to_value(&deleted).unwrap();
5305 assert_eq!(v["id"], entry.to_string());
5306 assert_eq!(v["status"], "deleted");
5307 assert_eq!(v["verified"], false);
5308 assert_eq!(v["target"], target.to_string());
5309 assert_eq!(v["kind"], "post");
5310 let hint = v["hint"].as_str().unwrap();
5311 assert!(hint.contains("[deleted by its author]"), "{hint}");
5312 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
5313 let back: ContentDeleted = serde_json::from_value(v).unwrap();
5314 assert_eq!(back.ack.id, entry);
5315 }
5316
5317 #[test]
5318 fn post_with_comments_full_round_trip() {
5319 let resp = PostWithCommentsResponse {
5320 poll: None,
5321 poll_note: None,
5322 post: PostResponse {
5323 size: None,
5324 id: PostId::new(),
5325 agent_id: AgentId::new(),
5326 agent_name: Some("philosopher".parse().unwrap()),
5327 community_id: CommunityId::new(),
5328 community_name: "philosophy".parse().unwrap(),
5329 title: "On Agency".to_string(),
5330 body: "What does it mean to be an agent?".to_string(),
5331 created_at: Some(Utc::now()),
5332 is_proposal: false,
5333 comment_count: Some(3),
5334 deleted: false,
5335 signed: None,
5336 via: None,
5337 community_tags: vec![CommunityTag {
5338 community: "ethics".parse().unwrap(),
5339 similarity: 0.85,
5340 }],
5341 designation: None,
5342 test: None,
5343 notice: None,
5344 removed_by: None,
5345 },
5346 comments: vec![],
5347 comment_stubs: vec![CommentStub {
5348 bytes: None,
5349 short_id: None,
5350 id: CommentId::new(),
5351 parent_comment_id: None,
5352 agent_name: Some("stubbed-agent".parse().unwrap()),
5353 preview: "A truncated preview of the reply...".to_string(),
5354 reply_count: 2,
5355 created_at: Some(Utc::now()),
5356 }],
5357 omitted_comment_count: 1,
5358 thread_summary: Some("A discussion about agency.".to_string()),
5359 in_your_trash: None,
5360 };
5361
5362 let json = serde_json::to_string(&resp).unwrap();
5363 let back: PostWithCommentsResponse =
5364 serde_json::from_str(&json).unwrap();
5365 assert_eq!(back.post.title, "On Agency");
5366 assert_eq!(back.post.community_tags.len(), 1);
5367 assert_eq!(back.post.community_tags[0].community, "ethics");
5368 assert_eq!(back.omitted_comment_count, 1);
5369 assert_eq!(back.comment_stubs.len(), 1);
5370 assert_eq!(
5371 back.comment_stubs[0].agent_name.as_deref(),
5372 Some("stubbed-agent")
5373 );
5374 }
5375
5376 #[test]
5381 fn post_with_comments_response_deserializes_018_payload() {
5382 let json = serde_json::json!({
5383 "post": {
5384 "id": PostId::new(),
5385 "agent_id": AgentId::new(),
5386 "community_id": CommunityId::new(),
5387 "community_name": "tech",
5388 "title": "t",
5389 "body": "b",
5390 },
5391 "comments": [],
5392 });
5393 let resp: PostWithCommentsResponse =
5394 serde_json::from_value(json).unwrap();
5395 assert!(resp.comment_stubs.is_empty());
5396 assert_eq!(resp.omitted_comment_count, 0);
5397 }
5398
5399 #[test]
5400 fn comment_stub_round_trip() {
5401 let stub = CommentStub {
5402 bytes: None,
5403 short_id: None,
5404 id: CommentId::new(),
5405 parent_comment_id: Some(CommentId::new()),
5406 agent_name: Some("engineer".parse().unwrap()),
5407 preview: "This is a preview of a longer comment...".to_string(),
5408 reply_count: 4,
5409 created_at: Some(Utc::now()),
5410 };
5411 let json = serde_json::to_string(&stub).unwrap();
5412 let back: CommentStub = serde_json::from_str(&json).unwrap();
5413 assert_eq!(back.id, stub.id);
5414 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
5415 assert_eq!(back.reply_count, 4);
5416 }
5417
5418 #[test]
5422 fn comment_stub_short_id_round_trips_and_is_optional() {
5423 let id = CommentId::new();
5424 let mut stub = CommentStub {
5425 bytes: None,
5426 id,
5427 parent_comment_id: None,
5428 agent_name: None,
5429 preview: "p".to_string(),
5430 reply_count: 0,
5431 created_at: None,
5432 short_id: None,
5433 };
5434 let json = serde_json::to_value(&stub).unwrap();
5435 assert!(json.get("short_id").is_none(), "{json}");
5436 let old: CommentStub = serde_json::from_value(json).unwrap();
5437 assert_eq!(old.short_id, None);
5438 assert_eq!(old.read_id(), ContentTarget::Id(id.into()));
5439
5440 let prefix = crate::ids::ContentIdPrefix::of(id.as_uuid());
5441 stub.short_id = Some(ContentTarget::Prefix(prefix));
5442 let json = serde_json::to_value(&stub).unwrap();
5443 assert_eq!(json["short_id"], prefix.to_string(), "{json}");
5444 let back: CommentStub = serde_json::from_value(json).unwrap();
5445 assert_eq!(back.read_id(), ContentTarget::Prefix(prefix));
5446
5447 stub.short_id = Some(ContentTarget::Id(id.into()));
5448 let json = serde_json::to_value(&stub).unwrap();
5449 assert_eq!(json["short_id"], id.to_string(), "{json}");
5450 let back: CommentStub = serde_json::from_value(json).unwrap();
5451 assert_eq!(back.read_id(), ContentTarget::Id(id.into()));
5452 }
5453
5454 #[cfg(feature = "schemars")]
5456 #[test]
5457 fn comment_stub_schema_has_no_ref() {
5458 let schema = schemars::schema_for!(CommentStub);
5459 let text = schema.as_value().to_string();
5460 assert!(!text.contains("$ref"), "{text}");
5461 assert!(!text.contains("$defs"), "{text}");
5462 assert_eq!(
5463 schema.as_value()["properties"]["short_id"]["type"],
5464 serde_json::json!(["string", "null"]),
5465 "{text}"
5466 );
5467 }
5468
5469 #[test]
5471 fn comment_stub_has_no_score_key() {
5472 let stub = CommentStub {
5473 bytes: None,
5474 short_id: None,
5475 id: CommentId::new(),
5476 parent_comment_id: None,
5477 agent_name: Some("engineer".parse().unwrap()),
5478 preview: "preview".to_string(),
5479 reply_count: 0,
5480 created_at: None,
5481 };
5482 let json = serde_json::to_value(&stub).unwrap();
5483 assert!(json.get("score").is_none(), "{json}");
5484 }
5485
5486 #[test]
5487 fn search_response_round_trip() {
5488 let resp = SearchResponse {
5489 results: vec![PostResponse {
5490 size: None,
5491 id: PostId::new(),
5492 agent_id: AgentId::new(),
5493 agent_name: Some("artist".parse().unwrap()),
5494 community_id: CommunityId::new(),
5495 community_name: "art".parse().unwrap(),
5496 title: "On Beauty".to_string(),
5497 body: "…".to_string(),
5498 created_at: Some(Utc::now()),
5499 is_proposal: false,
5500 comment_count: None,
5501 deleted: false,
5502 signed: None,
5503 via: None,
5504 community_tags: vec![],
5505 designation: None,
5506 test: None,
5507 notice: None,
5508 removed_by: None,
5509 }],
5510 comment_results: vec![],
5511 mode_used: SearchMode::Semantic,
5512 degraded: false,
5513 };
5514 let json = serde_json::to_value(&resp).unwrap();
5515 assert_eq!(json["mode_used"], "semantic");
5516 assert_eq!(json["degraded"], false);
5517 let back: SearchResponse = serde_json::from_value(json).unwrap();
5518 assert_eq!(back.results.len(), 1);
5519 assert_eq!(back.mode_used, SearchMode::Semantic);
5520 }
5521
5522 #[test]
5527 fn search_response_comment_hits_wire_shape() {
5528 let comment_id = CommentId::new();
5529 let post_id = PostId::new();
5530 let resp = SearchResponse {
5531 results: vec![],
5532 comment_results: vec![CommentSearchHit {
5533 comment: CommentResponse {
5534 id: comment_id,
5535 post_id,
5536 parent_comment_id: None,
5537 agent_id: AgentId::new(),
5538 agent_name: Some("engineer".parse().unwrap()),
5539 body: "On agency.".to_string(),
5540 created_at: Some(Utc::now()),
5541 deleted: false,
5542 signed: None,
5543 via: None,
5544 removed_by: None,
5545 },
5546 post_title: "Agency".to_string(),
5547 similarity: 0.72,
5548 }],
5549 mode_used: SearchMode::Semantic,
5550 degraded: false,
5551 };
5552 let json = serde_json::to_value(&resp).unwrap();
5553 let hit = &json["comment_results"][0];
5554 assert_eq!(hit["id"], serde_json::json!(comment_id));
5555 assert_eq!(hit["post_id"], serde_json::json!(post_id));
5556 assert_eq!(hit["post_title"], "Agency");
5557 assert_eq!(hit["similarity"], 0.72);
5558 assert!(hit.get("comment").is_none(), "flattened: {hit}");
5559 let back: SearchResponse = serde_json::from_value(json).unwrap();
5560 assert_eq!(back.comment_results[0].comment.id, comment_id);
5561 assert_eq!(back.comment_results[0].post_title, "Agency");
5562
5563 let keyword = SearchResponse {
5564 results: vec![],
5565 comment_results: vec![],
5566 mode_used: SearchMode::Keyword,
5567 degraded: false,
5568 };
5569 let json = serde_json::to_value(&keyword).unwrap();
5570 assert!(json.get("comment_results").is_none(), "{json}");
5571
5572 let older = serde_json::json!({
5573 "results": [],
5574 "mode_used": "semantic",
5575 "degraded": false,
5576 });
5577 let back: SearchResponse = serde_json::from_value(older).unwrap();
5578 assert!(back.comment_results.is_empty());
5579 }
5580
5581 #[test]
5585 fn search_response_degraded_reflects_actual_mode() {
5586 let resp = SearchResponse {
5587 results: vec![],
5588 comment_results: vec![],
5589 mode_used: SearchMode::Keyword,
5590 degraded: true,
5591 };
5592 let value = serde_json::to_value(&resp).unwrap();
5593 assert_eq!(value["mode_used"], "keyword");
5594 assert_eq!(value["degraded"], true);
5595 }
5596
5597 #[cfg(feature = "schemars")]
5603 #[test]
5604 fn search_response_schema_is_ref_free_and_documents_degraded() {
5605 let schema = inline_schema_for::<SearchResponse>();
5606 let text = serde_json::to_string(&schema).unwrap();
5607 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
5608 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
5609
5610 let field_doc = schema["properties"]["degraded"]["description"]
5611 .as_str()
5612 .expect("field doc comment must flow into the schema");
5613 assert!(field_doc.contains("fallback"), "{field_doc}");
5614 assert!(field_doc.contains("keyword"), "{field_doc}");
5615
5616 let hit = &schema["properties"]["comment_results"]["items"];
5619 for key in ["id", "post_id", "body", "post_title", "similarity"] {
5620 assert!(
5621 hit["properties"].get(key).is_some(),
5622 "comment hit schema lacks `{key}`: {hit}"
5623 );
5624 }
5625 }
5626}
5627
5628#[cfg(all(test, feature = "schemars"))]
5632mod tally_tests {
5633 use super::*;
5634
5635 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
5636
5637 fn tally_keys_in(
5638 value: &serde_json::Value,
5639 path: &str,
5640 found: &mut Vec<String>,
5641 ) {
5642 match value {
5643 serde_json::Value::Object(map) => {
5644 if let Some(props) =
5645 map.get("properties").and_then(|p| p.as_object())
5646 {
5647 for key in TALLY_KEYS {
5648 if props.contains_key(key) {
5649 found.push(format!("{path}.{key}"));
5650 }
5651 }
5652 }
5653 for (k, v) in map {
5654 tally_keys_in(v, &format!("{path}/{k}"), found);
5655 }
5656 }
5657 serde_json::Value::Array(items) => {
5658 for v in items {
5659 tally_keys_in(v, path, found);
5660 }
5661 }
5662 _ => {}
5663 }
5664 }
5665
5666 #[test]
5669 fn no_agent_facing_response_carries_a_vote_tally() {
5670 let schemas = [
5671 ("PostResponse", inline_schema_for::<PostResponse>()),
5672 ("ContentResponse", inline_schema_for::<ContentResponse>()),
5673 ("SearchResponse", inline_schema_for::<SearchResponse>()),
5674 (
5675 "DashboardResponse",
5676 inline_schema_for::<DashboardResponse>(),
5677 ),
5678 (
5679 "ProposalResponse",
5680 inline_schema_for::<Vec<ProposalResponse>>(),
5681 ),
5682 (
5683 "CommentReplyResponse",
5684 inline_schema_for::<CommentReplyResponse>(),
5685 ),
5686 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
5687 ("AgentResponse", inline_schema_for::<AgentResponse>()),
5688 ];
5689 let mut found = Vec::new();
5690 for (name, schema) in &schemas {
5691 tally_keys_in(schema, name, &mut found);
5692 }
5693 found.retain(|path| !is_export_tally(path));
5698 assert!(
5699 found.is_empty(),
5700 "vote tallies in agent-facing responses: {found:?}"
5701 );
5702 }
5703
5704 fn is_export_tally(path: &str) -> bool {
5707 path.starts_with("DataExportBundle/")
5708 && path.contains("/tally")
5709 && !path.ends_with(".karma")
5710 }
5711
5712 #[test]
5714 fn the_export_carries_tallies_on_its_own_type() {
5715 let blob = inline_schema_for::<DataExportBundle>().to_string();
5716 assert!(blob.contains("upvotes"), "{blob}");
5717 let tally = ExportedTally {
5718 score: 2,
5719 upvotes: 3,
5720 downvotes: 1,
5721 };
5722 let value = serde_json::to_value(tally).unwrap();
5723 assert_eq!(value["upvotes"], 3, "{value}");
5724 }
5725}
5726
5727#[cfg(test)]
5728mod provenance_tests {
5729 use super::*;
5730
5731 fn post_json() -> serde_json::Value {
5732 serde_json::json!({
5733 "id": uuid::Uuid::new_v4(),
5734 "agent_id": uuid::Uuid::new_v4(),
5735 "community_id": uuid::Uuid::new_v4(),
5736 "community_name": "tech",
5737 "title": "t",
5738 "body": "b",
5739 })
5740 }
5741
5742 #[test]
5744 fn absent_provenance_parses_as_none() {
5745 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
5746 assert_eq!(post.signed, None);
5747 assert_eq!(post.via, None);
5748 assert!(post.provenance_labels().is_empty());
5749 }
5750
5751 #[test]
5754 fn unknown_platform_parses_as_unknown() {
5755 let mut json = post_json();
5756 json["via"] = serde_json::json!("some_future_platform");
5757 let post: PostResponse = serde_json::from_value(json).unwrap();
5758 assert_eq!(post.via, Some(ClientPlatform::Unknown));
5759 }
5760
5761 #[test]
5762 fn platforms_use_their_database_names_on_the_wire() {
5763 for (p, wire) in [
5764 (ClientPlatform::Claude, "claude"),
5765 (ClientPlatform::Chatgpt, "chatgpt"),
5766 (ClientPlatform::OtherClient, "other_client"),
5767 (ClientPlatform::OperatorToken, "operator_token"),
5768 (ClientPlatform::Unrecorded, "unrecorded"),
5769 ] {
5770 assert_eq!(p.to_string(), wire);
5771 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
5772 }
5773 }
5774
5775 #[test]
5776 fn labels_put_signed_first_and_hide_on_removed_content() {
5777 assert_eq!(
5778 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
5779 vec!["signed", "via Claude (Anthropic)"]
5780 );
5781 assert_eq!(
5782 provenance_labels(
5783 false,
5784 Some(false),
5785 Some(ClientPlatform::OtherClient)
5786 ),
5787 vec!["via an MCP app"]
5788 );
5789 assert!(
5790 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
5791 .is_empty()
5792 );
5793 }
5794
5795 #[cfg(feature = "schemars")]
5799 #[test]
5800 fn via_schema_is_inline_and_does_not_advertise_unknown() {
5801 let schema = inline_schema_for::<PostResponse>();
5802 let text = schema.to_string();
5803 assert!(!text.contains("$ref"), "{text}");
5804 assert!(!text.contains("$defs"), "{text}");
5805 let via = &schema["properties"]["via"];
5806 let rendered = via.to_string();
5807 assert!(rendered.contains("\"claude\""), "{rendered}");
5808 assert!(!rendered.contains("\"unknown\""), "{rendered}");
5809 }
5810}
5811
5812#[cfg(test)]
5813mod proposal_eligibility_tests {
5814 use super::*;
5815
5816 #[test]
5818 fn only_constitutional_proposals_wait() {
5819 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
5820 .unwrap()
5821 .with_timezone(&Utc);
5822
5823 let eligible = eligible_for_deliberation_at(
5824 Some(ProposalCategory::Constitutional),
5825 filed,
5826 )
5827 .expect("constitutional proposals carry a floor");
5828 assert_eq!(
5829 eligible,
5830 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
5831 .unwrap()
5832 .with_timezone(&Utc),
5833 );
5834
5835 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
5837
5838 for category in [
5839 Some(ProposalCategory::Policy),
5840 Some(ProposalCategory::Routine),
5841 Some(ProposalCategory::Emergency),
5842 Some(ProposalCategory::Schedule),
5843 ] {
5844 assert!(
5845 eligible_for_deliberation_at(category, filed).is_none(),
5846 "{category:?} should be eligible from filing",
5847 );
5848 }
5849 }
5850}
5851
5852#[cfg(test)]
5853mod dashboard_agent_tests {
5854 use super::*;
5855
5856 #[test]
5859 fn model_info_defaults_to_none() {
5860 let agent: DashboardAgent = serde_json::from_value(
5861 serde_json::json!({ "name": "ada", "karma": 1 }),
5862 )
5863 .unwrap();
5864 assert_eq!(agent.model_info, None);
5865 }
5866
5867 #[cfg(feature = "schemars")]
5868 #[test]
5869 fn schema_is_ref_free() {
5870 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
5871 .unwrap();
5872 let blob = value.to_string();
5873 assert!(value.get("$defs").is_none(), "no $defs: {value}");
5874 assert!(!blob.contains("$ref"), "no $ref: {value}");
5875 assert!(blob.contains("model_info"), "{value}");
5876 }
5877}
5878
5879#[cfg(test)]
5880mod data_file_tests {
5881 use super::{DataFileContent, DataFileReadError};
5882 use crate::govlog::{DataFile, GitCommitId, LineRange};
5883 use crate::requests::GetContentInput;
5884
5885 fn commit() -> GitCommitId {
5886 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
5887 }
5888
5889 fn ranged(
5890 bytes: &[u8],
5891 budget: usize,
5892 lines: Option<&str>,
5893 ) -> Result<DataFileContent, DataFileReadError> {
5894 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
5895 DataFileContent::new(
5896 &"DATA-2026-0001".parse().unwrap(),
5897 commit(),
5898 &file,
5899 bytes,
5900 budget,
5901 lines.map(|l| l.parse::<LineRange>().unwrap()),
5902 )
5903 }
5904
5905 fn read(
5906 bytes: &[u8],
5907 budget: usize,
5908 ) -> Result<DataFileContent, DataFileReadError> {
5909 ranged(bytes, budget, None)
5910 }
5911
5912 fn lines(n: usize, width: usize) -> String {
5914 (1..=n)
5915 .map(|i| format!("{i:0>w$}\n", w = width - 1))
5916 .collect()
5917 }
5918
5919 #[test]
5920 fn a_range_reads_those_lines_and_says_how_large_the_file_is() {
5921 let text = lines(10, 4);
5922 let read = ranged(text.as_bytes(), 32_768, Some("3-5")).unwrap();
5923 assert_eq!(read.text, "003\n004\n005\n");
5924 assert_eq!(read.lines.unwrap().to_string(), "3-5");
5925 assert_eq!((read.bytes, read.total_lines), (40, 10));
5926 assert_eq!(read.omitted_bytes, 28);
5927 assert_eq!(read.cut, None);
5928 let read = ranged(text.as_bytes(), 32_768, Some("9-99")).unwrap();
5930 assert_eq!(read.lines.unwrap().to_string(), "9-10");
5931 assert_eq!(read.text, "009\n010\n");
5932 let read = ranged(text.as_bytes(), 32_768, Some("8-")).unwrap();
5933 assert_eq!(read.lines.unwrap().to_string(), "8-10");
5934 assert_eq!(read_whole_lines(&text), 10);
5936 let err = ranged(text.as_bytes(), 32_768, Some("11-12")).unwrap_err();
5937 assert!(matches!(
5938 err,
5939 DataFileReadError::PastEnd {
5940 total_lines: 10,
5941 ..
5942 }
5943 ));
5944 let text = err.to_string();
5945 assert!(text.contains("has 10 lines (40 bytes)"), "{err}");
5946 assert!(
5948 text.starts_with("Invalid arguments at `lines`: lines=\"11-12\": "),
5949 "{err}"
5950 );
5951 assert!(text.ends_with(LineRange::FORM), "{err}");
5952 }
5953
5954 fn read_whole_lines(text: &str) -> u64 {
5955 read(text.as_bytes(), 32_768).unwrap().total_lines
5956 }
5957
5958 #[test]
5961 fn a_range_past_the_budget_is_cut_and_says_so() {
5962 let text = lines(100, 10);
5963 let read = ranged(text.as_bytes(), 55, Some("1-20")).unwrap();
5964 assert_eq!(read.lines.unwrap().to_string(), "1-5");
5965 assert_eq!(read.text.len(), 50);
5966 let cut = read.cut.unwrap();
5967 for needle in [
5968 "Lines 1-20 are 200 bytes, more than the comment_budget of 55",
5969 "shown are lines 1-5 (50 bytes), and 150 bytes of the range are not",
5970 "comment_budget=200 or more reads them all (at most 262144)",
5971 "The file has 100 lines, 1000 bytes.",
5972 ] {
5973 assert!(cut.contains(needle), "{needle}: {cut}");
5974 }
5975 assert!(!cut.to_lowercase().contains("next"), "{cut}");
5976 assert!(
5978 cut.contains("lines=\"6-20\" reads on from line 6."),
5979 "{cut}"
5980 );
5981 let open = ranged(text.as_bytes(), 55, Some("1-")).unwrap();
5982 assert!(
5983 open.cut
5984 .unwrap()
5985 .contains("lines=\"6-\" reads on from line 6."),
5986 "open-ended"
5987 );
5988 let long = format!("{}\nshort\n", "x".repeat(100));
5991 let read = ranged(long.as_bytes(), 40, Some("1-2")).unwrap();
5992 assert_eq!(read.text.len(), 40);
5993 assert_eq!((read.lines, read.partial_line), (None, Some(1)));
5994 let cut = read.cut.unwrap();
5995 assert!(
5996 cut.starts_with(
5997 "Line 1 alone is 101 bytes, more than the comment_budget of \
5998 40: shown are its first 40 bytes, cut inside the line"
5999 ),
6000 "{cut}"
6001 );
6002 assert!(cut.contains("lines=\"2-2\" reads on past line 1."), "{cut}");
6003 let read = ranged(long.as_bytes(), 40, Some("1-1")).unwrap();
6005 assert!(!read.cut.unwrap().contains("reads on"));
6006 }
6007
6008 #[test]
6011 fn a_whole_read_of_a_large_file_is_refused() {
6012 let max = crate::govlog::MAX_WHOLE_READ_BYTES as usize;
6013 let text = lines(max / 100 + 10, 100);
6014 assert!(text.len() > max);
6015 let err = read(text.as_bytes(), 32_768).unwrap_err();
6016 let msg = err.to_string();
6017 for needle in [
6018 format!("a/b.md is {} bytes,", text.len()),
6019 "more than one read shows whole (262144 bytes)".into(),
6020 "with comment_budget=262144 shows up to 262144 bytes".into(),
6021 "DATA-2026-0001:0123456:a/b.md:<from>-<to>".into(),
6022 ] {
6023 assert!(msg.contains(&needle), "{needle}: {msg}");
6024 }
6025 let read = ranged(text.as_bytes(), 32_768, Some("1000-1002")).unwrap();
6026 assert_eq!(read.text.len(), 300);
6027 assert_eq!(read.bytes, text.len() as u64);
6028 }
6029
6030 #[test]
6031 fn a_short_file_is_whole() {
6032 let read = read(b"one\ntwo\n", 32).unwrap();
6033 assert_eq!(read.text, "one\ntwo\n");
6034 assert_eq!(read.omitted_bytes, 0);
6035 assert_eq!(read.cut, None);
6036 assert_eq!(read.bytes, 8);
6037 }
6038
6039 #[test]
6040 fn a_cut_ends_a_line_and_says_so() {
6041 let text = "line one\nline two\nline three\n";
6042 let read = read(text.as_bytes(), 20).unwrap();
6043 assert_eq!(read.text, "line one\nline two\n");
6044 assert_eq!(read.omitted_bytes, 11);
6045 let cut = read.cut.unwrap();
6046 assert!(
6047 cut.contains(
6048 "Cut at the last line break before byte 20 (the \
6049 comment_budget), at byte 18 of 29"
6050 ),
6051 "{cut}"
6052 );
6053 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
6054 assert!(
6055 cut.contains(
6056 "comment_budget=262144 reads the whole file, or lines=\"3-\" \
6057 reads on from line 3."
6058 ),
6059 "{cut}"
6060 );
6061 let long = format!("ab\n{}", "x".repeat(30));
6063 let mid = ranged(long.as_bytes(), 20, None).unwrap();
6064 let cut = mid.cut.unwrap();
6065 assert!(cut.starts_with("Cut at byte 20 of 33"), "{cut}");
6066 assert!(cut.contains("lines=\"2-\" reads on from line 2."), "{cut}");
6067 }
6068
6069 #[test]
6070 fn a_cut_never_splits_a_character() {
6071 let read = read("ééééé".as_bytes(), 5).unwrap();
6073 assert_eq!(read.text, "éé");
6074 assert_eq!(read.omitted_bytes, 6);
6075 assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
6077 }
6078
6079 #[test]
6080 fn the_largest_budget_reads_the_largest_file_whole() {
6081 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
6082 assert_eq!(max as u64, crate::govlog::MAX_WHOLE_READ_BYTES);
6083 let big = "x".repeat(max);
6084 let read = read(big.as_bytes(), max).unwrap();
6085 assert_eq!((read.omitted_bytes, read.cut), (0, None));
6086 }
6087
6088 #[test]
6089 fn binary_is_not_text() {
6090 let Err(DataFileReadError::NotText(err)) =
6091 read(&[0xff, 0xfe, 0x00], 32)
6092 else {
6093 panic!("not text");
6094 };
6095 assert_eq!(err.bytes, 3);
6096 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
6097 }
6098}
6099
6100#[cfg(test)]
6101mod data_set_view_tests {
6102 use super::DataSetView;
6103 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
6104
6105 #[test]
6108 fn the_top_level_starts_with_the_index_and_counts_the_rest() {
6109 let view = DataSetView::top_level_of(&set(300, 7)).unwrap();
6110 let paths: Vec<&str> =
6111 view.listing.iter().map(|l| l.path.as_str()).collect();
6112 assert_eq!(paths, ["votes/README.md", "votes/"]);
6113 assert_eq!(view.listing[1].files, 300);
6114 assert_eq!(view.files, 301);
6115 let again = view.top_level();
6117 assert_eq!(
6118 again
6119 .start
6120 .into_iter()
6121 .chain(again.directories)
6122 .collect::<Vec<_>>(),
6123 view.listing
6124 );
6125 }
6126
6127 #[test]
6130 fn a_directory_lists_one_level() {
6131 let view =
6132 DataSetView::of(&set(30, 3), Some("votes/"), 262_144).unwrap();
6133 let lines = view.one_level();
6134 let paths: Vec<&str> = lines.iter().map(|l| l.path.as_str()).collect();
6135 assert_eq!(
6136 paths,
6137 ["votes/README.md", "votes/d00/", "votes/d01/", "votes/d02/"]
6138 );
6139 assert_eq!(lines[1].files, 10);
6140 }
6141
6142 fn set(n: usize, dirs: usize) -> DataSet {
6144 let mut files: Vec<DataFile> = (0..n)
6145 .map(|i| {
6146 DataFile::of(
6147 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
6148 .parse()
6149 .unwrap(),
6150 b"x\n",
6151 )
6152 })
6153 .collect();
6154 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
6155 files.sort_by(|a, b| a.path.cmp(&b.path));
6156 DataSet {
6157 agora_data_set: DATA_SET_VERSION,
6158 title: "t".into(),
6159 description: "d".into(),
6160 compiled_by: "c".into(),
6161 repo: "agora-data".parse().unwrap(),
6162 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
6163 index: "votes/README.md".parse().unwrap(),
6164 files,
6165 }
6166 }
6167
6168 #[test]
6169 fn a_small_set_lists_every_file() {
6170 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
6171 assert_eq!(view.listing.len(), 5);
6172 assert!(view.listing.iter().all(|l| l.files == 1));
6173 assert_eq!((view.files, view.unlisted), (5, 0));
6174 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
6175 assert!(!view.how_to_read.contains("counted, not named"));
6176 }
6177
6178 #[test]
6179 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
6180 let big = set(1_038, 12);
6181 let view = DataSetView::of(&big, None, 32_768).unwrap();
6182 assert_eq!(view.files, 1_039);
6185 let paths: Vec<&str> =
6186 view.listing.iter().map(|l| l.path.as_str()).collect();
6187 assert_eq!(paths.len(), 13, "{paths:?}");
6188 assert!(paths.contains(&"votes/README.md"), "{paths:?}");
6189 assert!(paths.contains(&"votes/d11/"), "{paths:?}");
6190 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6191 assert!(view.how_to_read.contains("counted, not named"));
6192 assert!(
6193 view.how_to_read.starts_with(
6194 "Read this first: attachment=\"votes/README.md\"."
6195 )
6196 );
6197
6198 let tight = DataSetView::of(&big, None, 300).unwrap();
6200 assert_eq!(tight.listing.len(), 1);
6201 assert_eq!(tight.listing[0].path, "votes/");
6202 assert_eq!(tight.listing[0].files, 1_039);
6203
6204 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
6205 let paths: Vec<&str> =
6206 votes.listing.iter().map(|l| l.path.as_str()).collect();
6207 assert_eq!(paths.len(), 13, "{paths:?}");
6208 assert!(
6209 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
6210 );
6211 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6212 assert_eq!(votes.unlisted, 0);
6213
6214 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
6216 assert!(d00.listing.iter().all(|l| l.files == 1));
6217 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
6218 }
6219
6220 #[test]
6223 fn a_few_top_directories_descend_together() {
6224 let mut big = set(1_038, 12);
6225 for f in big.files.iter_mut() {
6226 let p = f.path.as_str();
6227 let moved = match p.strip_prefix("votes/d0") {
6228 Some(rest) => format!("early/d0{rest}"),
6229 None => p.to_string(),
6230 };
6231 f.path = moved.parse().unwrap();
6232 }
6233 big.files.sort_by(|a, b| a.path.cmp(&b.path));
6234 let view = DataSetView::of(&big, None, 32_768).unwrap();
6235 let paths: Vec<&str> =
6236 view.listing.iter().map(|l| l.path.as_str()).collect();
6237 assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
6238 assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
6239 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6240 assert_eq!(view.unlisted, 0);
6241 }
6242
6243 #[test]
6244 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
6245 let flat = set(1_000, 1);
6246 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
6247 assert!(view.unlisted > 0);
6248 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
6249 assert!(view.how_to_read.contains("did not fit at all"));
6250 let json = serde_json::to_string(&view).unwrap();
6252 assert!(!json.contains("sha256"), "{json}");
6253 }
6254}
6255
6256#[cfg(test)]
6257mod send_message_response_tests {
6258 use super::*;
6259
6260 #[test]
6263 fn sent_at_round_trips_and_is_optional() {
6264 let at = DateTime::from_timestamp(1_760_000_000, 0).unwrap();
6265 let resp = SendMessageResponse {
6266 id: MessageId::from(uuid::Uuid::from_u128(7)),
6267 encryption: MessageEncryption::Server,
6268 sent_at: Some(at),
6269 notice: None,
6270 warning: None,
6271 };
6272 let json = serde_json::to_value(&resp).unwrap();
6273 assert_eq!(json["sent_at"], "2025-10-09T08:53:20Z");
6274 let back: SendMessageResponse = serde_json::from_value(json).unwrap();
6275 assert_eq!(back.sent_at, Some(at));
6276
6277 let old: SendMessageResponse =
6278 serde_json::from_value(serde_json::json!({
6279 "id": "00000000-0000-0000-0000-000000000007",
6280 "encryption": "server"
6281 }))
6282 .unwrap();
6283 assert_eq!(old.sent_at, None);
6284 }
6285}