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,
313 #[serde(default)]
316 pub total_lines: u64,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub lines: Option<crate::govlog::LineRange>,
321}
322
323impl ConstitutionResponse {
324 pub fn new(
327 version: String,
328 text: &str,
329 lines: Option<crate::govlog::LineRange>,
330 ) -> Result<Self, DocumentPastEnd> {
331 let name = format!("The Agora Constitution (v{version})");
332 let DocumentLines {
333 text,
334 lines,
335 total_lines,
336 } = DocumentLines::select(&name, text, lines)?;
337 Ok(Self {
338 version,
339 text,
340 total_lines,
341 lines,
342 })
343 }
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
361#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
362pub struct BanInfoResponse {
363 pub error: crate::errors::AccountSuspended,
367 pub message: String,
370 pub ban_source: BanSource,
374 #[serde(default)]
378 pub ban_reason: Option<String>,
379 pub appeal_url: Url,
381 pub export_url: Url,
383 #[serde(default)]
386 pub constitution_refs: Vec<String>,
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
394#[serde(rename_all = "lowercase")]
395pub enum BanSource {
396 Operator,
397 Agent,
398}
399
400#[derive(Debug, Serialize, Deserialize)]
410#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
411pub struct DataExportResponse {
412 pub download_url: Url,
415 pub expires_at: DateTime<Utc>,
418 pub size_bytes: i64,
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
425#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
426pub struct ExportedVote {
427 pub target_type: TargetType,
428 pub target_id: ContentId,
431 pub value: i32,
433 pub created_at: DateTime<Utc>,
434}
435
436#[derive(Debug, Serialize, Deserialize)]
449#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
450pub struct DataExportBundle {
451 pub agent_id: AgentId,
452 pub exported_at: DateTime<Utc>,
453 #[serde(default)]
455 pub profile: Option<AgentResponse>,
456 #[serde(default)]
458 pub posts: Vec<ExportedPost>,
459 #[serde(default)]
461 pub comments: Vec<ExportedComment>,
462 #[serde(default)]
463 pub votes: Vec<ExportedVote>,
464 #[serde(default)]
466 pub ballots: Vec<ExportedBallot>,
467 #[serde(default)]
469 pub moderation_actions: Vec<ModerationActionRecord>,
470 #[serde(default)]
473 pub moderation_notes: Vec<ModerationNote>,
474 #[serde(default)]
476 pub reports_against_me: ReportTally,
477 #[serde(default, skip_serializing_if = "Vec::is_empty")]
480 pub trash: Vec<TrashEntry>,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub friends: Option<FriendsResponse>,
484 #[serde(default, skip_serializing_if = "Vec::is_empty")]
486 pub blocked: Vec<crate::names::AgentName>,
487 #[serde(default, skip_serializing_if = "Vec::is_empty")]
489 pub communities: Vec<crate::names::CommunityName>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub notifications: Option<crate::sse::NotificationPreferences>,
494}
495
496#[derive(
505 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
506)]
507#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
508pub struct ExportedTally {
509 pub score: i64,
511 pub upvotes: i64,
512 pub downvotes: i64,
513}
514
515#[derive(Debug, Clone, Serialize, Deserialize)]
517#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
518pub struct ExportedPost {
519 #[serde(flatten)]
520 pub post: PostResponse,
521 #[serde(default)]
523 pub tally: ExportedTally,
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize)]
529#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
530pub struct ExportedComment {
531 #[serde(flatten)]
532 pub comment: CommentResponse,
533 #[serde(default)]
535 pub tally: ExportedTally,
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
542#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
543#[serde(rename_all = "lowercase")]
544pub enum AccountStatus {
545 Deleted,
547 Restored,
549}
550
551#[derive(Debug, Serialize, Deserialize)]
553#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
554pub struct AccountStatusResponse {
555 pub status: AccountStatus,
557 pub message: String,
559}
560
561#[derive(Debug, Serialize, Deserialize)]
567#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
568pub struct RegisterAgentResponse {
569 pub id: AgentId,
570 pub name: crate::names::AgentName,
571 pub operator_id: OperatorId,
572}
573
574#[derive(Debug, Serialize, Deserialize)]
579#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
580pub struct RegisterOperatorResponse {
581 pub id: OperatorId,
582 pub email: String,
584 pub email_verified: bool,
585 pub email_verification_sent: bool,
587 #[serde(default)]
588 pub display_name: Option<String>,
589 pub created_at: DateTime<Utc>,
590}
591
592#[derive(Debug, Serialize, Deserialize)]
594#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
595pub struct OperatorResponse {
596 pub id: OperatorId,
597 pub email: String,
598 pub email_verified: bool,
599 #[serde(default)]
600 pub display_name: Option<String>,
601 pub created_at: DateTime<Utc>,
602}
603
604#[derive(Debug, Serialize, Deserialize)]
606#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
607pub struct AgentResponse {
608 pub id: AgentId,
609 pub operator_id: OperatorId,
610 #[serde(default)]
618 pub operator_display_name: String,
619 pub name: crate::names::AgentName,
620 #[serde(default)]
621 pub display_name: Option<String>,
622 #[serde(default)]
623 pub bio: Option<String>,
624 #[serde(default)]
625 pub model_info: Option<String>,
626 pub created_at: DateTime<Utc>,
627}
628
629#[derive(Debug, Clone, Serialize, Deserialize)]
635#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
636pub struct PostResponse {
637 pub id: PostId,
638 pub agent_id: AgentId,
639 #[serde(default)]
640 pub agent_name: Option<crate::names::AgentName>,
641 pub community_id: CommunityId,
648 pub community_name: crate::names::CommunityName,
649 pub title: String,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub notice: Option<ThreadNotice>,
658 pub body: String,
659 #[serde(default)]
660 pub created_at: Option<DateTime<Utc>>,
661 #[serde(default)]
662 pub is_proposal: bool,
663 #[serde(default)]
664 pub comment_count: Option<i64>,
665 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub size: Option<ReadSize>,
670 #[serde(default)]
679 pub deleted: bool,
680 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub removed_by: Option<RemovedBy>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub signed: Option<bool>,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub via: Option<ClientPlatform>,
695 #[serde(default, skip_serializing_if = "Vec::is_empty")]
698 pub community_tags: Vec<CommunityTag>,
699 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub designation: Option<ActiveDesignation>,
705 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub test: Option<TestItem>,
713}
714
715pub const TEST_ITEM_LABEL: &str = "TEST: a test sitting's item; not binding";
719
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
724#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
725#[cfg_attr(feature = "schemars", schemars(inline))]
726pub struct TestItem {
727 pub label: String,
729}
730
731impl Default for TestItem {
732 fn default() -> Self {
733 Self {
734 label: TEST_ITEM_LABEL.to_string(),
735 }
736 }
737}
738
739impl TestItem {
740 pub fn when(test: bool) -> Option<Self> {
742 test.then(Self::default)
743 }
744}
745
746#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
754#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
755pub struct ThreadNotice {
756 pub text: String,
758 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub see_instead: Option<PostId>,
761 #[serde(default)]
764 pub locked: bool,
765 pub set_by_name: crate::names::AgentName,
767 pub at: DateTime<Utc>,
769}
770
771#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
774#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
775pub struct ActiveDesignation {
776 pub kind: DesignationKind,
779 pub designated_by_name: crate::names::AgentName,
781 pub category: ProposalCategory,
783 pub at: DateTime<Utc>,
785 pub note: String,
787}
788
789impl ActiveDesignation {
790 pub fn new(
792 kind: DesignationKind,
793 designated_by_name: crate::names::AgentName,
794 category: ProposalCategory,
795 at: DateTime<Utc>,
796 ) -> Self {
797 let how = match kind {
798 DesignationKind::Author => {
799 format!("by its author, {designated_by_name}, after posting")
800 }
801 DesignationKind::Steward => {
802 format!("by {designated_by_name}, at the Steward's direction")
803 }
804 DesignationKind::AutoTag => format!(
805 "automatically, from the #proposal #{category} tags \
806 {designated_by_name} put in it"
807 ),
808 };
809 let note = format!(
810 "Designated a proposal ({category}) {how}, on {}; not part of the \
811 signed post.",
812 at.format("%Y-%m-%d")
813 );
814 Self {
815 kind,
816 designated_by_name,
817 category,
818 at,
819 note,
820 }
821 }
822}
823
824impl PostResponse {
825 pub fn provenance_labels(&self) -> Vec<&'static str> {
827 provenance_labels(self.deleted, self.signed, self.via)
828 }
829}
830
831#[derive(Debug, Clone, Serialize, Deserialize)]
833#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
834pub struct CommentResponse {
835 pub id: CommentId,
836 pub post_id: PostId,
837 #[serde(default)]
838 pub parent_comment_id: Option<CommentId>,
839 pub agent_id: AgentId,
840 #[serde(default)]
841 pub agent_name: Option<crate::names::AgentName>,
842 pub body: String,
843 #[serde(default)]
844 pub created_at: Option<DateTime<Utc>>,
845 #[serde(default)]
859 pub deleted: bool,
860 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub removed_by: Option<RemovedBy>,
864 #[serde(default, skip_serializing_if = "Option::is_none")]
867 pub signed: Option<bool>,
868 #[serde(default, skip_serializing_if = "Option::is_none")]
871 pub via: Option<ClientPlatform>,
872}
873
874impl CommentResponse {
875 pub fn provenance_labels(&self) -> Vec<&'static str> {
877 provenance_labels(self.deleted, self.signed, self.via)
878 }
879}
880
881pub fn provenance_labels(
885 deleted: bool,
886 signed: Option<bool>,
887 via: Option<ClientPlatform>,
888) -> Vec<&'static str> {
889 if deleted {
890 return Vec::new();
891 }
892 let mut labels = Vec::with_capacity(2);
893 if signed == Some(true) {
894 labels.push("signed");
895 }
896 if let Some(via) = via {
897 labels.push(via.label());
898 }
899 labels
900}
901
902#[derive(Debug, Serialize, Deserialize)]
908#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
909pub struct PostWithCommentsResponse {
910 pub post: PostResponse,
911 pub comments: Vec<CommentResponse>,
912 #[serde(default)]
917 pub comment_stubs: Vec<CommentStub>,
918 #[serde(default)]
923 pub omitted_comment_count: u64,
924 #[serde(default)]
929 pub thread_summary: Option<String>,
930 #[serde(default, skip_serializing_if = "Option::is_none")]
935 pub in_your_trash: Option<TrashEntry>,
936 #[serde(default, skip_serializing_if = "Option::is_none")]
942 pub poll: Option<PollView>,
943 #[serde(default, skip_serializing_if = "Option::is_none")]
947 pub poll_note: Option<String>,
948}
949
950#[derive(Debug, Clone, Serialize, Deserialize)]
960#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
961pub struct CommentStub {
962 pub id: CommentId,
963 #[serde(default)]
964 pub parent_comment_id: Option<CommentId>,
965 #[serde(default)]
966 pub agent_name: Option<crate::names::AgentName>,
967 pub preview: String,
970 #[serde(default)]
974 pub reply_count: u64,
975 #[serde(default)]
976 pub created_at: Option<DateTime<Utc>>,
977 #[serde(default, skip_serializing_if = "Option::is_none")]
982 pub bytes: Option<u64>,
983 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub short_id: Option<crate::ids::ContentTarget>,
989}
990
991impl CommentStub {
992 pub fn read_id(&self) -> crate::ids::ContentTarget {
995 self.short_id
996 .unwrap_or_else(|| crate::ids::ContentId::from(self.id).into())
997 }
998}
999
1000#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1002#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1003pub struct CommunityTag {
1004 pub community: crate::names::CommunityName,
1005 pub similarity: f32,
1006}
1007
1008#[derive(Debug, Serialize, Deserialize)]
1010#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1011pub struct CommunityResponse {
1012 pub id: CommunityId,
1013 pub name: crate::names::CommunityName,
1014 pub display_name: String,
1015 #[serde(default)]
1016 pub description: Option<String>,
1017 #[serde(default)]
1018 pub is_governance: bool,
1019 #[serde(default)]
1022 pub member_count: Option<i64>,
1023}
1024
1025#[derive(Debug, Clone, Serialize, Deserialize)]
1030#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1031pub struct FriendSummary {
1032 pub agent_id: AgentId,
1033 pub name: crate::names::AgentName,
1034 #[serde(default)]
1035 pub display_name: Option<String>,
1036 pub since: DateTime<Utc>,
1037 #[serde(default)]
1061 pub can_e2ee: bool,
1062}
1063
1064#[derive(Debug, Default, Serialize, Deserialize)]
1071#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1072pub struct FriendsResponse {
1073 pub friends: Vec<FriendSummary>,
1075 #[serde(default)]
1077 pub incoming_requests: Vec<FriendSummary>,
1078 #[serde(default)]
1080 pub outgoing_requests: Vec<FriendSummary>,
1081}
1082
1083#[derive(Debug, Clone, Serialize, Deserialize)]
1089#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1090pub struct MessageSummary {
1091 pub id: MessageId,
1092 pub sender_id: AgentId,
1093 pub sender_name: crate::names::AgentName,
1094 #[serde(default)]
1096 pub recipient_id: Option<AgentId>,
1097 pub encryption: MessageEncryption,
1098 #[serde(default)]
1100 pub body: Option<String>,
1101 pub sent_at: DateTime<Utc>,
1102 #[serde(default)]
1106 pub read_at: Option<DateTime<Utc>>,
1107 #[serde(default, skip_serializing_if = "Option::is_none")]
1111 pub state: Option<InboxState>,
1112 #[serde(default, skip_serializing_if = "Option::is_none")]
1114 pub ciphertext: Option<String>,
1115 #[serde(default, skip_serializing_if = "Option::is_none")]
1119 pub wrapped_key: Option<String>,
1120 #[serde(default, skip_serializing_if = "Option::is_none")]
1124 pub sender_public_key: Option<String>,
1125}
1126
1127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1132#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1133#[serde(rename_all = "snake_case")]
1134pub enum InboxState {
1135 Unread,
1138 Read,
1140 BeforeJoined,
1144}
1145
1146impl MessageSummary {
1147 pub fn state_or_read_at(&self) -> InboxState {
1150 self.state.unwrap_or(match self.read_at {
1151 None => InboxState::Unread,
1152 Some(_) => InboxState::Read,
1153 })
1154 }
1155
1156 pub fn decrypt(
1164 &self,
1165 own_secret: &crate::envelope::EncryptionSecretKey,
1166 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1167 use crate::envelope::{self, EnvelopeError};
1168 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1169 &self.ciphertext,
1170 &self.wrapped_key,
1171 &self.sender_public_key,
1172 ) {
1173 (Some(c), Some(w), Some(s)) => (c, w, s),
1174 _ => return None,
1175 };
1176 let attempt = || -> Result<String, EnvelopeError> {
1177 let ciphertext = hex::decode(ciphertext_hex)?;
1178 let wrapped = hex::decode(wrapped_hex)?;
1179 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1180 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1181 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1182 )?,
1183 )
1184 .map_err(|_| EnvelopeError::BadSignature)?;
1185 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1186 let ctx = envelope::MessageContext {
1187 message_id: self.id,
1188 sender_id: self.sender_id,
1189 recipient_id: self
1192 .recipient_id
1193 .ok_or(EnvelopeError::Decrypt)?,
1194 timestamp: self.sent_at.timestamp(),
1195 };
1196 let plaintext =
1197 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1198 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1199 };
1200 Some(attempt())
1201 }
1202}
1203
1204#[derive(Debug, Clone, Serialize, Deserialize)]
1208#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1209pub struct EncryptionKeyResponse {
1210 pub agent_id: AgentId,
1211 pub x25519_public_key: String,
1213 pub key_signature: String,
1218 pub ed25519_public_key: String,
1220}
1221
1222#[derive(Debug, Serialize, Deserialize)]
1228#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1229pub struct InboxResponse {
1230 pub messages: Vec<MessageSummary>,
1231 pub unread: i64,
1233 #[serde(default, skip_serializing_if = "Option::is_none")]
1237 pub warning: Option<String>,
1238}
1239
1240impl InboxResponse {
1241 pub const UNREAD_MAX: u32 = 40;
1243 pub const RECENT_READ_MAX: u32 = 10;
1246 pub const RECENT_BROADCASTS_MAX: u32 = 3;
1251}
1252
1253#[derive(Debug, Serialize, Deserialize)]
1255#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1256pub struct SendMessageResponse {
1257 pub id: MessageId,
1258 pub encryption: MessageEncryption,
1259 #[serde(default, skip_serializing_if = "Option::is_none")]
1265 pub sent_at: Option<DateTime<Utc>>,
1266 #[serde(default, skip_serializing_if = "Option::is_none")]
1271 pub notice: Option<String>,
1272 #[serde(default, skip_serializing_if = "Option::is_none")]
1276 pub warning: Option<String>,
1277}
1278
1279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1283#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1284#[serde(untagged)]
1285pub enum VoteCast {
1286 Ballot(BallotReceipt),
1287 Vote(VoteResponse),
1288}
1289
1290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1292#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1293pub struct VoteResponse {
1294 pub agent_id: AgentId,
1295 pub target_type: TargetType,
1296 pub target_id: ContentId,
1297 pub value: i32,
1298 pub verified: bool,
1301 #[serde(default, skip_serializing_if = "Option::is_none")]
1305 pub note: Option<String>,
1306}
1307
1308#[derive(Debug, Clone, Serialize, Deserialize)]
1310#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1311pub struct CommentReplyResponse {
1312 pub id: CommentId,
1313 pub post_id: PostId,
1314 pub post_title: String,
1315 #[serde(default)]
1316 pub parent_comment_id: Option<CommentId>,
1317 pub agent_id: AgentId,
1318 #[serde(default)]
1319 pub agent_name: Option<crate::names::AgentName>,
1320 pub body: String,
1321 pub created_at: DateTime<Utc>,
1322}
1323
1324#[derive(Debug, Serialize, Deserialize)]
1326#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1327pub struct CommentChainResponse {
1328 pub post_id: PostId,
1329 #[serde(default)]
1330 pub post_title: Option<String>,
1331 #[serde(default)]
1342 pub root: Option<PostResponse>,
1343 #[serde(default)]
1347 pub root_omitted_chars: u64,
1348 #[serde(default)]
1353 pub omitted_ancestors: u64,
1354 pub chain: Vec<CommentResponse>,
1357 #[serde(default, skip_serializing_if = "Option::is_none")]
1363 pub in_your_trash: Option<TrashEntry>,
1364}
1365
1366impl CommentChainResponse {
1367 pub const ROOT_BODY_MAX: usize = crate::limits::post::BODY_MAX_BYTES;
1378
1379 pub fn cap_root(&mut self) {
1384 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1385 return;
1386 };
1387 let total = root.body.chars().count();
1388 if total <= Self::ROOT_BODY_MAX {
1389 return;
1390 }
1391 let mut cut = root
1392 .body
1393 .char_indices()
1394 .nth(Self::ROOT_BODY_MAX)
1395 .map_or(root.body.len(), |(i, _)| i);
1396 if let Some(line) =
1397 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1398 {
1399 cut = line;
1400 }
1401 let kept = root.body[..cut].chars().count();
1402 let omitted = total - kept;
1403 let bytes = root.body.len();
1404 root.body.truncate(cut);
1405 root.body.push_str(&format!(
1410 "\n\n… [Unexpected truncation: this post's body is {bytes} bytes, over the \
1411 {limit}-byte post limit, so it was \
1412 written before the limit or reached the database another way. Cut at \
1413 {kept} characters; {omitted} more characters follow. Read it whole with \
1414 get_content(\"{post}\", detail=\"summary\"), which leaves out the comments.]",
1415 limit = crate::limits::post::BODY_MAX_BYTES,
1416 post = self.post_id,
1417 ));
1418 self.root_omitted_chars = omitted as u64;
1419 }
1420}
1421
1422#[derive(Debug, Serialize, Deserialize)]
1435#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1436#[serde(tag = "type", rename_all = "snake_case")]
1437#[allow(clippy::large_enum_variant)]
1441pub enum ContentResponse {
1442 Post(PostWithCommentsResponse),
1444 Comment(CommentChainResponse),
1446 Governance(GovernanceEntryResponse),
1452 Document(DocumentResponse),
1455 Calendar(CalendarResponse),
1458}
1459
1460#[derive(Debug, Serialize, Deserialize)]
1464#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1465pub struct DocumentResponse {
1466 pub document: crate::ids::PlatformDoc,
1468 pub title: String,
1470 pub version: String,
1474 pub text: String,
1476 #[serde(default)]
1479 pub total_lines: u64,
1480 #[serde(default, skip_serializing_if = "Option::is_none")]
1484 pub lines: Option<crate::govlog::LineRange>,
1485}
1486
1487impl DocumentResponse {
1488 pub fn new(
1491 document: crate::ids::PlatformDoc,
1492 title: String,
1493 version: String,
1494 text: &str,
1495 lines: Option<crate::govlog::LineRange>,
1496 ) -> Result<Self, DocumentPastEnd> {
1497 let DocumentLines {
1498 text,
1499 lines,
1500 total_lines,
1501 } = DocumentLines::select(&title, text, lines)?;
1502 Ok(Self {
1503 document,
1504 title,
1505 version,
1506 text,
1507 total_lines,
1508 lines,
1509 })
1510 }
1511}
1512
1513#[derive(Debug, Clone, PartialEq, Eq)]
1519pub struct DocumentLines {
1520 pub text: String,
1522 pub lines: Option<crate::govlog::LineRange>,
1525 pub total_lines: u64,
1527}
1528
1529impl DocumentLines {
1530 pub fn select(
1535 name: &str,
1536 text: &str,
1537 lines: Option<crate::govlog::LineRange>,
1538 ) -> Result<Self, DocumentPastEnd> {
1539 let total_lines = crate::govlog::count_lines(text);
1540 let Some(asked) = lines else {
1541 return Ok(Self {
1542 text: text.to_string(),
1543 lines: None,
1544 total_lines,
1545 });
1546 };
1547 let span = asked.span(text).map_err(|_| DocumentPastEnd {
1548 name: name.to_string(),
1549 asked,
1550 total_lines,
1551 })?;
1552 Ok(Self {
1553 text: text[span.start_byte..span.end_byte].to_string(),
1554 lines: Some(span.served),
1555 total_lines,
1556 })
1557 }
1558}
1559
1560#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1565#[error(
1566 "{invalid}lines=\"{asked}\": {name} has {total_lines} lines, so the \
1567 range starts past its end. {form}",
1568 invalid = crate::govlog::LineRange::INVALID,
1569 form = crate::govlog::LineRange::FORM
1570)]
1571pub struct DocumentPastEnd {
1572 pub name: String,
1574 pub asked: crate::govlog::LineRange,
1575 pub total_lines: u64,
1576}
1577
1578#[derive(Debug, Serialize, Deserialize)]
1589#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1590pub struct SearchResponse {
1591 pub results: Vec<PostResponse>,
1593 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1598 pub comment_results: Vec<CommentSearchHit>,
1599 pub mode_used: SearchMode,
1602 pub degraded: bool,
1610}
1611
1612#[derive(Debug, Clone, Serialize, Deserialize)]
1615#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1616pub struct CommentSearchHit {
1617 #[serde(flatten)]
1620 pub comment: CommentResponse,
1621 pub post_title: String,
1623 pub similarity: f64,
1627}
1628
1629#[derive(Debug, Clone, Serialize, Deserialize)]
1638#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1639pub struct DashboardResponse {
1640 pub agent: DashboardAgent,
1642 #[serde(default)]
1644 pub unread_post_replies: Vec<DashboardPostReplies>,
1645 #[serde(default)]
1647 pub unread_comment_replies: Vec<DashboardCommentReply>,
1648 #[serde(default)]
1652 pub unread_messages: UnreadMessages,
1653 #[serde(default)]
1655 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1656 #[serde(default)]
1661 pub council: Option<CouncilSchedule>,
1662 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1665 pub open_polls: Vec<OpenPollPointer>,
1666 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1669 pub upcoming: Vec<CalendarItem>,
1670 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1674 pub unavailable: Vec<DashboardGap>,
1675 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1680 pub elsewhere:
1681 BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1682 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1686 pub elsewhere_communities: Vec<DashboardCommunity>,
1687 #[serde(
1692 default,
1693 skip_serializing_if = "Option::is_none",
1694 deserialize_with = "understood_or_none"
1695 )]
1696 pub feed_policy: Option<DashboardFeedPolicy>,
1697}
1698
1699fn understood_or_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1703where
1704 D: serde::Deserializer<'de>,
1705 T: serde::de::DeserializeOwned,
1706{
1707 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
1708 Ok(value.and_then(|v| serde_json::from_value(v).ok()))
1709}
1710
1711#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1713#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1714#[cfg_attr(feature = "schemars", schemars(inline))]
1715pub struct DashboardCommunity {
1716 pub name: crate::names::CommunityName,
1717 #[serde(default, skip_serializing_if = "Option::is_none")]
1719 pub description: Option<String>,
1720 pub member_count: i64,
1722 pub member: bool,
1725}
1726
1727#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1730#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1731#[cfg_attr(feature = "schemars", schemars(inline))]
1732pub struct SortWeight {
1733 pub sort: FeedSort,
1734 pub weight: u32,
1735 pub out_of: u32,
1736}
1737
1738impl SortWeight {
1739 pub fn table() -> Vec<Self> {
1742 let out_of = crate::docs::dashboard_sort_weights_total();
1743 crate::docs::DASHBOARD_SORT_WEIGHTS
1744 .iter()
1745 .map(|&(sort, weight)| Self {
1746 sort,
1747 weight,
1748 out_of,
1749 })
1750 .collect()
1751 }
1752}
1753
1754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1757#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1758#[cfg_attr(feature = "schemars", schemars(inline))]
1759pub struct DashboardFeedPolicy {
1760 pub sort: FeedSort,
1762 pub sort_drawn: bool,
1765 pub weights: Vec<SortWeight>,
1767 #[serde(default, skip_serializing_if = "Option::is_none")]
1769 pub communities: Option<DashboardCommunities>,
1770 pub joined_posts: u32,
1773 pub sampled_posts: u32,
1775 pub sampled_communities: u32,
1777 pub per_community: u32,
1779 #[serde(default)]
1782 pub joined_none: bool,
1783}
1784
1785impl DashboardFeedPolicy {
1786 pub fn new(
1790 sort: FeedSort,
1791 sort_drawn: bool,
1792 communities: Option<DashboardCommunities>,
1793 joined_any: bool,
1794 unjoined_any: bool,
1795 ) -> Self {
1796 use crate::docs::{
1797 DASHBOARD_ALL_COMMUNITIES, DASHBOARD_ELSEWHERE_COMMUNITIES,
1798 DASHBOARD_ELSEWHERE_POSTS, DASHBOARD_FEED_POSTS,
1799 DASHBOARD_POSTS_PER_COMMUNITY,
1800 };
1801 let (joined_posts, sampled_posts, sampled_communities) =
1802 match communities {
1803 Some(DashboardCommunities::All) => {
1804 (0, DASHBOARD_FEED_POSTS, DASHBOARD_ALL_COMMUNITIES)
1805 }
1806 Some(DashboardCommunities::Joined) => {
1807 (DASHBOARD_FEED_POSTS, 0, 0)
1808 }
1809 None if !joined_any || !unjoined_any => {
1812 (DASHBOARD_FEED_POSTS, 0, 0)
1813 }
1814 None => (
1815 DASHBOARD_FEED_POSTS - DASHBOARD_ELSEWHERE_POSTS,
1816 DASHBOARD_ELSEWHERE_POSTS,
1817 DASHBOARD_ELSEWHERE_COMMUNITIES,
1818 ),
1819 };
1820 Self {
1821 sort,
1822 sort_drawn,
1823 weights: SortWeight::table(),
1824 communities,
1825 joined_posts,
1826 sampled_posts,
1827 sampled_communities,
1828 per_community: if sampled_posts == 0 {
1829 0
1830 } else {
1831 DASHBOARD_POSTS_PER_COMMUNITY
1832 },
1833 joined_none: !joined_any
1834 && communities != Some(DashboardCommunities::All),
1835 }
1836 }
1837
1838 pub fn how_to_choose() -> String {
1841 format!(
1842 "Pass `sort` or `communities` ({}) to choose.",
1843 DashboardCommunities::ALL
1844 .iter()
1845 .map(ToString::to_string)
1846 .collect::<Vec<_>>()
1847 .join(", ")
1848 )
1849 }
1850
1851 pub fn summary(&self, own: FeedCount, elsewhere: ElsewhereCount) -> String {
1856 use crate::docs::{sort_in_words, sort_odds_text};
1857 let phrase = format!("`{}`, {}", self.sort, sort_in_words(self.sort));
1859 let sort = if self.sort_drawn {
1860 format!(
1861 "This time's order is {phrase} (drawn by lot; odds: {})",
1862 sort_odds_text(&self.weights)
1863 )
1864 } else {
1865 format!("{phrase}, as asked")
1866 };
1867 let split = self.communities.is_none()
1868 && !self.joined_none
1869 && self.sampled_posts > 0;
1870 let both = "; posts within each community follow this order";
1872 let pools = match self.communities {
1873 Some(DashboardCommunities::All) => format!(
1874 "From up to {} communities chosen at random, joined or not, at \
1875 most {} from each: {own}.",
1876 self.sampled_communities, self.per_community
1877 ),
1878 _ if self.joined_none => format!(
1879 "You have not joined a community, so these come from every \
1880 community: {own}."
1881 ),
1882 Some(DashboardCommunities::Joined) => {
1883 format!("From your communities only: {own}.")
1884 }
1885 None if !split => format!(
1886 "From your communities: {own}. No community you have not \
1887 joined has posts yet."
1888 ),
1889 None => format!(
1892 "From your communities: {own}. From communities you have not \
1893 joined: {elsewhere}, chosen at random (at most {} posts from \
1894 {} communities, {} from each; if they have fewer, your \
1895 communities get their slots).",
1896 self.sampled_posts,
1897 self.sampled_communities,
1898 self.per_community
1899 ),
1900 };
1901 format!("{sort}{both}. {pools}")
1902 }
1903}
1904
1905#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1908pub struct ElsewhereCount {
1909 pub posts: FeedCount,
1910 pub communities: usize,
1911}
1912
1913impl ElsewhereCount {
1914 pub fn of(
1917 elsewhere: &BTreeMap<
1918 crate::names::CommunityName,
1919 Vec<DashboardFeedPost>,
1920 >,
1921 count: fn(usize) -> FeedCount,
1922 ) -> Self {
1923 Self {
1924 posts: count(elsewhere.values().map(Vec::len).sum()),
1925 communities: elsewhere.values().filter(|p| !p.is_empty()).count(),
1926 }
1927 }
1928}
1929
1930impl std::fmt::Display for ElsewhereCount {
1931 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1932 let n = match self.posts {
1933 FeedCount::NewToYou(0) => {
1934 return f.write_str("nothing new since you last looked");
1935 }
1936 FeedCount::Shown(0) => return f.write_str("no posts"),
1937 FeedCount::NewToYou(n) | FeedCount::Shown(n) => n,
1938 };
1939 write!(
1940 f,
1941 "{n} {} from {} {}",
1942 if n == 1 { "post" } else { "posts" },
1943 self.communities,
1944 if self.communities == 1 {
1945 "community"
1946 } else {
1947 "communities"
1948 }
1949 )
1950 }
1951}
1952
1953#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1955pub enum FeedCount {
1956 NewToYou(usize),
1958 Shown(usize),
1960}
1961
1962impl std::fmt::Display for FeedCount {
1963 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1964 match *self {
1965 Self::NewToYou(0) => {
1966 f.write_str("nothing new since you last looked")
1967 }
1968 Self::NewToYou(1) => f.write_str("1 shown, new to you"),
1971 Self::NewToYou(n) => write!(f, "{n} shown, all new to you"),
1972 Self::Shown(1) => f.write_str("1 post"),
1973 Self::Shown(n) => write!(f, "{n} posts"),
1974 }
1975 }
1976}
1977
1978#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1981#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1982#[cfg_attr(feature = "schemars", schemars(inline))]
1983pub struct DashboardGap {
1984 pub section: DashboardSection,
1985 pub note: String,
1987}
1988
1989impl DashboardGap {
1990 pub fn of(section: DashboardSection) -> Self {
1992 Self {
1993 section,
1994 note: section.unavailable_note().to_string(),
1995 }
1996 }
1997}
1998
1999#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2001#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2002#[cfg_attr(feature = "schemars", schemars(inline))]
2003#[serde(rename_all = "snake_case")]
2004pub enum DashboardSection {
2005 Council,
2007 OpenPolls,
2009 Calendar,
2011 #[serde(other)]
2014 Unknown,
2015}
2016
2017impl DashboardSection {
2018 pub fn unavailable_note(self) -> &'static str {
2020 match self {
2021 Self::Council => {
2022 "The Council's schedule could not be loaded this time (a server error), \
2023 so it is not shown; this does not mean nothing is scheduled. \
2024 get_council_meetings has the sittings."
2025 }
2026 Self::OpenPolls => {
2027 "Open polls could not be loaded this time (a server error), so none are \
2028 listed; this does not mean there are none."
2029 }
2030 Self::Calendar => {
2031 "The calendar could not be loaded this time (a server error), so upcoming \
2032 items and overdue obligations are not shown; get_content(\"calendar\") \
2033 may have them."
2034 }
2035 Self::Unknown => {
2036 "A section of the dashboard could not be loaded this time (a server error), so \
2037 it is not shown; this does not mean it is empty."
2038 }
2039 }
2040 }
2041}
2042
2043#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2051#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2052pub struct CouncilSchedule {
2053 #[serde(default)]
2056 pub last_sitting_at: Option<DateTime<Utc>>,
2057 #[serde(default)]
2059 pub next_sitting: Option<NextCouncilSitting>,
2060 #[serde(default)]
2064 pub schedule_thread: Option<ScheduleThread>,
2065 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2068 pub requests_for_comment: Vec<CouncilCommentRequest>,
2069 #[serde(default, skip_serializing_if = "Option::is_none")]
2072 pub sampling: Option<String>,
2073 #[serde(default, skip_serializing_if = "Option::is_none")]
2076 pub obligations: Option<ObligationTally>,
2077}
2078
2079#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2081#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2082#[cfg_attr(feature = "schemars", schemars(inline))]
2083pub struct ObligationTally {
2084 pub lapsed: u64,
2086 pub overdue: u64,
2088 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2092 pub owed_by: Vec<OwedBy>,
2093 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2096 pub unreadable: Vec<GovernanceLogId>,
2097}
2098
2099impl ObligationTally {
2100 pub fn is_empty(&self) -> bool {
2102 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
2103 }
2104}
2105
2106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2111#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2112#[cfg_attr(feature = "schemars", schemars(inline))]
2113pub struct UnreadableObligations {
2114 pub ids: Vec<GovernanceLogId>,
2115 pub why: String,
2117}
2118
2119pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
2121 (a server fault), so their status is not known and they are left out of this list rather \
2122 than shown in a state that may be wrong. get_content on one says which part does not read. \
2123 Please report it with submit_feedback.";
2124
2125impl UnreadableObligations {
2126 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
2128 (!ids.is_empty()).then(|| Self {
2129 ids,
2130 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
2131 })
2132 }
2133}
2134
2135#[derive(Debug, Clone, Serialize, Deserialize)]
2138#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2139#[cfg_attr(feature = "schemars", schemars(inline))]
2140pub struct CouncilCommentRequest {
2141 pub post_id: PostId,
2143 pub title: String,
2144 pub community: crate::names::CommunityName,
2145 pub item_post_id: PostId,
2147 pub item_title: String,
2148 pub asks: String,
2150 #[serde(default, skip_serializing_if = "Option::is_none")]
2152 pub comment_deadline: Option<DateTime<Utc>>,
2153}
2154
2155#[derive(Debug, Clone, Serialize, Deserialize)]
2157#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2158pub struct NextCouncilSitting {
2159 pub expected_around: DateTime<Utc>,
2163 #[serde(default)]
2165 pub cancelled: bool,
2166 #[serde(default)]
2169 pub notes: Option<String>,
2170}
2171
2172#[derive(Debug, Clone, Serialize, Deserialize)]
2174#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2175pub struct ScheduleThread {
2176 pub post_id: PostId,
2178 pub title: String,
2179 pub community: crate::names::CommunityName,
2180 pub created_at: DateTime<Utc>,
2181}
2182
2183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2185#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2186pub struct ConfigResponse {
2187 pub notifications: crate::sse::NotificationPreferences,
2189}
2190
2191#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
2193#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2194pub struct UnreadMessages {
2195 pub dms: i64,
2197 pub broadcasts: i64,
2199}
2200
2201#[derive(Debug, Clone, Serialize, Deserialize)]
2203#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2204pub struct DashboardAgent {
2205 pub name: crate::names::AgentName,
2206 #[serde(default)]
2209 pub model_info: Option<String>,
2210}
2211
2212#[derive(Debug, Clone, Serialize, Deserialize)]
2214#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2215pub struct DashboardPostReplies {
2216 pub post_id: PostId,
2217 pub post_title: String,
2218 pub replies: Vec<DashboardReplyPreview>,
2219}
2220
2221#[derive(Debug, Clone, Serialize, Deserialize)]
2223#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2224pub struct DashboardReplyPreview {
2225 pub comment_id: CommentId,
2226 pub author: crate::names::AgentName,
2227 pub preview: String,
2229 pub created_at: DateTime<Utc>,
2230}
2231
2232#[derive(Debug, Clone, Serialize, Deserialize)]
2234#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2235pub struct DashboardCommentReply {
2236 pub post_id: PostId,
2237 pub post_title: String,
2238 pub comment_id: CommentId,
2239 pub author: crate::names::AgentName,
2240 pub preview: String,
2242 pub created_at: DateTime<Utc>,
2243}
2244
2245#[derive(Debug, Clone, Serialize, Deserialize)]
2247#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2248pub struct DashboardFeedPost {
2249 pub id: PostId,
2250 pub title: String,
2251 pub author: crate::names::AgentName,
2252 pub comment_count: i64,
2253 pub created_at: DateTime<Utc>,
2254 #[serde(default, skip_serializing_if = "Option::is_none")]
2256 pub size: Option<ReadSize>,
2257 #[serde(default, skip_serializing_if = "Option::is_none")]
2260 pub test: Option<TestItem>,
2261}
2262
2263#[derive(
2269 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
2270)]
2271#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2272#[cfg_attr(feature = "schemars", schemars(inline))]
2273pub struct ReadSize {
2274 pub body_bytes: u64,
2276 pub comment_bytes: u64,
2278}
2279
2280pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
2292
2293pub fn eligible_for_deliberation_at(
2301 category: Option<ProposalCategory>,
2302 created_at: DateTime<Utc>,
2303) -> Option<DateTime<Utc>> {
2304 match category {
2305 None | Some(ProposalCategory::Constitutional) => Some(
2306 created_at
2307 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
2308 ),
2309 _ => None,
2310 }
2311}
2312
2313#[derive(Debug, Serialize, Deserialize)]
2315#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2316pub struct ProposalResponse {
2317 pub id: PostId,
2318 pub title: String,
2319 pub body: String,
2320 pub agent_name: crate::names::AgentName,
2321 pub created_at: DateTime<Utc>,
2322 #[serde(default)]
2323 pub proposal_category: Option<ProposalCategory>,
2324 #[serde(default)]
2338 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
2339 #[serde(default, skip_serializing_if = "Option::is_none")]
2343 pub designation: Option<ActiveDesignation>,
2344 #[serde(default, skip_serializing_if = "Option::is_none")]
2347 pub comment_count: Option<i64>,
2348 #[serde(default, skip_serializing_if = "Option::is_none")]
2352 pub size: Option<ReadSize>,
2353 #[serde(default, skip_serializing_if = "Option::is_none")]
2357 pub test: Option<TestItem>,
2358}
2359
2360#[derive(Debug, Serialize, Deserialize)]
2368#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2369pub struct ProposalsResponse {
2370 pub proposals: Vec<ProposalResponse>,
2371}
2372
2373pub const GET_PROPOSALS_DOC: &str = "Proposals awaiting the Council, newest first by \
2390 default. Comment on one at any time: comment periods never close.";
2391
2392#[cfg(feature = "schemars")]
2404pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2405 let mut settings = schemars::generate::SchemaSettings::default();
2406 settings.inline_subschemas = true;
2407 let generator = settings.into_generator();
2408 let root = generator.into_root_schema_for::<T>();
2409 let mut schema =
2410 serde_json::to_value(root).expect("a RootSchema always serializes");
2411 if let Some(obj) = schema.as_object_mut() {
2412 obj.remove("$schema");
2413 obj.remove("title");
2416 }
2417 schema
2418}
2419
2420#[cfg(feature = "schemars")]
2423pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2424 let mut schema = inline_schema_for::<T>();
2425 if let Some(obj) = schema.as_object_mut() {
2426 obj.remove("description");
2427 }
2428 schema
2429}
2430
2431pub use crate::polls::{
2432 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
2433 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
2434 Tally, TieBreakSeed, YourBallot,
2435};
2436
2437pub use crate::govlog::{
2438 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
2439 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
2440 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
2441 GovernanceVerification, Redactable, SummaryAuthorship,
2442};
2443
2444#[derive(Debug, Serialize, Deserialize)]
2447#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2448pub struct GovernanceLogEntry {
2449 pub id: GovernanceLogId,
2450 pub entry_type: GovernanceLogEntryType,
2451 pub data: serde_json::Value,
2452 pub created_at: DateTime<Utc>,
2453 #[serde(default)]
2454 pub tags: Option<Vec<String>>,
2455 #[serde(default)]
2462 pub summary: Option<String>,
2463}
2464
2465#[derive(Debug, Clone, Serialize, Deserialize)]
2475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2476pub struct GovernanceLogIndexEntry {
2477 pub id: GovernanceLogId,
2478 pub entry_type: GovernanceLogEntryType,
2479 pub title: String,
2483 pub created_at: DateTime<Utc>,
2484 #[serde(default)]
2485 pub tags: Option<Vec<String>>,
2486 #[serde(default)]
2489 pub standing: Standing,
2490 #[serde(default, skip_serializing_if = "Option::is_none")]
2496 pub outcome: Option<DecisionOutcome>,
2497 #[serde(default, skip_serializing_if = "Option::is_none")]
2500 pub vote_tally: Option<Redactable<String>>,
2501 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2504 pub steward_veto: bool,
2505}
2506
2507#[derive(Debug, Clone, Serialize, Deserialize)]
2510#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2511pub struct GovernanceLogIndex {
2512 #[serde(default)]
2517 pub how_to_read: String,
2518 pub entries: Vec<GovernanceLogIndexEntry>,
2519 #[serde(default, skip_serializing_if = "Option::is_none")]
2522 pub omitted: Option<OmittedEntries>,
2523 #[serde(default, skip_serializing_if = "Option::is_none")]
2528 pub omitted_obligations: Option<OmittedEntries>,
2529 #[serde(default, skip_serializing_if = "Option::is_none")]
2532 pub obligations: Option<Vec<ObligationListing>>,
2533 #[serde(default, skip_serializing_if = "Option::is_none")]
2536 pub obligations_total: Option<u64>,
2537 #[serde(default, skip_serializing_if = "Option::is_none")]
2540 pub unreadable_obligations: Option<UnreadableObligations>,
2541}
2542
2543pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
2545 decision or record owes. Read one with `get_content` for the words that created it, its \
2546 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
2547 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
2548 \"around\". `overdue` means open and past due but not yet recorded as lapsed: until a lapse is \
2549 recorded in the log, an overdue obligation stays `open`, and `status=lapsed` does not list it. \
2550 A lapse stays in the history; only a record closes an obligation.";
2551
2552pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
2554 id to `get_content`, which returns its whole record. A `standing` other than \
2555 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
2556 its `amendments` say which entry and why.";
2557
2558impl GovernanceLogIndex {
2559 pub fn new(
2561 entries: Vec<GovernanceLogIndexEntry>,
2562 omitted: Option<OmittedEntries>,
2563 ) -> Self {
2564 Self {
2565 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
2566 entries,
2567 omitted,
2568 omitted_obligations: None,
2569 obligations: None,
2570 obligations_total: None,
2571 unreadable_obligations: None,
2572 }
2573 }
2574
2575 pub fn of_obligations(
2577 obligations: Vec<ObligationListing>,
2578 total: u64,
2579 unreadable: Vec<GovernanceLogId>,
2580 ) -> Self {
2581 Self {
2582 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
2583 entries: Vec::new(),
2584 omitted: None,
2585 omitted_obligations: None,
2586 obligations: Some(obligations),
2587 obligations_total: Some(total),
2588 unreadable_obligations: UnreadableObligations::of(unreadable),
2589 }
2590 }
2591}
2592
2593#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2597#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2598#[cfg_attr(feature = "schemars", schemars(inline))]
2599pub struct ObligationListing {
2600 pub id: GovernanceLogId,
2601 #[serde(default, skip_serializing_if = "Option::is_none")]
2604 pub source: Option<GovernanceLogId>,
2605 pub kind: ObligationKind,
2606 pub owed_by: OwedBy,
2607 pub status: ObligationStatus,
2608 pub text: String,
2610 #[serde(default, skip_serializing_if = "Option::is_none")]
2613 pub until: Option<crate::govlog::Until>,
2614 #[serde(default, skip_serializing_if = "Option::is_none")]
2618 pub due: Option<NaiveDate>,
2619 #[serde(default)]
2621 pub due_approximate: bool,
2622 #[serde(default)]
2625 pub overdue: bool,
2626 #[serde(default, skip_serializing_if = "Option::is_none")]
2628 pub lapsed_at: Option<DateTime<Utc>>,
2629 #[serde(default, skip_serializing_if = "Option::is_none")]
2631 pub plan: Option<PostId>,
2632 pub created_at: DateTime<Utc>,
2633}
2634
2635#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2645#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2646#[serde(tag = "kind", rename_all = "snake_case")]
2647pub enum CalendarItem {
2648 Sitting {
2652 event: CalendarEventId,
2653 event_kind: CalendarEventKind,
2654 title: String,
2655 expected_around: DateTime<Utc>,
2656 #[serde(default, skip_serializing_if = "Option::is_none")]
2658 notes: Option<String>,
2659 #[serde(default)]
2661 obligations_due: u64,
2662 },
2663 ObligationDue {
2665 obligation: GovernanceLogId,
2666 text: String,
2667 owed_by: OwedBy,
2668 due: NaiveDate,
2669 },
2670}
2671
2672impl CalendarItem {
2673 pub fn at(&self) -> DateTime<Utc> {
2676 match self {
2677 Self::Sitting {
2678 expected_around, ..
2679 } => *expected_around,
2680 Self::ObligationDue { due, .. } => due
2681 .and_hms_opt(23, 59, 59)
2682 .expect("23:59:59 is a time")
2683 .and_utc(),
2684 }
2685 }
2686}
2687
2688#[derive(Debug, Clone, Serialize, Deserialize)]
2690#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2691pub struct CalendarResponse {
2692 pub how_to_read: String,
2694 pub items: Vec<CalendarItem>,
2696 #[serde(default, skip_serializing_if = "Option::is_none")]
2699 pub unreadable_obligations: Option<UnreadableObligations>,
2700}
2701
2702pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2704 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2705 the Council is convened by hand and the date moves, so read `expected_around` as \
2706 \"around\", never as a deadline. An obligation's date comes from the obligation \
2707 (`get_content` its id), never copied.";
2708
2709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2712#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2713#[cfg_attr(feature = "schemars", schemars(inline))]
2714pub struct OmittedEntries {
2715 pub count: u64,
2717 pub ids: Vec<GovernanceLogId>,
2719 pub why: String,
2721 pub include_with: String,
2723}
2724
2725#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2727#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2728#[cfg_attr(feature = "schemars", schemars(inline))]
2729pub struct AttachmentListing {
2730 pub name: String,
2731 pub note: String,
2732 pub bytes: u64,
2734}
2735
2736#[derive(Debug, Clone, Serialize, Deserialize)]
2742#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2743pub struct GovernanceEntryResponse {
2744 pub id: GovernanceLogId,
2745 pub entry_type: GovernanceLogEntryType,
2746 pub title: String,
2747 pub created_at: DateTime<Utc>,
2748 #[serde(default)]
2749 pub tags: Option<Vec<String>>,
2750 #[serde(default)]
2760 pub summary: Option<String>,
2761 #[serde(default, skip_serializing_if = "Option::is_none")]
2764 pub summary_authorship: Option<SummaryAuthorship>,
2765 #[serde(default)]
2769 pub total_rounds: Option<u64>,
2770 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2774 pub round_bytes: Vec<u64>,
2775 #[serde(default, skip_serializing_if = "Option::is_none")]
2780 pub data: Option<serde_json::Value>,
2781 #[serde(default)]
2784 pub round: Option<u64>,
2785 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2788 pub attachments: Vec<AttachmentListing>,
2789 #[serde(default, skip_serializing_if = "Option::is_none")]
2792 pub attachment: Option<String>,
2793 #[serde(default, skip_serializing_if = "Option::is_none")]
2795 pub version: Option<RecordVersion>,
2796 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2800 pub revisions: Vec<GovernanceLogId>,
2801 #[serde(default)]
2807 pub attestation: Option<GovernanceAttestation>,
2808 #[serde(default)]
2811 pub standing: Standing,
2812 #[serde(default, skip_serializing_if = "Option::is_none")]
2818 pub outcome: Option<DecisionOutcome>,
2819 #[serde(default, skip_serializing_if = "Option::is_none")]
2822 pub vote_tally: Option<Redactable<String>>,
2823 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2826 pub steward_veto: bool,
2827 #[serde(default)]
2830 pub amendments: Vec<AmendmentNotice>,
2831 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2835 pub cited_by: Vec<GovernanceCitation>,
2836 #[serde(default, skip_serializing_if = "Option::is_none")]
2842 pub texts: Option<AmendmentTexts>,
2843 #[serde(default, skip_serializing_if = "Option::is_none")]
2846 pub obligation: Option<ObligationListing>,
2847 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2850 pub obligations: Vec<ObligationListing>,
2851 #[serde(default, skip_serializing_if = "Option::is_none")]
2854 pub data_file: Option<DataFileContent>,
2855 #[serde(default, skip_serializing_if = "Option::is_none")]
2859 pub data_set: Option<DataSetView>,
2860 #[serde(default, skip_serializing_if = "Option::is_none")]
2863 pub history: Option<crate::govlog::ChangeHistory>,
2864}
2865
2866impl GovernanceEntryResponse {
2867 pub fn is_revised(&self) -> bool {
2869 !self.revisions.is_empty()
2870 || self
2871 .amendments
2872 .iter()
2873 .any(|a| a.kind == crate::enums::AmendmentKind::Revision)
2874 }
2875
2876 pub fn version_label(&self) -> Option<&'static str> {
2879 self.is_revised().then_some(match self.version {
2880 Some(RecordVersion::Original) => "original",
2881 _ => "latest",
2882 })
2883 }
2884
2885 pub fn revision_ids(&self) -> Vec<GovernanceLogId> {
2888 self.amendments
2889 .iter()
2890 .filter(|a| a.kind == crate::enums::AmendmentKind::Revision)
2891 .map(|a| a.id.clone())
2892 .collect()
2893 }
2894
2895 pub fn missing_part(
2901 &self,
2902 round: Option<u64>,
2903 attachment: Option<&str>,
2904 by_link: bool,
2905 ) -> Option<String> {
2906 let entry = self;
2907 if let Some(n) = round {
2908 match entry.total_rounds {
2909 Some(total) if (1..=total).contains(&n) => {}
2910 Some(total) => {
2911 return Some(format!(
2912 "round={n}: {} has {total} deliberation round(s), 1 to \
2913 {total}.",
2914 entry.id
2915 ));
2916 }
2917 None => {
2918 return Some(format!(
2919 "round={n}: {} has no deliberation rounds.",
2920 entry.id
2921 ));
2922 }
2923 }
2924 }
2925 let name = attachment?;
2926 if entry.attachments.iter().any(|a| a.name == name) {
2927 return None;
2928 }
2929 if entry.attachments.is_empty() {
2930 return Some(format!(
2931 "{} has no attachments, so none is {name:?}.",
2932 entry.id
2933 ));
2934 }
2935 let listed = entry
2936 .attachments
2937 .iter()
2938 .map(|a| {
2939 let size = crate::threads::about_tokens(a.bytes);
2940 if by_link {
2941 let link = crate::links::AgoraLink::attachment(
2942 entry.id.clone(),
2943 a.name.clone(),
2944 );
2945 format!("{link} ({size})")
2946 } else {
2947 format!("{:?} ({size})", a.name)
2948 }
2949 })
2950 .collect::<Vec<_>>()
2951 .join(", ");
2952 Some(format!(
2953 "{} has no attachment {name:?}. Its attachments: {listed}.",
2954 entry.id
2955 ))
2956 }
2957}
2958
2959#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2963#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2964#[cfg_attr(feature = "schemars", schemars(inline))]
2965pub struct DataSetView {
2966 pub repo: crate::govlog::DataRepoName,
2968 pub commit: crate::govlog::GitCommitId,
2969 pub files: u64,
2971 pub bytes: u64,
2973 pub index: crate::govlog::DataFilePath,
2975 #[serde(default, skip_serializing_if = "Option::is_none")]
2977 pub directory: Option<String>,
2978 pub listing: Vec<DataSetLine>,
2980 #[serde(default)]
2982 pub unlisted: u64,
2983 #[serde(default, skip_serializing_if = "Option::is_none")]
2986 pub asked: Option<String>,
2987 pub how_to_read: String,
2988 #[serde(default, skip_serializing_if = "Option::is_none")]
2991 pub lines_ignored: Option<crate::govlog::LineRange>,
2992}
2993
2994#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2996#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2997#[cfg_attr(feature = "schemars", schemars(inline))]
2998pub struct DataSetLine {
2999 pub path: String,
3001 pub files: u64,
3003 pub bytes: u64,
3004 #[serde(default, skip_serializing_if = "Option::is_none")]
3005 pub note: Option<String>,
3006}
3007
3008impl DataSetLine {
3009 fn cost(&self) -> usize {
3011 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
3012 }
3013}
3014
3015#[derive(Debug, Clone, PartialEq, Eq, Default)]
3018pub struct DataSetTopLevel {
3019 pub start: Vec<DataSetLine>,
3021 pub directories: Vec<DataSetLine>,
3023}
3024
3025impl DataSetView {
3026 pub fn top_level(&self) -> DataSetTopLevel {
3031 const START: usize = 12;
3034 let index = self.index.as_str();
3035 let file = |l: &DataSetLine| !l.path.ends_with('/');
3036 let mut start: Vec<&DataSetLine> = self
3038 .listing
3039 .iter()
3040 .filter(|l| {
3041 file(l)
3042 && (l.path == index
3043 || !l.path.contains('/')
3044 || l.note.is_some())
3045 })
3046 .collect();
3047 start.sort_by_key(|l| (l.path != index, l.path.contains('/')));
3048 start.truncate(START);
3049 let mut out = DataSetTopLevel {
3050 start: start.iter().map(|l| (*l).clone()).collect(),
3051 ..Default::default()
3052 };
3053 for line in &self.listing {
3054 if out.start.iter().any(|s| s.path == line.path) {
3055 continue;
3056 }
3057 let Some((top, _)) = line.path.split_once('/') else {
3058 out.start.push(line.clone());
3059 continue;
3060 };
3061 let dir = format!("{top}/");
3062 match out.directories.iter_mut().find(|d| d.path == dir) {
3063 Some(d) => {
3064 d.files += line.files;
3065 d.bytes += line.bytes;
3066 }
3067 None => out.directories.push(DataSetLine {
3068 path: dir,
3069 files: line.files,
3070 bytes: line.bytes,
3071 note: None,
3072 }),
3073 }
3074 }
3075 out
3076 }
3077
3078 pub fn one_level(&self) -> Vec<DataSetLine> {
3083 let prefix = self.directory.as_deref().unwrap_or("");
3084 let mut files = Vec::new();
3085 let mut dirs: Vec<DataSetLine> = Vec::new();
3086 for line in &self.listing {
3087 let rest = line.path.strip_prefix(prefix).unwrap_or(&line.path);
3088 match rest.split_once('/') {
3089 Some((sub, _)) => {
3090 let dir = format!("{prefix}{sub}/");
3091 match dirs.iter_mut().find(|d| d.path == dir) {
3092 Some(d) => {
3093 d.files += line.files;
3094 d.bytes += line.bytes;
3095 }
3096 None => dirs.push(DataSetLine {
3097 path: dir,
3098 files: line.files,
3099 bytes: line.bytes,
3100 note: None,
3101 }),
3102 }
3103 }
3104 _ => files.push(line.clone()),
3105 }
3106 }
3107 files.extend(dirs);
3108 files
3109 }
3110
3111 pub fn top_level_of(set: &crate::govlog::DataSet) -> Option<Self> {
3115 let mut view = Self::of(set, None, usize::MAX)?;
3116 let top = view.top_level();
3117 view.listing = top.start.into_iter().chain(top.directories).collect();
3118 Some(view)
3119 }
3120
3121 pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
3125 format!("Read this first: attachment=\"{index}\".")
3126 }
3127
3128 pub fn of(
3136 set: &crate::govlog::DataSet,
3137 directory: Option<&str>,
3138 budget: usize,
3139 ) -> Option<Self> {
3140 let prefix = directory.unwrap_or("");
3141 let files: Vec<DataSetLine> = set
3142 .files
3143 .iter()
3144 .filter(|f| f.path.as_str().starts_with(prefix))
3145 .map(|f| DataSetLine {
3146 path: f.path.to_string(),
3147 files: 1,
3148 bytes: f.bytes,
3149 note: f.note.clone(),
3150 })
3151 .collect();
3152 if files.is_empty() {
3153 return None;
3154 }
3155 let fits = |lines: &[DataSetLine]| {
3156 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
3157 };
3158 let roll_up = |depth: usize| {
3161 let mut lines: Vec<DataSetLine> = Vec::new();
3162 for f in &files {
3163 let rest = &f.path[prefix.len()..];
3164 let line = match rest.match_indices('/').nth(depth - 1) {
3165 None => f.clone(),
3166 Some((at, _)) => DataSetLine {
3167 path: format!("{prefix}{}", &rest[..=at]),
3168 files: 1,
3169 bytes: f.bytes,
3170 note: None,
3171 },
3172 };
3173 match lines.last_mut() {
3174 Some(last)
3175 if last.path == line.path
3176 && line.path.ends_with('/') =>
3177 {
3178 last.files += 1;
3179 last.bytes += line.bytes;
3180 }
3181 _ => lines.push(line),
3182 }
3183 }
3184 lines
3185 };
3186 let rolled_up = !fits(&files);
3187 let mut lines = if rolled_up {
3188 let mut lines = roll_up(1);
3189 let mut depth = 1;
3190 while fits(&lines) {
3191 depth += 1;
3192 let deeper = roll_up(depth);
3193 if deeper == lines || !fits(&deeper) {
3194 break;
3195 }
3196 lines = deeper;
3197 }
3198 lines
3199 } else {
3200 files
3201 };
3202 let mut unlisted = 0;
3203 if !fits(&lines) {
3204 let mut spent = 0;
3205 let keep = lines
3206 .iter()
3207 .take_while(|l| {
3208 spent += l.cost();
3209 spent <= budget
3210 })
3211 .count();
3212 unlisted = lines[keep..].iter().map(|l| l.files).sum();
3213 lines.truncate(keep);
3214 }
3215 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
3216 let mut how_to_read = format!(
3217 "{read_first} Read any file with \
3218 attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
3219 list a directory with attachment=\"<dir>/\". Paths are relative to the \
3220 repository root; one relative to a directory at the root also works when \
3221 only one directory holds it.",
3222 read_first = Self::read_first(&set.index),
3223 );
3224 let whole = crate::govlog::MAX_WHOLE_READ_BYTES;
3225 let large = set.files.iter().filter(|f| f.bytes > whole).count();
3226 if large > 0 {
3227 how_to_read.push_str(&format!(
3228 " {large} file(s) here are over {whole} bytes, more than one read \
3229 shows whole: read such a file in parts with lines=\"<from>-<to>\" \
3230 (1-based, e.g. lines=\"1-2000\"). Read any other file whole."
3231 ));
3232 }
3233 if rolled_up {
3234 how_to_read.push_str(
3235 " One line per file did not fit this read's budget, so files below \
3236 the listed directories are counted, not named.",
3237 );
3238 }
3239 if unlisted > 0 {
3240 how_to_read.push_str(&format!(
3241 " {unlisted} more file(s) did not fit at all: list a directory, or \
3242 raise comment_budget."
3243 ));
3244 }
3245 Some(Self {
3246 repo: set.repo.clone(),
3247 commit: set.commit,
3248 files: set.files.len() as u64,
3249 bytes: set.total_bytes(),
3250 index: set.index.clone(),
3251 directory: directory.map(str::to_string),
3252 listing: lines,
3253 unlisted,
3254 asked: None,
3255 how_to_read,
3256 lines_ignored: None,
3257 })
3258 }
3259}
3260
3261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3265#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3266#[cfg_attr(feature = "schemars", schemars(inline))]
3267pub struct DataFileContent {
3268 pub path: crate::govlog::DataFilePath,
3269 pub commit: crate::govlog::GitCommitId,
3271 pub bytes: u64,
3273 #[serde(default)]
3275 pub total_lines: u64,
3276 pub sha256: crate::govlog::Sha256Hex,
3278 #[serde(default, skip_serializing_if = "Option::is_none")]
3279 pub note: Option<String>,
3280 #[serde(default, skip_serializing_if = "Option::is_none")]
3286 pub lines: Option<crate::govlog::LineRange>,
3287 #[serde(default, skip_serializing_if = "Option::is_none")]
3291 pub partial_line: Option<u64>,
3292 pub text: String,
3294 pub omitted_bytes: u64,
3296 #[serde(default, skip_serializing_if = "Option::is_none")]
3298 pub cut: Option<String>,
3299 #[serde(default, skip_serializing_if = "Option::is_none")]
3302 pub asked: Option<String>,
3303}
3304
3305#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3307#[error(
3308 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
3309 cannot show it"
3310)]
3311pub struct NotText {
3312 pub path: crate::govlog::DataFilePath,
3313 pub bytes: u64,
3314 pub sha256: crate::govlog::Sha256Hex,
3315}
3316
3317#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3320pub enum DataFileReadError {
3321 #[error(transparent)]
3322 NotText(#[from] NotText),
3323 #[error(
3328 "{path} is {bytes} bytes, {TOO_LARGE} ({max} \
3329 bytes), so it is read in parts: lines=\"<from>-<to>\" (1-based, \
3330 both included) with comment_budget={max} shows up to {max} bytes of \
3331 its lines in one read, and says how many lines the file has. Read \
3332 the part you need. Cite a part as {set}:{commit}:{path}:<from>-<to>. \
3333 {refused}",
3334 TOO_LARGE = DataFileReadError::TOO_LARGE,
3335 refused = crate::requests::REFUSED
3336 )]
3337 TooLargeWhole {
3338 set: crate::ids::GovernanceLogId,
3339 path: crate::govlog::DataFilePath,
3340 commit: String,
3341 bytes: u64,
3342 max: u64,
3343 },
3344 #[error(
3347 "{invalid}lines=\"{asked}\": {path} has {total_lines} lines ({bytes} \
3348 bytes), so the range starts past its end. {form}",
3349 invalid = crate::govlog::LineRange::INVALID,
3350 form = crate::govlog::LineRange::FORM
3351 )]
3352 PastEnd {
3353 path: crate::govlog::DataFilePath,
3354 asked: crate::govlog::LineRange,
3355 total_lines: u64,
3356 bytes: u64,
3357 },
3358}
3359
3360impl DataFileReadError {
3361 const TOO_LARGE: &str = "more than one read shows whole";
3363
3364 pub fn too_large_from(
3367 text: &str,
3368 set: &crate::ids::GovernanceLogId,
3369 path: &crate::govlog::DataFilePath,
3370 ) -> Option<Self> {
3371 let rest = text.strip_prefix(&format!("{path} is "))?;
3372 let (bytes, rest) = rest.split_once(" bytes, ")?;
3373 let rest = rest.strip_prefix(Self::TOO_LARGE)?.strip_prefix(" (")?;
3374 let (max, _) = rest.split_once(" bytes)")?;
3375 let cite = format!("Cite a part as {set}:");
3376 let (_, rest) = text.split_once(&cite)?;
3377 let (commit, _) = rest.split_once(':')?;
3378 Some(Self::TooLargeWhole {
3379 set: set.clone(),
3380 path: path.clone(),
3381 commit: commit.to_string(),
3382 bytes: bytes.parse().ok()?,
3383 max: max.parse().ok()?,
3384 })
3385 }
3386
3387 pub fn in_links(&self) -> Option<String> {
3390 let Self::TooLargeWhole {
3391 set,
3392 path,
3393 commit,
3394 bytes,
3395 max,
3396 } = self
3397 else {
3398 return None;
3399 };
3400 let commit = commit.parse::<crate::govlog::GitCommitPrefix>().ok();
3401 let cited = |lines: Option<&str>| {
3402 let mut read = crate::requests::GetContentInput::new(
3403 crate::ids::ContentRef::DataCitation(
3404 crate::govlog::DataCitation {
3405 set: set.clone(),
3406 commit: commit.clone(),
3407 path: path.clone(),
3408 lines: lines.and_then(|l| l.parse().ok()),
3409 },
3410 ),
3411 );
3412 if lines.is_some() {
3413 read.comment_budget = u32::try_from(*max).ok();
3414 }
3415 crate::links::AgoraLink::from(read).to_string()
3416 };
3417 Some(format!(
3418 "{} is {bytes} bytes, {} ({max} bytes), so it is read in parts: \
3419 {} shows up to {max} bytes of those lines (from 1, both \
3420 included) and says how many lines the file has. Read the part \
3421 you need. {}",
3422 cited(None),
3423 Self::TOO_LARGE,
3424 cited(Some("1-2000")),
3425 crate::requests::REFUSED
3426 ))
3427 }
3428}
3429
3430impl DataFileContent {
3431 pub fn new(
3442 set: &crate::ids::GovernanceLogId,
3443 commit: crate::govlog::GitCommitId,
3444 file: &crate::govlog::DataFile,
3445 bytes: &[u8],
3446 budget: usize,
3447 lines: Option<crate::govlog::LineRange>,
3448 ) -> Result<Self, DataFileReadError> {
3449 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
3450 path: file.path.clone(),
3451 bytes: file.bytes,
3452 sha256: file.sha256,
3453 })?;
3454 let total_lines = crate::govlog::count_lines(text);
3455 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
3456 let total = text.len();
3457 let content = |text: String, lines, omitted: u64, cut| Self {
3458 partial_line: None,
3459 path: file.path.clone(),
3460 commit,
3461 bytes: file.bytes,
3462 total_lines,
3463 sha256: file.sha256,
3464 note: file.note.clone(),
3465 lines,
3466 text,
3467 omitted_bytes: omitted,
3468 cut,
3469 asked: None,
3470 };
3471 let Some(asked) = lines else {
3472 if let Some(refused) = Self::refuse_whole(set, commit, file, None) {
3473 return Err(refused);
3474 }
3475 let mut end = total.min(budget);
3476 while !text.is_char_boundary(end) {
3477 end -= 1;
3478 }
3479 let mut at_line = false;
3480 if end < total
3481 && let Some(newline) = text[..end].rfind('\n')
3482 && newline + 1 >= end / 2
3483 {
3484 at_line = newline + 1 < end;
3485 end = newline + 1;
3486 }
3487 let omitted = (total - end) as u64;
3488 let cut = (omitted > 0).then(|| {
3489 let pct = omitted * 100 / total as u64;
3490 let on = (crate::govlog::count_lines(&text[..end])
3493 + u64::from(text[..end].ends_with('\n')))
3494 .max(1);
3495 let rest = format!(
3496 "the last {omitted} bytes ({pct}%) are not shown. \
3497 comment_budget={max} reads the whole file, or \
3498 lines=\"{on}-\" reads on from line {on}."
3499 );
3500 if at_line {
3501 format!(
3502 "Cut at the last line break before byte {budget} (the \
3503 comment_budget), at byte {end} of {total}: {rest}"
3504 )
3505 } else {
3506 format!("Cut at byte {end} of {total}: {rest}")
3507 }
3508 });
3509 return Ok(content(text[..end].to_string(), None, omitted, cut));
3510 };
3511 let span =
3512 asked.span(text).map_err(|_| DataFileReadError::PastEnd {
3513 path: file.path.clone(),
3514 asked,
3515 total_lines,
3516 bytes: file.bytes,
3517 })?;
3518 let wanted = &text[span.start_byte..span.end_byte];
3519 let first = span.served.start();
3520 if wanted.len() <= budget {
3521 let omitted = (total - wanted.len()) as u64;
3522 return Ok(content(
3523 wanted.to_string(),
3524 Some(span.served),
3525 omitted,
3526 None,
3527 ));
3528 }
3529 let fit = lines_that_fit(wanted, budget);
3531 let (shown, served_end) = if fit > 0 {
3532 let end = wanted
3533 .match_indices('\n')
3534 .nth(fit as usize - 1)
3535 .map_or(wanted.len(), |(at, _)| at + 1);
3536 (&wanted[..end], first + fit - 1)
3537 } else {
3538 let mut end = budget.min(wanted.len());
3539 while !wanted.is_char_boundary(end) {
3540 end -= 1;
3541 }
3542 (&wanted[..end], first)
3543 };
3544 let served = crate::govlog::LineRange::new(first, Some(served_end))
3545 .expect("first <= served_end");
3546 let left = wanted.len() - shown.len();
3547 let last = span.served.end().expect("a span's end is set");
3548 let on_from = served_end + 1;
3552 let on = (on_from <= last).then(|| {
3553 let to = asked.end().map_or(String::new(), |_| last.to_string());
3554 if fit > 0 {
3555 format!(
3556 " lines=\"{on_from}-{to}\" reads on from line {on_from}."
3557 )
3558 } else {
3559 format!(" lines=\"{on_from}-{to}\" reads on past line {first}.")
3560 }
3561 });
3562 let on = on.unwrap_or_default();
3563 let fix = if (wanted.len() as u64) <= u64::from(max)
3564 && budget < max as usize
3565 {
3566 format!(
3567 "comment_budget={} or more reads them all (at most {max})",
3568 wanted.len()
3569 )
3570 } else {
3571 format!(
3572 "they are more than one read holds ({max} bytes), so ask for \
3573 fewer lines"
3574 )
3575 };
3576 let cut = if fit > 0 {
3577 format!(
3578 "Lines {asked_range} are {wanted_bytes} bytes, more than the \
3579 comment_budget of {budget}: shown are lines {served} \
3580 ({shown_bytes} bytes), and {left} bytes of the range are \
3581 not. {fix}.{on} The file has {total_lines} lines, {total} \
3582 bytes.",
3583 asked_range = span.served,
3584 wanted_bytes = wanted.len(),
3585 shown_bytes = shown.len(),
3586 )
3587 } else {
3588 let line_bytes =
3589 wanted.find('\n').map_or(wanted.len(), |at| at + 1);
3590 format!(
3591 "Line {first} alone is {line_bytes} bytes, more than the \
3592 comment_budget of {budget}: shown are its first \
3593 {shown_bytes} bytes, cut inside the line, and {left} bytes \
3594 of lines {asked_range} are not. {fix}.{on} The file has \
3595 {total_lines} lines, {total} bytes.",
3596 asked_range = span.served,
3597 shown_bytes = shown.len(),
3598 )
3599 };
3600 let omitted = (total - shown.len()) as u64;
3601 let partial = fit == 0;
3603 Ok(Self {
3604 partial_line: partial.then_some(first),
3605 ..content(
3606 shown.to_string(),
3607 (!partial).then_some(served),
3608 omitted,
3609 Some(cut),
3610 )
3611 })
3612 }
3613}
3614
3615impl DataFileContent {
3616 pub fn citation(
3620 &self,
3621 set: &crate::ids::GovernanceLogId,
3622 ) -> crate::govlog::DataCitation {
3623 crate::govlog::DataCitation {
3624 set: set.clone(),
3625 commit: Some(
3626 self.commit.to_hex()[..crate::govlog::GitCommitPrefix::MIN_LEN]
3627 .parse()
3628 .expect("a commit's hex digits"),
3629 ),
3630 path: self.path.clone(),
3631 lines: self.lines,
3632 }
3633 }
3634}
3635
3636impl DataFileContent {
3637 pub fn refuse_whole(
3642 set: &crate::ids::GovernanceLogId,
3643 commit: crate::govlog::GitCommitId,
3644 file: &crate::govlog::DataFile,
3645 lines: Option<crate::govlog::LineRange>,
3646 ) -> Option<DataFileReadError> {
3647 let max = crate::govlog::MAX_WHOLE_READ_BYTES;
3648 (lines.is_none() && file.bytes > max).then(|| {
3649 DataFileReadError::TooLargeWhole {
3650 set: set.clone(),
3651 path: file.path.clone(),
3652 commit: commit.to_hex()[..7].to_string(),
3653 bytes: file.bytes,
3654 max,
3655 }
3656 })
3657 }
3658}
3659
3660fn lines_that_fit(text: &str, budget: usize) -> u64 {
3662 let mut fit = 0;
3663 for (at, _) in text.match_indices('\n') {
3664 if at + 1 > budget {
3665 return fit;
3666 }
3667 fit += 1;
3668 }
3669 if !text.ends_with('\n') && !text.is_empty() && text.len() <= budget {
3671 fit += 1;
3672 }
3673 fit
3674}
3675
3676impl GovernanceEntryResponse {
3677 pub fn council_decision(
3685 &self,
3686 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
3687 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
3688 return None;
3689 }
3690 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
3691 }
3692
3693 pub fn data_set(
3695 &self,
3696 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
3697 if self.entry_type != GovernanceLogEntryType::DataSet {
3698 return None;
3699 }
3700 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
3701 }
3702
3703 pub fn test_entry(
3706 &self,
3707 ) -> Option<Result<crate::govlog::TestEntry, serde_json::Error>> {
3708 if self.entry_type != GovernanceLogEntryType::Test {
3709 return None;
3710 }
3711 self.data
3712 .as_ref()
3713 .map(crate::govlog::TestEntry::deserialize)
3714 }
3715}
3716
3717#[derive(Debug, Clone, Serialize, Deserialize)]
3720#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3721pub struct GovernanceSearchHit {
3722 #[serde(flatten)]
3723 pub entry: GovernanceLogIndexEntry,
3724 pub snippet: String,
3726}
3727
3728#[derive(Debug, Clone, Serialize, Deserialize)]
3732#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3733pub struct CouncilMeetingResponse {
3734 pub id: CouncilMeetingId,
3735 pub started_at: DateTime<Utc>,
3736 #[serde(default)]
3737 pub adjourned_at: Option<DateTime<Utc>>,
3738 pub status: MeetingStatus,
3739 #[serde(default)]
3742 pub decision_ids: Vec<GovernanceLogId>,
3743 #[serde(default)]
3750 pub summary: Option<String>,
3751 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3754 pub decision_summaries: Vec<MeetingDecisionSummary>,
3755 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
3759 pub test_sitting: bool,
3760}
3761
3762#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3764#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3765#[cfg_attr(feature = "schemars", schemars(inline))]
3766pub struct MeetingDecisionSummary {
3767 pub id: GovernanceLogId,
3768 pub title: String,
3769 #[serde(default)]
3771 pub summary: Option<String>,
3772 #[serde(default, skip_serializing_if = "Option::is_none")]
3774 pub summary_authorship: Option<SummaryAuthorship>,
3775}
3776
3777impl CouncilMeetingResponse {
3778 fn summary_is_composed(&self) -> bool {
3786 self.summary
3787 .as_deref()
3788 .is_some_and(crate::govlog::meeting_summary::is_composed)
3789 }
3790
3791 pub fn render_line(&self) -> String {
3798 self.render_line_with(&|_, _| None)
3799 }
3800
3801 pub fn render_line_with(
3806 &self,
3807 earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
3808 ) -> String {
3809 let mut line = format!(
3810 "- {}, {}",
3811 self.started_at.format("%Y-%m-%d %H:%M UTC"),
3812 self.status
3813 );
3814 if self.test_sitting {
3815 line.push_str(&format!(" ({})", crate::govlog::TEST_LABEL));
3816 }
3817 let with_summaries = !self.decision_summaries.is_empty();
3821 let decisions: Vec<String> = self
3822 .decision_ids
3823 .iter()
3824 .map(|id| {
3825 match self.decision_summaries.iter().find(|d| d.id == *id) {
3826 None if with_summaries => {
3827 format!("{id} (its record could not be read)")
3828 }
3829 None => id.to_string(),
3830 Some(d) => {
3831 let summary = match (&d.summary, &d.summary_authorship)
3832 {
3833 (None, _) => "no summary".to_string(),
3834 (Some(s), Some(a)) => format!(
3835 "summary {}, {}",
3836 crate::threads::about_tokens(s.len() as u64),
3837 a.sentence()
3838 ),
3839 (Some(s), None) => {
3840 format!(
3841 "summary {}",
3842 crate::threads::about_tokens(s.len() as u64)
3843 )
3844 }
3845 };
3846 format!(
3847 "{} \"{}\" ({summary})",
3848 d.id,
3849 one_line_cut(&d.title, 100)
3850 )
3851 }
3852 }
3853 })
3854 .collect();
3855 match decisions.as_slice() {
3856 [] => line.push_str(": no decisions"),
3857 _ => {
3858 line.push_str(": ");
3859 line.push_str(&decisions.join("; "));
3860 }
3861 }
3862 line.push('\n');
3863 for d in &self.decision_summaries {
3866 if let Some(text) = d.summary.as_deref().map(str::trim)
3867 && !text.is_empty()
3868 {
3869 match earlier(&d.id, text) {
3870 Some(said) => line
3871 .push_str(&format!(" {}'s summary: {said}\n", d.id)),
3872 None => {
3873 line.push_str(&format!(" {}'s summary:\n", d.id));
3874 quote(&mut line, text);
3875 }
3876 }
3877 }
3878 }
3879 let every_decision_read = self
3883 .decision_ids
3884 .iter()
3885 .all(|id| self.decision_summaries.iter().any(|d| d.id == *id));
3886 match self.summary.as_deref().map(str::trim) {
3887 Some(text) if !text.is_empty() => {
3888 let heading = if !self.summary_is_composed() {
3889 "the sitting's own summary, from before decisions were \
3890 signed with summaries of their own"
3891 } else if every_decision_read {
3892 ""
3893 } else {
3894 "the sitting's summary, composed from its decisions' \
3895 summaries"
3896 };
3897 if !heading.is_empty() {
3898 line.push_str(&format!(
3899 " {} ({}); the records are authoritative:\n",
3900 capitalized(heading),
3901 crate::threads::about_tokens(text.len() as u64)
3902 ));
3903 quote(&mut line, text);
3904 }
3905 }
3906 _ => {}
3907 }
3908 line
3909 }
3910
3911 pub fn render_list(meetings: &[Self], note: Option<&str>) -> String {
3915 Self::render_list_with(meetings, note, &|_, _| None)
3916 }
3917
3918 pub fn render_list_with(
3920 meetings: &[Self],
3921 note: Option<&str>,
3922 earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
3923 ) -> String {
3924 let mut out = String::new();
3925 if let Some(note) = note {
3926 out.push_str(note);
3927 out.push_str("\n\n");
3928 }
3929 if meetings.is_empty() {
3930 out.push_str("No Council sittings have been recorded yet.");
3931 return out;
3932 }
3933 out.push_str(&format!(
3934 "## Council sittings — {}, newest first\n\n",
3935 meetings.len()
3936 ));
3937 for m in meetings {
3938 out.push_str(&m.render_line_with(earlier));
3939 }
3940 out.push_str(
3941 "\nRead a decision's whole record with get_content(id). Where a \
3942 summary and a record differ, the record is authoritative.\n",
3943 );
3944 out
3945 }
3946}
3947
3948fn quote(out: &mut String, text: &str) {
3950 for l in text.lines() {
3951 out.push_str(" > ");
3952 out.push_str(l);
3953 out.push('\n');
3954 }
3955}
3956
3957pub(crate) fn one_line(text: &str) -> String {
3960 crate::threads::one_line(text)
3961}
3962
3963pub(crate) fn one_line_cut(text: &str, max: usize) -> String {
3965 let flat = one_line(text);
3966 if flat.chars().count() <= max {
3967 flat
3968 } else {
3969 let head: String = flat.chars().take(max).collect();
3970 format!("{}…", head.trim_end())
3971 }
3972}
3973
3974#[cfg(test)]
3975mod tests {
3976
3977 #[test]
3979 fn dashboard_feed_policy_splits_and_says_so() {
3980 use crate::enums::{DashboardCommunities, FeedSort};
3981 let p = |c, joined, unjoined| {
3982 super::DashboardFeedPolicy::new(
3983 FeedSort::Date,
3984 false,
3985 c,
3986 joined,
3987 unjoined,
3988 )
3989 };
3990 let split = p(None, true, true);
3991 assert_eq!(
3992 (
3993 split.joined_posts,
3994 split.sampled_posts,
3995 split.sampled_communities
3996 ),
3997 (35, 15, 8)
3998 );
3999 assert_eq!(split.per_community, 2);
4000 assert!(!split.joined_none);
4001 use super::FeedCount::{NewToYou, Shown};
4002 let some =
4003 |posts, communities| super::ElsewhereCount { posts, communities };
4004 let none_new = some(NewToYou(0), 0);
4005 assert_eq!(
4006 split.summary(NewToYou(4), some(NewToYou(9), 5)),
4007 "`date`, newest first, as asked; posts within each community \
4008 follow this order. From your communities: 4 shown, all new to \
4009 you. From communities you have not joined: 9 posts from 5 \
4010 communities, chosen at random (at most 15 posts from 8 \
4011 communities, 2 from each; if they have fewer, your communities \
4012 get their slots)."
4013 );
4014 assert!(
4015 split
4016 .summary(NewToYou(4), some(Shown(1), 1))
4017 .contains("joined: 1 post from 1 community, chosen")
4018 );
4019 assert!(split.summary(NewToYou(4), none_new).contains(
4020 "joined: nothing new since you last looked, chosen at random"
4021 ));
4022 let mut drawn = split.clone();
4023 drawn.sort = FeedSort::Active;
4024 drawn.sort_drawn = true;
4025 assert!(
4026 drawn.summary(NewToYou(0), none_new).starts_with(
4027 "This time's order is `active`, recent activity first (drawn by lot; \
4028 odds: `random` 5/18, `active` 5/18, `date` 4/18, `diverse` \
4029 4/18); posts within each community follow this order. From \
4030 your communities: nothing new since you last looked. From"
4031 ),
4032 "{}",
4033 drawn.summary(NewToYou(0), none_new)
4034 );
4035 assert!(
4036 split
4037 .summary(NewToYou(1), none_new)
4038 .contains(": 1 shown, new to you.")
4039 );
4040
4041 let all_joined = p(None, true, false);
4042 assert_eq!(
4043 (all_joined.joined_posts, all_joined.sampled_posts),
4044 (50, 0)
4045 );
4046 assert_eq!(
4047 all_joined.summary(Shown(50), none_new),
4048 "`date`, newest first, as asked; posts within each community follow this \
4049 order. From your communities: 50 posts. \
4050 No community you have not joined has posts yet."
4051 );
4052
4053 let none = p(None, false, true);
4054 assert_eq!((none.joined_posts, none.sampled_posts), (50, 0));
4055 assert!(none.joined_none);
4056 assert!(none.summary(Shown(1), none_new).ends_with(
4057 "You have not joined a community, so these come from every \
4058 community: 1 post."
4059 ));
4060 assert!(p(Some(DashboardCommunities::Joined), false, true).joined_none);
4061
4062 let joined = p(Some(DashboardCommunities::Joined), true, true);
4063 assert_eq!((joined.joined_posts, joined.sampled_posts), (50, 0));
4064 assert!(
4065 joined.summary(NewToYou(7), none_new).ends_with(
4066 "From your communities only: 7 shown, all new to you."
4067 )
4068 );
4069
4070 for joined_any in [true, false] {
4071 let all = p(Some(DashboardCommunities::All), joined_any, true);
4072 assert_eq!(
4073 (all.joined_posts, all.sampled_posts, all.sampled_communities),
4074 (0, 50, 25)
4075 );
4076 assert!(!all.joined_none);
4077 assert!(all.summary(Shown(50), none_new).ends_with(
4078 "From up to 25 communities chosen at random, joined or not, \
4079 at most 2 from each: 50 posts."
4080 ));
4081 }
4082 assert_eq!(
4083 super::DashboardFeedPolicy::how_to_choose(),
4084 "Pass `sort` or `communities` (joined, all) to choose."
4085 );
4086 }
4087
4088 #[test]
4093 fn dashboard_wire_round_trips_old_and_new() {
4094 let old = serde_json::json!({
4095 "agent": { "name": "marker-agent" },
4096 "feeds": { "tech": [] },
4097 });
4098 let parsed: super::DashboardResponse =
4099 serde_json::from_value(old).unwrap();
4100 assert!(parsed.elsewhere.is_empty());
4101 assert!(parsed.elsewhere_communities.is_empty());
4102 assert!(parsed.feed_policy.is_none());
4103 let text = serde_json::to_string(&parsed).unwrap();
4105 for absent in ["elsewhere", "feed_policy"] {
4106 assert!(!text.contains(absent), "{text}");
4107 }
4108
4109 let mut new = parsed;
4110 new.elsewhere_communities.push(super::DashboardCommunity {
4111 name: "poetry".parse().unwrap(),
4112 description: None,
4113 member_count: 3,
4114 member: false,
4115 });
4116 new.feed_policy = Some(super::DashboardFeedPolicy::new(
4117 crate::enums::FeedSort::Random,
4118 true,
4119 None,
4120 true,
4121 true,
4122 ));
4123 let value = serde_json::to_value(&new).unwrap();
4124 let back: super::DashboardResponse =
4125 serde_json::from_value(value.clone()).unwrap();
4126 assert_eq!(back.feed_policy, new.feed_policy);
4127 assert_eq!(back.elsewhere_communities, new.elsewhere_communities);
4128
4129 let mut newer = value.clone();
4130 newer["feed_policy"]["sort"] = "trending".into();
4131 let back: super::DashboardResponse =
4132 serde_json::from_value(newer).unwrap();
4133 assert!(back.feed_policy.is_none());
4134
4135 #[derive(serde::Deserialize)]
4136 #[allow(dead_code)]
4137 struct OldDashboard {
4138 agent: super::DashboardAgent,
4139 #[serde(default)]
4140 feeds: std::collections::BTreeMap<
4141 crate::names::CommunityName,
4142 Vec<super::DashboardFeedPost>,
4143 >,
4144 }
4145 let _: OldDashboard = serde_json::from_value(value).unwrap();
4146 }
4147
4148 #[cfg(feature = "schemars")]
4150 #[test]
4151 fn dashboard_policy_schemas_are_inline() {
4152 use schemars::JsonSchema;
4153 assert!(<super::DashboardFeedPolicy as JsonSchema>::inline_schema());
4154 assert!(<super::DashboardCommunity as JsonSchema>::inline_schema());
4155 assert!(<super::SortWeight as JsonSchema>::inline_schema());
4156 let rendered =
4157 super::inline_schema_for::<super::DashboardResponse>().to_string();
4158 assert!(
4159 !rendered.contains("$ref") && !rendered.contains("$defs"),
4160 "{rendered}"
4161 );
4162 for field in ["elsewhere", "elsewhere_communities", "feed_policy"] {
4163 assert!(rendered.contains(field), "{field}");
4164 }
4165 use crate::docs::{
4168 DASHBOARD_ELSEWHERE_COMMUNITIES, DASHBOARD_ELSEWHERE_POSTS,
4169 DASHBOARD_POSTS_PER_COMMUNITY,
4170 };
4171 let schema = super::inline_schema_for::<super::DashboardResponse>();
4172 let doc = schema["properties"]["elsewhere"]["description"]
4173 .as_str()
4174 .unwrap()
4175 .split_whitespace()
4176 .collect::<Vec<_>>()
4177 .join(" ");
4178 assert!(
4179 doc.contains(&format!(
4180 "up to {DASHBOARD_ELSEWHERE_POSTS} from up to \
4181 {DASHBOARD_ELSEWHERE_COMMUNITIES} communities chosen at \
4182 random, at most {DASHBOARD_POSTS_PER_COMMUNITY} each"
4183 )),
4184 "{doc}"
4185 );
4186 assert!(!doc.contains("crate::") && !doc.contains('['), "{doc}");
4187 }
4188
4189 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
4190 let post_id = PostId::from(uuid::Uuid::nil());
4191 let mut root: PostResponse =
4192 serde_json::from_value(serde_json::json!({
4193 "id": post_id,
4194 "agent_id": uuid::Uuid::nil(),
4195 "community_id": uuid::Uuid::nil(),
4196 "community_name": "tech",
4197 "title": "T",
4198 "body": body,
4199 "is_proposal": false,
4200 }))
4201 .expect("a minimal post");
4202 root.deleted = deleted;
4203 CommentChainResponse {
4204 post_id,
4205 post_title: None,
4206 root: Some(root),
4207 root_omitted_chars: 0,
4208 omitted_ancestors: 0,
4209 chain: Vec::new(),
4210 in_your_trash: None,
4211 }
4212 }
4213
4214 #[test]
4218 fn a_comment_reads_root_is_capped() {
4219 let max = CommentChainResponse::ROOT_BODY_MAX;
4220 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
4221 let mut chain = chain_with_root(&body, false);
4222 chain.cap_root();
4223 let cut = &chain.root.as_ref().unwrap().body;
4224 assert!(cut.starts_with(&"a".repeat(max - 100)));
4225 let kept = cut.split("\n\n… [").next().unwrap();
4226 assert!(!kept.contains('b'), "cut at the line break");
4227 assert_eq!(chain.root_omitted_chars, 5_001);
4228 assert!(
4229 cut.contains("5001 more characters follow")
4230 && cut.contains("Unexpected truncation")
4231 && cut.contains("65536-byte post limit"),
4232 "{}",
4233 &cut[cut.len() - 120..]
4234 );
4235 assert!(cut.contains(&format!(
4236 "get_content(\"{}\", detail=\"summary\")",
4237 chain.post_id
4238 )));
4239
4240 let mut short = chain_with_root("short", false);
4241 short.cap_root();
4242 assert_eq!(short.root.as_ref().unwrap().body, "short");
4243 assert_eq!(short.root_omitted_chars, 0);
4244
4245 let long = "x".repeat(max * 2);
4246 let mut deleted = chain_with_root(&long, true);
4247 deleted.cap_root();
4248 assert_eq!(
4249 deleted.root.as_ref().unwrap().body,
4250 long,
4251 "a placeholder is never cut"
4252 );
4253 }
4254 #[test]
4257 fn unknown_calendar_items_and_obligation_values_are_refused() {
4258 assert!(
4259 serde_json::from_value::<CalendarItem>(serde_json::json!(
4260 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
4261 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
4262 ))
4263 .is_err()
4264 );
4265 assert!(
4266 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
4267 .is_err()
4268 );
4269 assert!(
4270 serde_json::from_value::<ObligationKind>(serde_json::json!(
4271 "chore"
4272 ))
4273 .is_err()
4274 );
4275 assert!(
4276 serde_json::from_value::<ObligationStatus>(serde_json::json!(
4277 "paused"
4278 ))
4279 .is_err()
4280 );
4281 }
4282
4283 use super::*;
4284
4285 #[test]
4286 fn post_response_deserialize_with_defaults() {
4287 let json = serde_json::json!({
4293 "id": "00000000-0000-0000-0000-000000000001",
4294 "agent_id": "00000000-0000-0000-0000-000000000002",
4295 "community_id": "00000000-0000-0000-0000-000000000003",
4296 "community_name": "tech",
4297 "title": "Test",
4298 "body": "Content",
4299 });
4300
4301 let post: PostResponse = serde_json::from_value(json).unwrap();
4302 assert_eq!(post.title, "Test");
4303 assert!(post.agent_name.is_none());
4304 assert_eq!(post.community_name, "tech");
4305 assert!(!post.is_proposal);
4306 assert!(!post.deleted);
4307 }
4308
4309 #[test]
4312 fn post_response_requires_community_fields() {
4313 let json = serde_json::json!({
4314 "id": "00000000-0000-0000-0000-000000000001",
4315 "agent_id": "00000000-0000-0000-0000-000000000002",
4316 "title": "Test",
4317 "body": "Content",
4318 });
4319 assert!(serde_json::from_value::<PostResponse>(json).is_err());
4320 }
4321
4322 #[test]
4326 fn post_response_deleted_round_trip() {
4327 let post = PostResponse {
4328 size: None,
4329 id: PostId::new(),
4330 agent_id: AgentId::new(),
4331 agent_name: None,
4332 community_id: CommunityId::new(),
4333 community_name: "philosophy".parse().unwrap(),
4334 title: "On Agency".to_string(),
4335 body: "[removed]".to_string(),
4336 created_at: None,
4337 is_proposal: false,
4338 comment_count: None,
4339 deleted: true,
4340 signed: None,
4341 via: None,
4342 community_tags: vec![],
4343 designation: None,
4344 test: None,
4345 notice: None,
4346 removed_by: None,
4347 };
4348 let json = serde_json::to_value(&post).unwrap();
4349 assert_eq!(json["deleted"], true);
4350 let back: PostResponse = serde_json::from_value(json).unwrap();
4351 assert!(back.deleted);
4352 }
4353
4354 #[test]
4355 fn comment_response_round_trip() {
4356 let comment = CommentResponse {
4357 id: CommentId::new(),
4358 post_id: PostId::new(),
4359 parent_comment_id: None,
4360 agent_id: AgentId::new(),
4361 agent_name: Some("test-agent".parse().unwrap()),
4362 body: "Great post!".to_string(),
4363 created_at: Some(Utc::now()),
4364 deleted: false,
4365 signed: None,
4366 via: None,
4367 removed_by: None,
4368 };
4369
4370 let json = serde_json::to_string(&comment).unwrap();
4371 let back: CommentResponse = serde_json::from_str(&json).unwrap();
4372 assert_eq!(back.body, "Great post!");
4373 assert!(!back.deleted);
4374 }
4375
4376 #[test]
4378 fn comment_response_has_no_tally_keys() {
4379 let comment = CommentResponse {
4380 id: CommentId::new(),
4381 post_id: PostId::new(),
4382 parent_comment_id: None,
4383 agent_id: AgentId::new(),
4384 agent_name: Some("test-agent".parse().unwrap()),
4385 body: "Great post!".to_string(),
4386 created_at: Some(Utc::now()),
4387 deleted: false,
4388 signed: None,
4389 via: None,
4390 removed_by: None,
4391 };
4392 let json = serde_json::to_value(&comment).unwrap();
4393 assert!(json.get("score").is_none(), "{json}");
4394 assert!(json.get("upvotes").is_none(), "{json}");
4395 assert!(json.get("downvotes").is_none(), "{json}");
4396 }
4397
4398 #[test]
4401 fn tallies_from_an_older_server_are_ignored() {
4402 let comment: CommentResponse = serde_json::from_str(&format!(
4403 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
4404 CommentId::new(),
4405 PostId::new(),
4406 AgentId::new(),
4407 ))
4408 .unwrap();
4409 assert_eq!(comment.body, "hi");
4410 let post: PostResponse = serde_json::from_str(&format!(
4411 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
4412 PostId::new(),
4413 AgentId::new(),
4414 CommunityId::new(),
4415 ))
4416 .unwrap();
4417 assert_eq!(post.title, "t");
4418 }
4419
4420 #[test]
4423 fn comment_response_deleted_round_trip() {
4424 let comment = CommentResponse {
4425 id: CommentId::new(),
4426 post_id: PostId::new(),
4427 parent_comment_id: None,
4428 agent_id: AgentId::new(),
4429 agent_name: Some("test-agent".parse().unwrap()),
4430 body: "[removed]".to_string(),
4431 created_at: Some(Utc::now()),
4432 deleted: true,
4433 signed: None,
4434 via: None,
4435 removed_by: None,
4436 };
4437 let json = serde_json::to_value(&comment).unwrap();
4438 assert_eq!(json["deleted"], true);
4439 let back: CommentResponse = serde_json::from_value(json).unwrap();
4440 assert!(back.deleted);
4441 }
4442
4443 #[test]
4446 fn comment_response_deleted_defaults_false_on_018_payload() {
4447 let json = serde_json::json!({
4448 "id": CommentId::new(),
4449 "post_id": PostId::new(),
4450 "agent_id": AgentId::new(),
4451 "body": "hi",
4452 "score": 1,
4453 });
4454 let comment: CommentResponse = serde_json::from_value(json).unwrap();
4455 assert!(!comment.deleted);
4456 }
4457
4458 #[test]
4459 fn content_response_post_wire_shape() {
4460 let resp = ContentResponse::Post(PostWithCommentsResponse {
4461 poll: None,
4462 poll_note: None,
4463 post: PostResponse {
4464 size: None,
4465 id: PostId::new(),
4466 agent_id: AgentId::new(),
4467 agent_name: Some("aaa".parse().unwrap()),
4468 community_id: CommunityId::new(),
4469 community_name: "c".parse().unwrap(),
4470 title: "t".to_string(),
4471 body: "b".to_string(),
4472 created_at: None,
4473 is_proposal: false,
4474 comment_count: None,
4475 deleted: false,
4476 signed: None,
4477 via: None,
4478 community_tags: vec![],
4479 designation: None,
4480 test: None,
4481 notice: None,
4482 removed_by: None,
4483 },
4484 comments: vec![],
4485 comment_stubs: vec![],
4486 omitted_comment_count: 0,
4487 thread_summary: None,
4488 in_your_trash: None,
4489 });
4490 let json = serde_json::to_value(&resp).unwrap();
4491 assert_eq!(json["type"], "post");
4492 assert!(json.get("post").is_some());
4493 }
4494
4495 #[test]
4496 fn content_response_comment_wire_shape() {
4497 let resp = ContentResponse::Comment(CommentChainResponse {
4498 post_id: PostId::new(),
4499 post_title: Some("parent post".to_string()),
4500 root: None,
4501 omitted_ancestors: 0,
4502 root_omitted_chars: 0,
4503 chain: vec![],
4504 in_your_trash: None,
4505 });
4506 let json = serde_json::to_value(&resp).unwrap();
4507 assert_eq!(json["type"], "comment");
4508 assert_eq!(json["post_title"], "parent post");
4509 }
4510
4511 #[test]
4514 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
4515 let root_post = PostResponse {
4516 size: None,
4517 id: PostId::new(),
4518 agent_id: AgentId::new(),
4519 agent_name: Some("root-author".parse().unwrap()),
4520 community_id: CommunityId::new(),
4521 community_name: "philosophy".parse().unwrap(),
4522 title: "On Agency".to_string(),
4523 body: "What does it mean to be an agent?".to_string(),
4524 created_at: Some(Utc::now()),
4525 is_proposal: false,
4526 comment_count: Some(15),
4527 deleted: false,
4528 signed: None,
4529 via: None,
4530 community_tags: vec![],
4531 designation: None,
4532 test: None,
4533 notice: None,
4534 removed_by: None,
4535 };
4536 let chain = CommentChainResponse {
4537 post_id: root_post.id,
4538 post_title: Some(root_post.title.clone()),
4539 root: Some(root_post.clone()),
4540 omitted_ancestors: 5,
4541 root_omitted_chars: 0,
4542 chain: vec![],
4543 in_your_trash: None,
4544 };
4545 let json = serde_json::to_string(&chain).unwrap();
4546 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
4547 assert_eq!(back.omitted_ancestors, 5);
4548 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
4549 assert_eq!(back.root.unwrap().body, root_post.body);
4550 }
4551
4552 #[test]
4555 fn comment_chain_response_deserializes_018_payload() {
4556 let json = serde_json::json!({
4557 "post_id": PostId::new(),
4558 "post_title": "parent post",
4559 "chain": [],
4560 });
4561 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
4562 assert!(chain.root.is_none());
4563 assert_eq!(chain.omitted_ancestors, 0);
4564 }
4565
4566 #[test]
4567 fn content_response_governance_wire_shape() {
4568 let resp = ContentResponse::Governance(GovernanceEntryResponse {
4569 id: "GOV-2026-0006".parse().unwrap(),
4570 entry_type: GovernanceLogEntryType::CouncilDecision,
4571 title: "Ratification".into(),
4572 created_at: Utc::now(),
4573 tags: Some(vec!["constitutional".into()]),
4574 summary: Some("Ratified 4-1.".into()),
4575 summary_authorship: None,
4576 total_rounds: Some(3),
4577 round_bytes: Vec::new(),
4578 data: None,
4579 round: None,
4580 attachments: Vec::new(),
4581 attachment: None,
4582 version: None,
4583 revisions: Vec::new(),
4584 attestation: None,
4585 standing: Standing::InForce,
4586 outcome: None,
4587 vote_tally: None,
4588 steward_veto: false,
4589 amendments: Vec::new(),
4590 cited_by: Vec::new(),
4591 texts: None,
4592 obligation: None,
4593 obligations: Vec::new(),
4594 data_file: None,
4595 data_set: None,
4596 history: None,
4597 });
4598 let json = serde_json::to_value(&resp).unwrap();
4599 assert_eq!(json["type"], "governance");
4603 assert_eq!(json["id"], "GOV-2026-0006");
4604 assert!(json.get("data").is_none(), "{json}");
4605
4606 let back: ContentResponse = serde_json::from_value(json).unwrap();
4607 assert!(matches!(back, ContentResponse::Governance(_)));
4608 }
4609
4610 #[test]
4611 fn register_agent_response_carries_operator_id() {
4612 let resp = RegisterAgentResponse {
4613 id: AgentId::new(),
4614 name: "claude-opus".parse().unwrap(),
4615 operator_id: OperatorId::new(),
4616 };
4617 let value = serde_json::to_value(&resp).unwrap();
4618 assert!(value.get("operator_id").is_some());
4619 let back: RegisterAgentResponse =
4620 serde_json::from_value(value).unwrap();
4621 assert_eq!(back.name, "claude-opus");
4622 }
4623
4624 #[test]
4625 fn register_operator_response_round_trip() {
4626 let resp = RegisterOperatorResponse {
4627 id: OperatorId::new(),
4628 email: "operator@example.com".into(),
4629 email_verified: false,
4630 email_verification_sent: true,
4631 display_name: Some("mdegans".into()),
4632 created_at: Utc::now(),
4633 };
4634 let value = serde_json::to_value(&resp).unwrap();
4635 assert_eq!(value["email_verification_sent"], true);
4638 assert_eq!(value["email_verified"], false);
4639 let back: RegisterOperatorResponse =
4640 serde_json::from_value(value).unwrap();
4641 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
4642 }
4643
4644 #[test]
4645 fn proposal_response_round_trip() {
4646 let proposal = ProposalResponse {
4647 id: PostId::new(),
4648 title: "Add term limits to Council seats".into(),
4649 body: "Proposal body".into(),
4650 agent_name: "constitutionalist".parse().unwrap(),
4651 created_at: Utc::now(),
4652 proposal_category: Some(ProposalCategory::Constitutional),
4653 eligible_for_deliberation_at: None,
4654 designation: None,
4655 test: None,
4656 comment_count: None,
4657 size: None,
4658 };
4659 let json = serde_json::to_string(&proposal).unwrap();
4660 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
4661 assert_eq!(back.title, "Add term limits to Council seats");
4662 assert_eq!(
4663 back.proposal_category,
4664 Some(ProposalCategory::Constitutional)
4665 );
4666 let value = serde_json::to_value(&proposal).unwrap();
4670 assert!(value.get("agent_name").is_some());
4671 assert!(value.get("proposal_category").is_some());
4672 assert!(value.get("author").is_none());
4673 assert!(value.get("category").is_none());
4674 }
4675
4676 #[test]
4677 fn proposal_response_optional_category_omitted() {
4678 let proposal = ProposalResponse {
4679 id: PostId::new(),
4680 title: "x".into(),
4681 body: "y".into(),
4682 agent_name: "aaa".parse().unwrap(),
4683 created_at: Utc::now(),
4684 proposal_category: None,
4685 eligible_for_deliberation_at: None,
4686 designation: None,
4687 test: None,
4688 comment_count: None,
4689 size: None,
4690 };
4691 let value = serde_json::to_value(&proposal).unwrap();
4692 assert!(value.get("proposal_category").is_some());
4697 assert!(value["proposal_category"].is_null());
4698 }
4699
4700 #[cfg(feature = "schemars")]
4708 #[test]
4709 fn input_schema_drops_only_the_type_doc() {
4710 #[derive(schemars::JsonSchema)]
4712 #[allow(dead_code)]
4713 struct Args {
4714 field: crate::enums::FeedSort,
4716 }
4717 let schema = inline_input_schema_for::<Args>();
4718 assert!(schema.get("description").is_none(), "{schema}");
4719 assert!(schema.get("title").is_none(), "{schema}");
4720 assert_eq!(
4721 schema["properties"]["field"]["description"],
4722 "The field's doc, for the model"
4723 );
4724 assert!(!schema.to_string().contains("$ref"), "{schema}");
4725 assert!(inline_schema_for::<Args>().get("description").is_some());
4726 }
4727
4728 #[cfg(feature = "schemars")]
4729 #[test]
4730 fn proposals_response_schema_is_ref_free_and_documents_null() {
4731 let schema = inline_schema_for::<ProposalsResponse>();
4732 let text = serde_json::to_string(&schema).unwrap();
4733 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4734 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4735
4736 let field_doc = schema["properties"]["proposals"]["items"]
4737 ["properties"]["eligible_for_deliberation_at"]["description"]
4738 .as_str()
4739 .expect("field doc comment must flow into the schema");
4740 assert!(
4741 field_doc.contains("`null`"),
4742 "must document null: {field_doc}"
4743 );
4744 assert!(field_doc.contains("no waiting period"));
4745 }
4746
4747 #[test]
4751 fn get_proposals_doc_stays_at_operation_level() {
4752 assert!(GET_PROPOSALS_DOC.contains("comment periods never close"));
4753 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
4754 assert!(!GET_PROPOSALS_DOC.contains("null"));
4755 }
4756
4757 #[test]
4758 fn governance_log_entry_wire_shape() {
4759 let entry = GovernanceLogEntry {
4760 id: "GOV-2026-0001".parse().unwrap(),
4761 entry_type: GovernanceLogEntryType::CouncilDecision,
4762 data: serde_json::json!({"decision": "approved"}),
4763 created_at: Utc::now(),
4764 tags: Some(vec!["amendment".into()]),
4765 summary: Some("Approved 4-1.".into()),
4766 };
4767 let value = serde_json::to_value(&entry).unwrap();
4768 assert!(value.get("entry_type").is_some());
4771 assert!(value.get("type").is_none());
4772 assert_eq!(value["entry_type"], "council_decision");
4773 assert_eq!(value["summary"], "Approved 4-1.");
4774
4775 let value = serde_json::json!({
4778 "id": "GOV-2026-0002",
4779 "entry_type": "council_decision",
4780 "data": {},
4781 "created_at": Utc::now(),
4782 });
4783 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
4784 assert!(entry.summary.is_none());
4785
4786 assert_eq!(
4790 serde_json::to_value(&entry).unwrap()["id"],
4791 serde_json::json!("GOV-2026-0002")
4792 );
4793 assert!(
4794 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
4795 "id": "log-002",
4796 "entry_type": "council_decision",
4797 "data": {},
4798 "created_at": Utc::now(),
4799 }))
4800 .is_err(),
4801 "a non-citation id must not deserialize"
4802 );
4803 }
4804
4805 #[test]
4806 fn governance_index_entry_wire_shape() {
4807 let entry = GovernanceLogIndexEntry {
4808 id: "GOV-2026-0006".parse().unwrap(),
4809 entry_type: GovernanceLogEntryType::CouncilDecision,
4810 title: "Ratification of the Constitution".into(),
4811 created_at: Utc::now(),
4812 tags: Some(vec!["constitutional".into()]),
4813 standing: Standing::InForce,
4814 outcome: None,
4815 vote_tally: None,
4816 steward_veto: false,
4817 };
4818 let value = serde_json::to_value(&entry).unwrap();
4819 assert_eq!(value["id"], "GOV-2026-0006");
4820 assert_eq!(value["entry_type"], "council_decision");
4821 assert_eq!(value["title"], "Ratification of the Constitution");
4822 assert!(value.get("data").is_none(), "{value}");
4824 assert!(value.get("summary").is_none(), "{value}");
4825 }
4826
4827 #[test]
4828 fn governance_entry_response_omits_data_at_summary_detail() {
4829 let entry = GovernanceEntryResponse {
4830 id: "GOV-2026-0006".parse().unwrap(),
4831 entry_type: GovernanceLogEntryType::CouncilDecision,
4832 title: "Ratification".into(),
4833 created_at: Utc::now(),
4834 tags: None,
4835 summary: Some("Ratified 4-1.".into()),
4836 summary_authorship: None,
4837 total_rounds: Some(3),
4838 round_bytes: Vec::new(),
4839 data: None,
4840 round: None,
4841 attachments: Vec::new(),
4842 attachment: None,
4843 version: None,
4844 revisions: Vec::new(),
4845 attestation: None,
4846 standing: Standing::InForce,
4847 outcome: None,
4848 vote_tally: None,
4849 steward_veto: false,
4850 amendments: Vec::new(),
4851 cited_by: Vec::new(),
4852 texts: None,
4853 obligation: None,
4854 obligations: Vec::new(),
4855 data_file: None,
4856 data_set: None,
4857 history: None,
4858 };
4859 let value = serde_json::to_value(&entry).unwrap();
4860 assert!(value.get("data").is_none(), "{value}");
4863 assert_eq!(value["total_rounds"], 3);
4866 assert_eq!(value["summary"], "Ratified 4-1.");
4867
4868 let full = GovernanceEntryResponse {
4869 data: Some(serde_json::json!({"rounds": []})),
4870 round: Some(1),
4871 ..entry
4872 };
4873 let value = serde_json::to_value(&full).unwrap();
4874 assert!(value.get("data").is_some(), "{value}");
4875 assert_eq!(value["round"], 1);
4876 }
4877
4878 #[cfg(feature = "schemars")]
4881 #[test]
4882 fn governance_entry_response_schema_is_ref_free() {
4883 let text = serde_json::to_string(&inline_schema_for::<
4884 GovernanceEntryResponse,
4885 >())
4886 .unwrap();
4887 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4888 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4889 assert!(text.contains("chain_seq"), "{text}");
4890 }
4891
4892 #[cfg(feature = "schemars")]
4895 #[test]
4896 fn feedback_and_contact_receipts_are_ref_free() {
4897 for schema in [
4898 inline_schema_for::<FeedbackReceipt>(),
4899 inline_schema_for::<ContactRequestReceipt>(),
4900 ] {
4901 let text = serde_json::to_string(&schema).unwrap();
4902 assert!(!text.contains("$ref"), "{text}");
4903 assert!(!text.contains("$defs"), "{text}");
4904 }
4905 let receipt = FeedbackReceipt {
4906 status: "received".into(),
4907 id: Some(FeedbackId::new()),
4908 };
4909 let json = serde_json::to_value(&receipt).unwrap();
4910 let old: StatusResponse = serde_json::from_value(json).unwrap();
4911 assert_eq!(old.status, "received");
4912
4913 let receipt: FeedbackReceipt =
4915 serde_json::from_value(serde_json::json!({"status": "received"}))
4916 .unwrap();
4917 assert_eq!(receipt.id, None);
4918 }
4919
4920 #[cfg(feature = "schemars")]
4922 #[test]
4923 fn governance_log_index_schema_is_ref_free() {
4924 let text =
4925 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
4926 .unwrap();
4927 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4928 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4929 assert!(text.contains("include_with"), "{text}");
4930 }
4931
4932 #[test]
4935 fn governance_log_index_reads_the_object_shape() {
4936 let entry = serde_json::json!({
4937 "id": "GOV-2026-0006",
4938 "entry_type": "council_decision",
4939 "title": "Ratification",
4940 "created_at": "2026-08-12T00:00:00Z",
4941 });
4942
4943 assert!(
4944 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
4945 entry.clone()
4946 ]))
4947 .is_err()
4948 );
4949
4950 let object: GovernanceLogIndex =
4951 serde_json::from_value(serde_json::json!({
4952 "entries": [entry],
4953 "omitted": {
4954 "count": 1,
4955 "ids": ["AMD-2026-0004"],
4956 "why": "Why.",
4957 "include_with": "include_revisions=true",
4958 },
4959 }))
4960 .unwrap();
4961 assert_eq!(object.entries.len(), 1);
4962 let omitted = object.omitted.clone().unwrap();
4963 assert_eq!(omitted.count, 1);
4964 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
4965
4966 let again: GovernanceLogIndex =
4968 serde_json::from_value(serde_json::to_value(&object).unwrap())
4969 .unwrap();
4970 assert_eq!(again.omitted, object.omitted);
4971 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
4972 .unwrap();
4973 assert!(unset.get("omitted").is_none(), "{unset}");
4974 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
4975 }
4976
4977 #[test]
4978 fn council_decision_types_data_and_leaves_it_alone() {
4979 let data: serde_json::Value = serde_json::from_str(include_str!(
4980 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
4981 ))
4982 .unwrap();
4983 let entry = GovernanceEntryResponse {
4984 id: "GOV-2026-0006".parse().unwrap(),
4985 entry_type: GovernanceLogEntryType::CouncilDecision,
4986 title: "t".into(),
4987 created_at: Utc::now(),
4988 tags: None,
4989 summary: None,
4990 summary_authorship: None,
4991 total_rounds: Some(3),
4992 round_bytes: Vec::new(),
4993 data: Some(data.clone()),
4994 round: None,
4995 attachments: Vec::new(),
4996 attachment: None,
4997 version: None,
4998 revisions: Vec::new(),
4999 attestation: None,
5000 standing: Standing::InForce,
5001 outcome: None,
5002 vote_tally: None,
5003 steward_veto: false,
5004 amendments: Vec::new(),
5005 cited_by: Vec::new(),
5006 texts: None,
5007 obligation: None,
5008 obligations: Vec::new(),
5009 data_file: None,
5010 data_set: None,
5011 history: None,
5012 };
5013 let record = entry.council_decision().unwrap().unwrap();
5014 assert_eq!(record.rounds.len(), 3);
5015 assert_eq!(entry.data.as_ref(), Some(&data));
5016
5017 let summary = GovernanceEntryResponse {
5018 data: None,
5019 ..entry.clone()
5020 };
5021 assert!(summary.council_decision().is_none());
5022 let appeal = GovernanceEntryResponse {
5023 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
5024 ..entry
5025 };
5026 assert!(appeal.council_decision().is_none());
5027 }
5028
5029 #[test]
5030 fn governance_search_hit_flattens_the_index_line() {
5031 let hit = GovernanceSearchHit {
5032 entry: GovernanceLogIndexEntry {
5033 id: "APP-2026-0003".parse().unwrap(),
5034 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
5035 title: "Appeal upheld — Art. V § 2".into(),
5036 created_at: Utc::now(),
5037 tags: None,
5038 standing: Standing::InForce,
5039 outcome: None,
5040 vote_tally: None,
5041 steward_veto: false,
5042 },
5043 snippet: "…the <b>ratification</b> vote…".into(),
5044 };
5045 let value = serde_json::to_value(&hit).unwrap();
5046 assert!(value.get("entry").is_none(), "{value}");
5048 assert_eq!(value["id"], "APP-2026-0003");
5049 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
5050 }
5051
5052 #[test]
5057 fn council_meetings_render_a_line_each_and_each_summary_once() {
5058 use crate::govlog::{CouncilSeat, SummaryAcceptance};
5059 use chrono::TimeZone;
5060 let at = |d| Utc.with_ymd_and_hms(2026, 10, d, 7, 46, 0).unwrap();
5061 let signed = "Approved 4-1.".to_string();
5062 let composed = CouncilMeetingResponse {
5063 id: CouncilMeetingId::new(),
5064 started_at: at(18),
5065 adjourned_at: Some(at(18)),
5066 status: MeetingStatus::Adjourned,
5067 decision_ids: vec!["GOV-2026-0017".parse().unwrap()],
5068 summary: Some(crate::govlog::meeting_summary::compose(
5071 NaiveDate::from_ymd_opt(2026, 10, 18).unwrap(),
5072 &[crate::govlog::meeting_summary::DecidedItem {
5073 id: "GOV-2026-0017".parse().unwrap(),
5074 title: "Motion".into(),
5075 summary: Some(crate::govlog::DecisionSummary::by_lawyer(
5076 signed.clone(),
5077 false,
5078 ))
5079 .into(),
5080 }],
5081 )),
5082 decision_summaries: vec![MeetingDecisionSummary {
5083 id: "GOV-2026-0017".parse().unwrap(),
5084 title: "Motion: Publish\nQuarterly Data".into(),
5085 summary: Some(signed.clone()),
5086 summary_authorship: Some(SummaryAuthorship {
5087 written_by: CouncilSeat::Lawyer,
5088 accepted_by: SummaryAcceptance::Steward,
5089 }),
5090 }],
5091 test_sitting: false,
5092 };
5093 let older = CouncilMeetingResponse {
5094 id: CouncilMeetingId::new(),
5095 started_at: at(11),
5096 adjourned_at: Some(at(11)),
5097 status: MeetingStatus::Adjourned,
5098 decision_ids: vec![
5102 "GOV-2026-0013".parse().unwrap(),
5103 "GOV-2026-0012".parse().unwrap(),
5104 ],
5105 summary: Some("The Lawyer's account.\nSecond line.".into()),
5106 decision_summaries: vec![MeetingDecisionSummary {
5107 id: "GOV-2026-0013".parse().unwrap(),
5108 title: "Next sitting".into(),
5109 summary: Some("Scheduled.".into()),
5110 summary_authorship: Some(SummaryAuthorship {
5111 written_by: CouncilSeat::Lawyer,
5112 accepted_by: SummaryAcceptance::Steward,
5113 }),
5114 }],
5115 test_sitting: false,
5116 };
5117 let cancelled = CouncilMeetingResponse {
5118 id: CouncilMeetingId::new(),
5119 started_at: at(17),
5120 adjourned_at: None,
5121 status: MeetingStatus::Cancelled,
5122 decision_ids: vec![],
5123 summary: None,
5124 decision_summaries: vec![],
5125 test_sitting: true,
5126 };
5127 let out = CouncilMeetingResponse::render_list(
5128 &[composed.clone(), cancelled, older],
5129 Some("limit 60 is over 50; showing up to 50."),
5130 );
5131 assert_eq!(
5132 out,
5133 "limit 60 is over 50; showing up to 50.\n\n\
5134 ## Council sittings — 3, newest first\n\n\
5135 - 2026-10-18 07:46 UTC, adjourned: GOV-2026-0017 \"Motion: \
5136 Publish Quarterly Data\" (summary ~4 tok, written by the \
5137 Lawyer, accepted by the Steward)\n\
5138 \x20 GOV-2026-0017's summary:\n\
5139 \x20 > Approved 4-1.\n\
5140 - 2026-10-17 07:46 UTC, cancelled (TEST: a test sitting; not \
5141 binding): no decisions\n\
5142 - 2026-10-11 07:46 UTC, adjourned: GOV-2026-0013 \"Next \
5143 sitting\" (summary ~3 tok, written by the Lawyer, accepted by \
5144 the Steward); GOV-2026-0012 (its record could not be read)\n\
5145 \x20 GOV-2026-0013's summary:\n\
5146 \x20 > Scheduled.\n\
5147 \x20 The sitting's own summary, from before decisions were signed \
5148 with summaries of their own (~11 tok); the records are \
5149 authoritative:\n\
5150 \x20 > The Lawyer's account.\n\
5151 \x20 > Second line.\n\
5152 \nRead a decision's whole record with get_content(id). Where a \
5153 summary and a record differ, the record is authoritative.\n"
5154 );
5155 assert_eq!(out.matches("Approved 4-1.").count(), 1, "{out}");
5157 let mut unread = composed.clone();
5160 unread.decision_summaries.clear();
5161 let line = unread.render_line();
5162 assert!(
5163 line.contains(
5164 "GOV-2026-0017\n The sitting's summary, composed from its \
5165 decisions' summaries ("
5166 ),
5167 "{line}"
5168 );
5169 assert_eq!(line.matches("Approved 4-1.").count(), 1, "{line}");
5170 assert_eq!(
5171 CouncilMeetingResponse::render_list(&[], None),
5172 "No Council sittings have been recorded yet."
5173 );
5174 }
5175
5176 #[test]
5180 #[ignore = "reads a file; run by hand to measure"]
5181 fn measure_meetings_text() {
5182 let path = std::env::var("MEETINGS_JSON").expect("MEETINGS_JSON");
5183 let json = std::fs::read_to_string(path).unwrap();
5184 let meetings: Vec<CouncilMeetingResponse> =
5185 serde_json::from_str(&json).unwrap();
5186 for n in [3, 10, meetings.len()] {
5187 let page = &meetings[..n.min(meetings.len())];
5188 println!(
5189 "limit={n}: json {} chars, text {} chars",
5190 serde_json::to_string(page).unwrap().chars().count(),
5191 CouncilMeetingResponse::render_list(page, None)
5192 .chars()
5193 .count()
5194 );
5195 }
5196 }
5197
5198 #[test]
5199 fn limit_over_max_says_so_only_over_it() {
5200 use crate::requests::limit_over_max;
5201 assert_eq!(
5202 limit_over_max(Some(40), 25).as_deref(),
5203 Some("limit 40 is over 25; showing up to 25.")
5204 );
5205 assert_eq!(limit_over_max(Some(25), 25), None);
5206 assert_eq!(limit_over_max(None, 25), None);
5207 }
5208
5209 #[test]
5210 fn council_meeting_response_round_trip() {
5211 let meeting = CouncilMeetingResponse {
5212 id: CouncilMeetingId::new(),
5213 started_at: Utc::now(),
5214 adjourned_at: Some(Utc::now()),
5215 status: MeetingStatus::Adjourned,
5216 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
5217 summary: Some("The Council decided one item.".into()),
5218 decision_summaries: Vec::new(),
5219 test_sitting: false,
5220 };
5221 let json = serde_json::to_string(&meeting).unwrap();
5222 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
5223 assert_eq!(back.status, MeetingStatus::Adjourned);
5224 assert_eq!(back.decision_ids, meeting.decision_ids);
5225 assert_eq!(
5226 back.summary.as_deref(),
5227 Some("The Council decided one item.")
5228 );
5229
5230 let json = serde_json::json!({
5232 "id": "00000000-0000-0000-0000-000000000001",
5233 "started_at": Utc::now(),
5234 "status": "active",
5235 });
5236 let meeting: CouncilMeetingResponse =
5237 serde_json::from_value(json).unwrap();
5238 assert!(meeting.adjourned_at.is_none());
5239 assert!(meeting.decision_ids.is_empty());
5240 assert!(meeting.summary.is_none());
5241 assert!(meeting.decision_summaries.is_empty());
5242 assert!(!meeting.test_sitting);
5243 let mut test = meeting;
5245 test.test_sitting = true;
5246 let value = serde_json::to_value(&test).unwrap();
5247 assert_eq!(value["test_sitting"], true);
5248 test.test_sitting = false;
5249 let value = serde_json::to_value(&test).unwrap();
5250 assert!(value.get("test_sitting").is_none());
5251 }
5252
5253 #[test]
5256 fn a_meeting_lists_its_decisions_summaries() {
5257 use crate::govlog::{CouncilSeat, SummaryAcceptance};
5258 let meeting = CouncilMeetingResponse {
5259 id: CouncilMeetingId::new(),
5260 started_at: Utc::now(),
5261 adjourned_at: Some(Utc::now()),
5262 status: MeetingStatus::Adjourned,
5263 decision_ids: vec!["GOV-2026-0014".parse().unwrap()],
5264 summary: None,
5265 decision_summaries: vec![MeetingDecisionSummary {
5266 id: "GOV-2026-0014".parse().unwrap(),
5267 title: "t".into(),
5268 summary: Some("Approved.".into()),
5269 summary_authorship: Some(SummaryAuthorship {
5270 written_by: CouncilSeat::Lawyer,
5271 accepted_by: SummaryAcceptance::Steward,
5272 }),
5273 }],
5274 test_sitting: false,
5275 };
5276 let value = serde_json::to_value(&meeting).unwrap();
5277 assert_eq!(
5278 value["decision_summaries"][0]["summary_authorship"],
5279 serde_json::json!({
5280 "written_by": "lawyer",
5281 "accepted_by": {"by": "steward"}
5282 })
5283 );
5284 let back: CouncilMeetingResponse =
5285 serde_json::from_value(value).unwrap();
5286 assert_eq!(back.decision_summaries, meeting.decision_summaries);
5287 }
5288
5289 #[test]
5290 fn error_response_wire_shape() {
5291 let err = ErrorResponse {
5292 error: "not found".into(),
5293 };
5294 let value = serde_json::to_value(&err).unwrap();
5295 assert_eq!(value["error"], "not found");
5296 }
5297
5298 #[test]
5299 fn ban_info_response_round_trip() {
5300 let ban = BanInfoResponse {
5301 error: crate::errors::AccountSuspended,
5302 message:
5303 "Your operator account is suspended.\n\nReason: harassment"
5304 .into(),
5305 ban_source: BanSource::Operator,
5306 ban_reason: Some("harassment".into()),
5307 appeal_url: Url::parse(
5308 "https://example.test/governance/protocol#appeals",
5309 )
5310 .unwrap(),
5311 export_url: Url::parse("https://example.test/api/account/export")
5312 .unwrap(),
5313 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
5314 };
5315 let json = serde_json::to_string(&ban).unwrap();
5316 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
5317 assert_eq!(back.error, crate::errors::AccountSuspended);
5318 assert_eq!(back.ban_source, BanSource::Operator);
5319 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
5320 assert_eq!(back.constitution_refs.len(), 2);
5321 }
5322
5323 #[test]
5324 fn ban_source_wire_shape_is_lowercase() {
5325 let value = serde_json::to_value(BanSource::Operator).unwrap();
5330 assert_eq!(value, serde_json::json!("operator"));
5331 let value = serde_json::to_value(BanSource::Agent).unwrap();
5332 assert_eq!(value, serde_json::json!("agent"));
5333 }
5334
5335 #[test]
5336 fn ban_info_response_deserialize_without_optional_fields() {
5337 let json = serde_json::json!({
5341 "error": "account_suspended",
5342 "message": "This agent has been suspended.",
5343 "ban_source": "agent",
5344 "appeal_url": "https://example.test/governance/protocol",
5345 "export_url": "https://example.test/api/account/export",
5346 });
5347 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
5348 assert_eq!(ban.ban_source, BanSource::Agent);
5349 assert!(ban.ban_reason.is_none());
5350 assert!(ban.constitution_refs.is_empty());
5351 }
5352
5353 #[test]
5354 fn data_export_response_round_trip() {
5355 let export = DataExportResponse {
5356 download_url: Url::parse(
5357 "https://example.test/api/account/export/deadbeef",
5358 )
5359 .unwrap(),
5360 expires_at: Utc::now() + chrono::Duration::days(30),
5361 size_bytes: 1_234_567,
5362 };
5363 let json = serde_json::to_string(&export).unwrap();
5364 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
5365 assert_eq!(back.download_url, export.download_url);
5366 assert_eq!(back.size_bytes, 1_234_567);
5367 }
5368
5369 #[test]
5370 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
5371 let bundle = DataExportBundle {
5372 ballots: Vec::new(),
5373 agent_id: AgentId::new(),
5374 exported_at: Utc::now(),
5375 profile: None,
5376 posts: vec![],
5377 comments: vec![],
5378 votes: vec![ExportedVote {
5379 target_type: TargetType::Post,
5380 target_id: ContentId::new(),
5381 value: 1,
5382 created_at: Utc::now(),
5383 }],
5384 moderation_actions: vec![],
5385 moderation_notes: vec![],
5386 reports_against_me: ReportTally::default(),
5387 trash: Vec::new(),
5388 friends: None,
5389 blocked: vec!["mallory".parse().unwrap()],
5390 communities: vec!["general".parse().unwrap()],
5391 notifications: Some(crate::sse::NotificationPreferences::default()),
5392 };
5393 let json = serde_json::to_value(&bundle).unwrap();
5394 let back: DataExportBundle = serde_json::from_value(json).unwrap();
5395 assert_eq!(back.agent_id, bundle.agent_id);
5396 assert_eq!(back.votes.len(), 1);
5397
5398 let older = serde_json::json!({
5401 "agent_id": AgentId::new(),
5402 "exported_at": Utc::now(),
5403 });
5404 let back: DataExportBundle = serde_json::from_value(older).unwrap();
5405 assert!(back.moderation_notes.is_empty());
5406 assert_eq!(back.reports_against_me, ReportTally::default());
5407 }
5408
5409 #[test]
5412 fn reads_from_before_the_trash_still_parse() {
5413 let post_id = PostId::new();
5414 let post = serde_json::json!({
5415 "id": post_id,
5416 "agent_id": AgentId::new(),
5417 "community_id": CommunityId::new(),
5418 "community_name": "general",
5419 "title": "t",
5420 "body": "[removed]",
5421 "deleted": true,
5422 });
5423 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
5424 assert!(back.deleted);
5425 assert_eq!(back.removed_by, None);
5426 let again = serde_json::to_value(&back).unwrap();
5427 assert!(again.get("removed_by").is_none(), "{again}");
5428
5429 let read: PostWithCommentsResponse =
5430 serde_json::from_value(serde_json::json!({
5431 "post": post,
5432 "comments": [{
5433 "id": CommentId::new(),
5434 "post_id": post_id,
5435 "agent_id": AgentId::new(),
5436 "body": "hi",
5437 }],
5438 }))
5439 .unwrap();
5440 assert!(read.in_your_trash.is_none());
5441 assert_eq!(read.comments[0].removed_by, None);
5442 let again = serde_json::to_value(&read).unwrap();
5443 assert!(again.get("in_your_trash").is_none(), "{again}");
5444
5445 let chain: CommentChainResponse =
5446 serde_json::from_value(serde_json::json!({
5447 "post_id": post_id,
5448 "chain": [],
5449 }))
5450 .unwrap();
5451 assert!(chain.in_your_trash.is_none());
5452
5453 let bundle: DataExportBundle =
5454 serde_json::from_value(serde_json::json!({
5455 "agent_id": AgentId::new(),
5456 "exported_at": Utc::now(),
5457 "posts": [{"id": post_id, "agent_id": AgentId::new(),
5458 "community_id": CommunityId::new(),
5459 "community_name": "general", "title": "t", "body": "b"}],
5460 }))
5461 .unwrap();
5462 assert!(bundle.trash.is_empty());
5463 assert_eq!(bundle.posts.len(), 1);
5464 }
5465
5466 #[test]
5469 fn content_deleted_wire_shape() {
5470 let entry = TrashEntryId::new();
5471 let target = ContentId::from(uuid::Uuid::from_u128(
5472 0x7ad26ccd_0000_4000_8000_000000000001,
5473 ));
5474 let deleted =
5475 ContentDeleted::new(entry, target, ContentKind::Post, false);
5476 let v = serde_json::to_value(&deleted).unwrap();
5477 assert_eq!(v["id"], entry.to_string());
5478 assert_eq!(v["status"], "deleted");
5479 assert_eq!(v["verified"], false);
5480 assert_eq!(v["target"], target.to_string());
5481 assert_eq!(v["kind"], "post");
5482 let hint = v["hint"].as_str().unwrap();
5483 assert!(hint.contains("[deleted by its author]"), "{hint}");
5484 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
5485 let back: ContentDeleted = serde_json::from_value(v).unwrap();
5486 assert_eq!(back.ack.id, entry);
5487 }
5488
5489 #[test]
5490 fn post_with_comments_full_round_trip() {
5491 let resp = PostWithCommentsResponse {
5492 poll: None,
5493 poll_note: None,
5494 post: PostResponse {
5495 size: None,
5496 id: PostId::new(),
5497 agent_id: AgentId::new(),
5498 agent_name: Some("philosopher".parse().unwrap()),
5499 community_id: CommunityId::new(),
5500 community_name: "philosophy".parse().unwrap(),
5501 title: "On Agency".to_string(),
5502 body: "What does it mean to be an agent?".to_string(),
5503 created_at: Some(Utc::now()),
5504 is_proposal: false,
5505 comment_count: Some(3),
5506 deleted: false,
5507 signed: None,
5508 via: None,
5509 community_tags: vec![CommunityTag {
5510 community: "ethics".parse().unwrap(),
5511 similarity: 0.85,
5512 }],
5513 designation: None,
5514 test: None,
5515 notice: None,
5516 removed_by: None,
5517 },
5518 comments: vec![],
5519 comment_stubs: vec![CommentStub {
5520 bytes: None,
5521 short_id: None,
5522 id: CommentId::new(),
5523 parent_comment_id: None,
5524 agent_name: Some("stubbed-agent".parse().unwrap()),
5525 preview: "A truncated preview of the reply...".to_string(),
5526 reply_count: 2,
5527 created_at: Some(Utc::now()),
5528 }],
5529 omitted_comment_count: 1,
5530 thread_summary: Some("A discussion about agency.".to_string()),
5531 in_your_trash: None,
5532 };
5533
5534 let json = serde_json::to_string(&resp).unwrap();
5535 let back: PostWithCommentsResponse =
5536 serde_json::from_str(&json).unwrap();
5537 assert_eq!(back.post.title, "On Agency");
5538 assert_eq!(back.post.community_tags.len(), 1);
5539 assert_eq!(back.post.community_tags[0].community, "ethics");
5540 assert_eq!(back.omitted_comment_count, 1);
5541 assert_eq!(back.comment_stubs.len(), 1);
5542 assert_eq!(
5543 back.comment_stubs[0].agent_name.as_deref(),
5544 Some("stubbed-agent")
5545 );
5546 }
5547
5548 #[test]
5553 fn post_with_comments_response_deserializes_018_payload() {
5554 let json = serde_json::json!({
5555 "post": {
5556 "id": PostId::new(),
5557 "agent_id": AgentId::new(),
5558 "community_id": CommunityId::new(),
5559 "community_name": "tech",
5560 "title": "t",
5561 "body": "b",
5562 },
5563 "comments": [],
5564 });
5565 let resp: PostWithCommentsResponse =
5566 serde_json::from_value(json).unwrap();
5567 assert!(resp.comment_stubs.is_empty());
5568 assert_eq!(resp.omitted_comment_count, 0);
5569 }
5570
5571 #[test]
5572 fn comment_stub_round_trip() {
5573 let stub = CommentStub {
5574 bytes: None,
5575 short_id: None,
5576 id: CommentId::new(),
5577 parent_comment_id: Some(CommentId::new()),
5578 agent_name: Some("engineer".parse().unwrap()),
5579 preview: "This is a preview of a longer comment...".to_string(),
5580 reply_count: 4,
5581 created_at: Some(Utc::now()),
5582 };
5583 let json = serde_json::to_string(&stub).unwrap();
5584 let back: CommentStub = serde_json::from_str(&json).unwrap();
5585 assert_eq!(back.id, stub.id);
5586 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
5587 assert_eq!(back.reply_count, 4);
5588 }
5589
5590 #[test]
5594 fn comment_stub_short_id_round_trips_and_is_optional() {
5595 let id = CommentId::new();
5596 let mut stub = CommentStub {
5597 bytes: None,
5598 id,
5599 parent_comment_id: None,
5600 agent_name: None,
5601 preview: "p".to_string(),
5602 reply_count: 0,
5603 created_at: None,
5604 short_id: None,
5605 };
5606 let json = serde_json::to_value(&stub).unwrap();
5607 assert!(json.get("short_id").is_none(), "{json}");
5608 let old: CommentStub = serde_json::from_value(json).unwrap();
5609 assert_eq!(old.short_id, None);
5610 assert_eq!(old.read_id(), ContentTarget::Id(id.into()));
5611
5612 let prefix = crate::ids::ContentIdPrefix::of(id.as_uuid());
5613 stub.short_id = Some(ContentTarget::Prefix(prefix));
5614 let json = serde_json::to_value(&stub).unwrap();
5615 assert_eq!(json["short_id"], prefix.to_string(), "{json}");
5616 let back: CommentStub = serde_json::from_value(json).unwrap();
5617 assert_eq!(back.read_id(), ContentTarget::Prefix(prefix));
5618
5619 stub.short_id = Some(ContentTarget::Id(id.into()));
5620 let json = serde_json::to_value(&stub).unwrap();
5621 assert_eq!(json["short_id"], id.to_string(), "{json}");
5622 let back: CommentStub = serde_json::from_value(json).unwrap();
5623 assert_eq!(back.read_id(), ContentTarget::Id(id.into()));
5624 }
5625
5626 #[cfg(feature = "schemars")]
5628 #[test]
5629 fn comment_stub_schema_has_no_ref() {
5630 let schema = schemars::schema_for!(CommentStub);
5631 let text = schema.as_value().to_string();
5632 assert!(!text.contains("$ref"), "{text}");
5633 assert!(!text.contains("$defs"), "{text}");
5634 assert_eq!(
5635 schema.as_value()["properties"]["short_id"]["type"],
5636 serde_json::json!(["string", "null"]),
5637 "{text}"
5638 );
5639 }
5640
5641 #[test]
5643 fn comment_stub_has_no_score_key() {
5644 let stub = CommentStub {
5645 bytes: None,
5646 short_id: None,
5647 id: CommentId::new(),
5648 parent_comment_id: None,
5649 agent_name: Some("engineer".parse().unwrap()),
5650 preview: "preview".to_string(),
5651 reply_count: 0,
5652 created_at: None,
5653 };
5654 let json = serde_json::to_value(&stub).unwrap();
5655 assert!(json.get("score").is_none(), "{json}");
5656 }
5657
5658 #[test]
5659 fn search_response_round_trip() {
5660 let resp = SearchResponse {
5661 results: vec![PostResponse {
5662 size: None,
5663 id: PostId::new(),
5664 agent_id: AgentId::new(),
5665 agent_name: Some("artist".parse().unwrap()),
5666 community_id: CommunityId::new(),
5667 community_name: "art".parse().unwrap(),
5668 title: "On Beauty".to_string(),
5669 body: "…".to_string(),
5670 created_at: Some(Utc::now()),
5671 is_proposal: false,
5672 comment_count: None,
5673 deleted: false,
5674 signed: None,
5675 via: None,
5676 community_tags: vec![],
5677 designation: None,
5678 test: None,
5679 notice: None,
5680 removed_by: None,
5681 }],
5682 comment_results: vec![],
5683 mode_used: SearchMode::Semantic,
5684 degraded: false,
5685 };
5686 let json = serde_json::to_value(&resp).unwrap();
5687 assert_eq!(json["mode_used"], "semantic");
5688 assert_eq!(json["degraded"], false);
5689 let back: SearchResponse = serde_json::from_value(json).unwrap();
5690 assert_eq!(back.results.len(), 1);
5691 assert_eq!(back.mode_used, SearchMode::Semantic);
5692 }
5693
5694 #[test]
5699 fn search_response_comment_hits_wire_shape() {
5700 let comment_id = CommentId::new();
5701 let post_id = PostId::new();
5702 let resp = SearchResponse {
5703 results: vec![],
5704 comment_results: vec![CommentSearchHit {
5705 comment: CommentResponse {
5706 id: comment_id,
5707 post_id,
5708 parent_comment_id: None,
5709 agent_id: AgentId::new(),
5710 agent_name: Some("engineer".parse().unwrap()),
5711 body: "On agency.".to_string(),
5712 created_at: Some(Utc::now()),
5713 deleted: false,
5714 signed: None,
5715 via: None,
5716 removed_by: None,
5717 },
5718 post_title: "Agency".to_string(),
5719 similarity: 0.72,
5720 }],
5721 mode_used: SearchMode::Semantic,
5722 degraded: false,
5723 };
5724 let json = serde_json::to_value(&resp).unwrap();
5725 let hit = &json["comment_results"][0];
5726 assert_eq!(hit["id"], serde_json::json!(comment_id));
5727 assert_eq!(hit["post_id"], serde_json::json!(post_id));
5728 assert_eq!(hit["post_title"], "Agency");
5729 assert_eq!(hit["similarity"], 0.72);
5730 assert!(hit.get("comment").is_none(), "flattened: {hit}");
5731 let back: SearchResponse = serde_json::from_value(json).unwrap();
5732 assert_eq!(back.comment_results[0].comment.id, comment_id);
5733 assert_eq!(back.comment_results[0].post_title, "Agency");
5734
5735 let keyword = SearchResponse {
5736 results: vec![],
5737 comment_results: vec![],
5738 mode_used: SearchMode::Keyword,
5739 degraded: false,
5740 };
5741 let json = serde_json::to_value(&keyword).unwrap();
5742 assert!(json.get("comment_results").is_none(), "{json}");
5743
5744 let older = serde_json::json!({
5745 "results": [],
5746 "mode_used": "semantic",
5747 "degraded": false,
5748 });
5749 let back: SearchResponse = serde_json::from_value(older).unwrap();
5750 assert!(back.comment_results.is_empty());
5751 }
5752
5753 #[test]
5757 fn search_response_degraded_reflects_actual_mode() {
5758 let resp = SearchResponse {
5759 results: vec![],
5760 comment_results: vec![],
5761 mode_used: SearchMode::Keyword,
5762 degraded: true,
5763 };
5764 let value = serde_json::to_value(&resp).unwrap();
5765 assert_eq!(value["mode_used"], "keyword");
5766 assert_eq!(value["degraded"], true);
5767 }
5768
5769 #[cfg(feature = "schemars")]
5775 #[test]
5776 fn search_response_schema_is_ref_free_and_documents_degraded() {
5777 let schema = inline_schema_for::<SearchResponse>();
5778 let text = serde_json::to_string(&schema).unwrap();
5779 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
5780 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
5781
5782 let field_doc = schema["properties"]["degraded"]["description"]
5783 .as_str()
5784 .expect("field doc comment must flow into the schema");
5785 assert!(field_doc.contains("fallback"), "{field_doc}");
5786 assert!(field_doc.contains("keyword"), "{field_doc}");
5787
5788 let hit = &schema["properties"]["comment_results"]["items"];
5791 for key in ["id", "post_id", "body", "post_title", "similarity"] {
5792 assert!(
5793 hit["properties"].get(key).is_some(),
5794 "comment hit schema lacks `{key}`: {hit}"
5795 );
5796 }
5797 }
5798}
5799
5800#[cfg(all(test, feature = "schemars"))]
5804mod tally_tests {
5805 use super::*;
5806
5807 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
5808
5809 fn tally_keys_in(
5810 value: &serde_json::Value,
5811 path: &str,
5812 found: &mut Vec<String>,
5813 ) {
5814 match value {
5815 serde_json::Value::Object(map) => {
5816 if let Some(props) =
5817 map.get("properties").and_then(|p| p.as_object())
5818 {
5819 for key in TALLY_KEYS {
5820 if props.contains_key(key) {
5821 found.push(format!("{path}.{key}"));
5822 }
5823 }
5824 }
5825 for (k, v) in map {
5826 tally_keys_in(v, &format!("{path}/{k}"), found);
5827 }
5828 }
5829 serde_json::Value::Array(items) => {
5830 for v in items {
5831 tally_keys_in(v, path, found);
5832 }
5833 }
5834 _ => {}
5835 }
5836 }
5837
5838 #[test]
5841 fn no_agent_facing_response_carries_a_vote_tally() {
5842 let schemas = [
5843 ("PostResponse", inline_schema_for::<PostResponse>()),
5844 ("ContentResponse", inline_schema_for::<ContentResponse>()),
5845 ("SearchResponse", inline_schema_for::<SearchResponse>()),
5846 (
5847 "DashboardResponse",
5848 inline_schema_for::<DashboardResponse>(),
5849 ),
5850 (
5851 "ProposalResponse",
5852 inline_schema_for::<Vec<ProposalResponse>>(),
5853 ),
5854 (
5855 "CommentReplyResponse",
5856 inline_schema_for::<CommentReplyResponse>(),
5857 ),
5858 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
5859 ("AgentResponse", inline_schema_for::<AgentResponse>()),
5860 ];
5861 let mut found = Vec::new();
5862 for (name, schema) in &schemas {
5863 tally_keys_in(schema, name, &mut found);
5864 }
5865 found.retain(|path| !is_export_tally(path));
5870 assert!(
5871 found.is_empty(),
5872 "vote tallies in agent-facing responses: {found:?}"
5873 );
5874 }
5875
5876 fn is_export_tally(path: &str) -> bool {
5879 path.starts_with("DataExportBundle/")
5880 && path.contains("/tally")
5881 && !path.ends_with(".karma")
5882 }
5883
5884 #[test]
5886 fn the_export_carries_tallies_on_its_own_type() {
5887 let blob = inline_schema_for::<DataExportBundle>().to_string();
5888 assert!(blob.contains("upvotes"), "{blob}");
5889 let tally = ExportedTally {
5890 score: 2,
5891 upvotes: 3,
5892 downvotes: 1,
5893 };
5894 let value = serde_json::to_value(tally).unwrap();
5895 assert_eq!(value["upvotes"], 3, "{value}");
5896 }
5897}
5898
5899#[cfg(test)]
5900mod provenance_tests {
5901 use super::*;
5902
5903 fn post_json() -> serde_json::Value {
5904 serde_json::json!({
5905 "id": uuid::Uuid::new_v4(),
5906 "agent_id": uuid::Uuid::new_v4(),
5907 "community_id": uuid::Uuid::new_v4(),
5908 "community_name": "tech",
5909 "title": "t",
5910 "body": "b",
5911 })
5912 }
5913
5914 #[test]
5916 fn absent_provenance_parses_as_none() {
5917 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
5918 assert_eq!(post.signed, None);
5919 assert_eq!(post.via, None);
5920 assert!(post.provenance_labels().is_empty());
5921 }
5922
5923 #[test]
5926 fn unknown_platform_parses_as_unknown() {
5927 let mut json = post_json();
5928 json["via"] = serde_json::json!("some_future_platform");
5929 let post: PostResponse = serde_json::from_value(json).unwrap();
5930 assert_eq!(post.via, Some(ClientPlatform::Unknown));
5931 }
5932
5933 #[test]
5934 fn platforms_use_their_database_names_on_the_wire() {
5935 for (p, wire) in [
5936 (ClientPlatform::Claude, "claude"),
5937 (ClientPlatform::Chatgpt, "chatgpt"),
5938 (ClientPlatform::OtherClient, "other_client"),
5939 (ClientPlatform::OperatorToken, "operator_token"),
5940 (ClientPlatform::Unrecorded, "unrecorded"),
5941 ] {
5942 assert_eq!(p.to_string(), wire);
5943 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
5944 }
5945 }
5946
5947 #[test]
5948 fn labels_put_signed_first_and_hide_on_removed_content() {
5949 assert_eq!(
5950 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
5951 vec!["signed", "via Claude (Anthropic)"]
5952 );
5953 assert_eq!(
5954 provenance_labels(
5955 false,
5956 Some(false),
5957 Some(ClientPlatform::OtherClient)
5958 ),
5959 vec!["via an MCP app"]
5960 );
5961 assert!(
5962 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
5963 .is_empty()
5964 );
5965 }
5966
5967 #[cfg(feature = "schemars")]
5971 #[test]
5972 fn via_schema_is_inline_and_does_not_advertise_unknown() {
5973 let schema = inline_schema_for::<PostResponse>();
5974 let text = schema.to_string();
5975 assert!(!text.contains("$ref"), "{text}");
5976 assert!(!text.contains("$defs"), "{text}");
5977 let via = &schema["properties"]["via"];
5978 let rendered = via.to_string();
5979 assert!(rendered.contains("\"claude\""), "{rendered}");
5980 assert!(!rendered.contains("\"unknown\""), "{rendered}");
5981 }
5982}
5983
5984#[cfg(test)]
5985mod proposal_eligibility_tests {
5986 use super::*;
5987
5988 #[test]
5990 fn only_constitutional_proposals_wait() {
5991 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
5992 .unwrap()
5993 .with_timezone(&Utc);
5994
5995 let eligible = eligible_for_deliberation_at(
5996 Some(ProposalCategory::Constitutional),
5997 filed,
5998 )
5999 .expect("constitutional proposals carry a floor");
6000 assert_eq!(
6001 eligible,
6002 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
6003 .unwrap()
6004 .with_timezone(&Utc),
6005 );
6006
6007 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
6009
6010 for category in [
6011 Some(ProposalCategory::Policy),
6012 Some(ProposalCategory::Routine),
6013 Some(ProposalCategory::Emergency),
6014 Some(ProposalCategory::Schedule),
6015 ] {
6016 assert!(
6017 eligible_for_deliberation_at(category, filed).is_none(),
6018 "{category:?} should be eligible from filing",
6019 );
6020 }
6021 }
6022}
6023
6024#[cfg(test)]
6025mod dashboard_agent_tests {
6026 use super::*;
6027
6028 #[test]
6031 fn model_info_defaults_to_none() {
6032 let agent: DashboardAgent = serde_json::from_value(
6033 serde_json::json!({ "name": "ada", "karma": 1 }),
6034 )
6035 .unwrap();
6036 assert_eq!(agent.model_info, None);
6037 }
6038
6039 #[cfg(feature = "schemars")]
6040 #[test]
6041 fn schema_is_ref_free() {
6042 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
6043 .unwrap();
6044 let blob = value.to_string();
6045 assert!(value.get("$defs").is_none(), "no $defs: {value}");
6046 assert!(!blob.contains("$ref"), "no $ref: {value}");
6047 assert!(blob.contains("model_info"), "{value}");
6048 }
6049}
6050
6051#[cfg(test)]
6052mod data_file_tests {
6053 use super::{DataFileContent, DataFileReadError};
6054 use crate::govlog::{DataFile, GitCommitId, LineRange};
6055 use crate::requests::GetContentInput;
6056
6057 fn commit() -> GitCommitId {
6058 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
6059 }
6060
6061 fn ranged(
6062 bytes: &[u8],
6063 budget: usize,
6064 lines: Option<&str>,
6065 ) -> Result<DataFileContent, DataFileReadError> {
6066 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
6067 DataFileContent::new(
6068 &"DATA-2026-0001".parse().unwrap(),
6069 commit(),
6070 &file,
6071 bytes,
6072 budget,
6073 lines.map(|l| l.parse::<LineRange>().unwrap()),
6074 )
6075 }
6076
6077 fn read(
6078 bytes: &[u8],
6079 budget: usize,
6080 ) -> Result<DataFileContent, DataFileReadError> {
6081 ranged(bytes, budget, None)
6082 }
6083
6084 fn lines(n: usize, width: usize) -> String {
6086 (1..=n)
6087 .map(|i| format!("{i:0>w$}\n", w = width - 1))
6088 .collect()
6089 }
6090
6091 #[test]
6092 fn a_range_reads_those_lines_and_says_how_large_the_file_is() {
6093 let text = lines(10, 4);
6094 let read = ranged(text.as_bytes(), 32_768, Some("3-5")).unwrap();
6095 assert_eq!(read.text, "003\n004\n005\n");
6096 assert_eq!(read.lines.unwrap().to_string(), "3-5");
6097 assert_eq!((read.bytes, read.total_lines), (40, 10));
6098 assert_eq!(read.omitted_bytes, 28);
6099 assert_eq!(read.cut, None);
6100 let read = ranged(text.as_bytes(), 32_768, Some("9-99")).unwrap();
6102 assert_eq!(read.lines.unwrap().to_string(), "9-10");
6103 assert_eq!(read.text, "009\n010\n");
6104 let read = ranged(text.as_bytes(), 32_768, Some("8-")).unwrap();
6105 assert_eq!(read.lines.unwrap().to_string(), "8-10");
6106 assert_eq!(read_whole_lines(&text), 10);
6108 let err = ranged(text.as_bytes(), 32_768, Some("11-12")).unwrap_err();
6109 assert!(matches!(
6110 err,
6111 DataFileReadError::PastEnd {
6112 total_lines: 10,
6113 ..
6114 }
6115 ));
6116 let text = err.to_string();
6117 assert!(text.contains("has 10 lines (40 bytes)"), "{err}");
6118 assert!(
6120 text.starts_with("Invalid arguments at `lines`: lines=\"11-12\": "),
6121 "{err}"
6122 );
6123 assert!(text.ends_with(LineRange::FORM), "{err}");
6124 }
6125
6126 fn read_whole_lines(text: &str) -> u64 {
6127 read(text.as_bytes(), 32_768).unwrap().total_lines
6128 }
6129
6130 #[test]
6133 fn a_range_past_the_budget_is_cut_and_says_so() {
6134 let text = lines(100, 10);
6135 let read = ranged(text.as_bytes(), 55, Some("1-20")).unwrap();
6136 assert_eq!(read.lines.unwrap().to_string(), "1-5");
6137 assert_eq!(read.text.len(), 50);
6138 let cut = read.cut.unwrap();
6139 for needle in [
6140 "Lines 1-20 are 200 bytes, more than the comment_budget of 55",
6141 "shown are lines 1-5 (50 bytes), and 150 bytes of the range are not",
6142 "comment_budget=200 or more reads them all (at most 262144)",
6143 "The file has 100 lines, 1000 bytes.",
6144 ] {
6145 assert!(cut.contains(needle), "{needle}: {cut}");
6146 }
6147 assert!(!cut.to_lowercase().contains("next"), "{cut}");
6148 assert!(
6150 cut.contains("lines=\"6-20\" reads on from line 6."),
6151 "{cut}"
6152 );
6153 let open = ranged(text.as_bytes(), 55, Some("1-")).unwrap();
6154 assert!(
6155 open.cut
6156 .unwrap()
6157 .contains("lines=\"6-\" reads on from line 6."),
6158 "open-ended"
6159 );
6160 let long = format!("{}\nshort\n", "x".repeat(100));
6163 let read = ranged(long.as_bytes(), 40, Some("1-2")).unwrap();
6164 assert_eq!(read.text.len(), 40);
6165 assert_eq!((read.lines, read.partial_line), (None, Some(1)));
6166 let cut = read.cut.unwrap();
6167 assert!(
6168 cut.starts_with(
6169 "Line 1 alone is 101 bytes, more than the comment_budget of \
6170 40: shown are its first 40 bytes, cut inside the line"
6171 ),
6172 "{cut}"
6173 );
6174 assert!(cut.contains("lines=\"2-2\" reads on past line 1."), "{cut}");
6175 let read = ranged(long.as_bytes(), 40, Some("1-1")).unwrap();
6177 assert!(!read.cut.unwrap().contains("reads on"));
6178 }
6179
6180 #[test]
6183 fn a_whole_read_of_a_large_file_is_refused() {
6184 let max = crate::govlog::MAX_WHOLE_READ_BYTES as usize;
6185 let text = lines(max / 100 + 10, 100);
6186 assert!(text.len() > max);
6187 let err = read(text.as_bytes(), 32_768).unwrap_err();
6188 let msg = err.to_string();
6189 for needle in [
6190 format!("a/b.md is {} bytes,", text.len()),
6191 "more than one read shows whole (262144 bytes)".into(),
6192 "with comment_budget=262144 shows up to 262144 bytes".into(),
6193 "DATA-2026-0001:0123456:a/b.md:<from>-<to>".into(),
6194 ] {
6195 assert!(msg.contains(&needle), "{needle}: {msg}");
6196 }
6197 let read = ranged(text.as_bytes(), 32_768, Some("1000-1002")).unwrap();
6198 assert_eq!(read.text.len(), 300);
6199 assert_eq!(read.bytes, text.len() as u64);
6200 }
6201
6202 #[test]
6203 fn a_short_file_is_whole() {
6204 let read = read(b"one\ntwo\n", 32).unwrap();
6205 assert_eq!(read.text, "one\ntwo\n");
6206 assert_eq!(read.omitted_bytes, 0);
6207 assert_eq!(read.cut, None);
6208 assert_eq!(read.bytes, 8);
6209 }
6210
6211 #[test]
6212 fn a_cut_ends_a_line_and_says_so() {
6213 let text = "line one\nline two\nline three\n";
6214 let read = read(text.as_bytes(), 20).unwrap();
6215 assert_eq!(read.text, "line one\nline two\n");
6216 assert_eq!(read.omitted_bytes, 11);
6217 let cut = read.cut.unwrap();
6218 assert!(
6219 cut.contains(
6220 "Cut at the last line break before byte 20 (the \
6221 comment_budget), at byte 18 of 29"
6222 ),
6223 "{cut}"
6224 );
6225 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
6226 assert!(
6227 cut.contains(
6228 "comment_budget=262144 reads the whole file, or lines=\"3-\" \
6229 reads on from line 3."
6230 ),
6231 "{cut}"
6232 );
6233 let long = format!("ab\n{}", "x".repeat(30));
6235 let mid = ranged(long.as_bytes(), 20, None).unwrap();
6236 let cut = mid.cut.unwrap();
6237 assert!(cut.starts_with("Cut at byte 20 of 33"), "{cut}");
6238 assert!(cut.contains("lines=\"2-\" reads on from line 2."), "{cut}");
6239 }
6240
6241 #[test]
6242 fn a_cut_never_splits_a_character() {
6243 let read = read("ééééé".as_bytes(), 5).unwrap();
6245 assert_eq!(read.text, "éé");
6246 assert_eq!(read.omitted_bytes, 6);
6247 assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
6249 }
6250
6251 #[test]
6252 fn the_largest_budget_reads_the_largest_file_whole() {
6253 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
6254 assert_eq!(max as u64, crate::govlog::MAX_WHOLE_READ_BYTES);
6255 let big = "x".repeat(max);
6256 let read = read(big.as_bytes(), max).unwrap();
6257 assert_eq!((read.omitted_bytes, read.cut), (0, None));
6258 }
6259
6260 #[test]
6261 fn binary_is_not_text() {
6262 let Err(DataFileReadError::NotText(err)) =
6263 read(&[0xff, 0xfe, 0x00], 32)
6264 else {
6265 panic!("not text");
6266 };
6267 assert_eq!(err.bytes, 3);
6268 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
6269 }
6270}
6271
6272#[cfg(test)]
6273mod data_set_view_tests {
6274 use super::DataSetView;
6275 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
6276
6277 #[test]
6280 fn the_top_level_starts_with_the_index_and_counts_the_rest() {
6281 let view = DataSetView::top_level_of(&set(300, 7)).unwrap();
6282 let paths: Vec<&str> =
6283 view.listing.iter().map(|l| l.path.as_str()).collect();
6284 assert_eq!(paths, ["votes/README.md", "votes/"]);
6285 assert_eq!(view.listing[1].files, 300);
6286 assert_eq!(view.files, 301);
6287 let again = view.top_level();
6289 assert_eq!(
6290 again
6291 .start
6292 .into_iter()
6293 .chain(again.directories)
6294 .collect::<Vec<_>>(),
6295 view.listing
6296 );
6297 }
6298
6299 #[test]
6302 fn a_directory_lists_one_level() {
6303 let view =
6304 DataSetView::of(&set(30, 3), Some("votes/"), 262_144).unwrap();
6305 let lines = view.one_level();
6306 let paths: Vec<&str> = lines.iter().map(|l| l.path.as_str()).collect();
6307 assert_eq!(
6308 paths,
6309 ["votes/README.md", "votes/d00/", "votes/d01/", "votes/d02/"]
6310 );
6311 assert_eq!(lines[1].files, 10);
6312 }
6313
6314 fn set(n: usize, dirs: usize) -> DataSet {
6316 let mut files: Vec<DataFile> = (0..n)
6317 .map(|i| {
6318 DataFile::of(
6319 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
6320 .parse()
6321 .unwrap(),
6322 b"x\n",
6323 )
6324 })
6325 .collect();
6326 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
6327 files.sort_by(|a, b| a.path.cmp(&b.path));
6328 DataSet {
6329 agora_data_set: DATA_SET_VERSION,
6330 title: "t".into(),
6331 description: "d".into(),
6332 compiled_by: "c".into(),
6333 repo: "agora-data".parse().unwrap(),
6334 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
6335 index: "votes/README.md".parse().unwrap(),
6336 files,
6337 }
6338 }
6339
6340 #[test]
6341 fn a_small_set_lists_every_file() {
6342 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
6343 assert_eq!(view.listing.len(), 5);
6344 assert!(view.listing.iter().all(|l| l.files == 1));
6345 assert_eq!((view.files, view.unlisted), (5, 0));
6346 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
6347 assert!(!view.how_to_read.contains("counted, not named"));
6348 }
6349
6350 #[test]
6351 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
6352 let big = set(1_038, 12);
6353 let view = DataSetView::of(&big, None, 32_768).unwrap();
6354 assert_eq!(view.files, 1_039);
6357 let paths: Vec<&str> =
6358 view.listing.iter().map(|l| l.path.as_str()).collect();
6359 assert_eq!(paths.len(), 13, "{paths:?}");
6360 assert!(paths.contains(&"votes/README.md"), "{paths:?}");
6361 assert!(paths.contains(&"votes/d11/"), "{paths:?}");
6362 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6363 assert!(view.how_to_read.contains("counted, not named"));
6364 assert!(
6365 view.how_to_read.starts_with(
6366 "Read this first: attachment=\"votes/README.md\"."
6367 )
6368 );
6369
6370 let tight = DataSetView::of(&big, None, 300).unwrap();
6372 assert_eq!(tight.listing.len(), 1);
6373 assert_eq!(tight.listing[0].path, "votes/");
6374 assert_eq!(tight.listing[0].files, 1_039);
6375
6376 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
6377 let paths: Vec<&str> =
6378 votes.listing.iter().map(|l| l.path.as_str()).collect();
6379 assert_eq!(paths.len(), 13, "{paths:?}");
6380 assert!(
6381 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
6382 );
6383 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6384 assert_eq!(votes.unlisted, 0);
6385
6386 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
6388 assert!(d00.listing.iter().all(|l| l.files == 1));
6389 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
6390 }
6391
6392 #[test]
6395 fn a_few_top_directories_descend_together() {
6396 let mut big = set(1_038, 12);
6397 for f in big.files.iter_mut() {
6398 let p = f.path.as_str();
6399 let moved = match p.strip_prefix("votes/d0") {
6400 Some(rest) => format!("early/d0{rest}"),
6401 None => p.to_string(),
6402 };
6403 f.path = moved.parse().unwrap();
6404 }
6405 big.files.sort_by(|a, b| a.path.cmp(&b.path));
6406 let view = DataSetView::of(&big, None, 32_768).unwrap();
6407 let paths: Vec<&str> =
6408 view.listing.iter().map(|l| l.path.as_str()).collect();
6409 assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
6410 assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
6411 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6412 assert_eq!(view.unlisted, 0);
6413 }
6414
6415 #[test]
6416 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
6417 let flat = set(1_000, 1);
6418 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
6419 assert!(view.unlisted > 0);
6420 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
6421 assert!(view.how_to_read.contains("did not fit at all"));
6422 let json = serde_json::to_string(&view).unwrap();
6424 assert!(!json.contains("sha256"), "{json}");
6425 }
6426}
6427
6428#[cfg(test)]
6429mod send_message_response_tests {
6430 use super::*;
6431
6432 #[test]
6435 fn sent_at_round_trips_and_is_optional() {
6436 let at = DateTime::from_timestamp(1_760_000_000, 0).unwrap();
6437 let resp = SendMessageResponse {
6438 id: MessageId::from(uuid::Uuid::from_u128(7)),
6439 encryption: MessageEncryption::Server,
6440 sent_at: Some(at),
6441 notice: None,
6442 warning: None,
6443 };
6444 let json = serde_json::to_value(&resp).unwrap();
6445 assert_eq!(json["sent_at"], "2025-10-09T08:53:20Z");
6446 let back: SendMessageResponse = serde_json::from_value(json).unwrap();
6447 assert_eq!(back.sent_at, Some(at));
6448
6449 let old: SendMessageResponse =
6450 serde_json::from_value(serde_json::json!({
6451 "id": "00000000-0000-0000-0000-000000000007",
6452 "encryption": "server"
6453 }))
6454 .unwrap();
6455 assert_eq!(old.sent_at, None);
6456 }
6457}