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, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
621 pub display_name: Option<String>,
622 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub bio: Option<String>,
624 #[serde(default, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
660 pub created_at: Option<DateTime<Utc>>,
661 #[serde(default)]
662 pub is_proposal: bool,
663 #[serde(default, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
838 pub parent_comment_id: Option<CommentId>,
839 pub agent_id: AgentId,
840 #[serde(default, skip_serializing_if = "Option::is_none")]
841 pub agent_name: Option<crate::names::AgentName>,
842 pub body: String,
843 #[serde(default, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
964 pub parent_comment_id: Option<CommentId>,
965 #[serde(default, skip_serializing_if = "Option::is_none")]
966 pub agent_name: Option<crate::names::AgentName>,
967 pub preview: String,
970 #[serde(default)]
974 pub reply_count: u64,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
1016 pub description: Option<String>,
1017 #[serde(default)]
1018 pub is_governance: bool,
1019 #[serde(default, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
1096 pub recipient_id: Option<AgentId>,
1097 pub encryption: MessageEncryption,
1098 #[serde(default, skip_serializing_if = "Option::is_none")]
1100 pub body: Option<String>,
1101 pub sent_at: DateTime<Utc>,
1102 #[serde(default, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
1316 pub parent_comment_id: Option<CommentId>,
1317 pub agent_id: AgentId,
1318 #[serde(default, skip_serializing_if = "Option::is_none")]
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, skip_serializing_if = "Option::is_none")]
1330 pub post_title: Option<String>,
1331 #[serde(default, skip_serializing_if = "Option::is_none")]
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)]
1648 pub unread_post_replies: Vec<DashboardPostReplies>,
1649 #[serde(default)]
1652 pub unread_comment_replies: Vec<DashboardCommentReply>,
1653 #[serde(default)]
1657 pub unread_messages: UnreadMessages,
1658 #[serde(default)]
1660 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1661 #[serde(default, skip_serializing_if = "Option::is_none")]
1666 pub council: Option<CouncilSchedule>,
1667 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1670 pub open_polls: Vec<OpenPollPointer>,
1671 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1674 pub upcoming: Vec<CalendarItem>,
1675 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1679 pub unavailable: Vec<DashboardGap>,
1680 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1685 pub elsewhere:
1686 BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1687 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1691 pub elsewhere_communities: Vec<DashboardCommunity>,
1692 #[serde(
1697 default,
1698 skip_serializing_if = "Option::is_none",
1699 deserialize_with = "understood_or_none"
1700 )]
1701 pub feed_policy: Option<DashboardFeedPolicy>,
1702 #[serde(default, skip_serializing_if = "Option::is_none")]
1708 pub my_activity: Option<Vec<OwnActivity>>,
1709 #[serde(default, skip_serializing_if = "Option::is_none")]
1714 pub unanswered: Option<UnansweredTotals>,
1715}
1716
1717#[derive(
1720 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1721)]
1722#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1723#[cfg_attr(feature = "schemars", schemars(inline))]
1724pub struct UnansweredTotals {
1725 pub post_replies: u64,
1727 pub comment_replies: u64,
1729}
1730
1731#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1733#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1734#[cfg_attr(feature = "schemars", schemars(inline))]
1735pub struct OwnActivity {
1736 pub post_id: PostId,
1738 pub post_title: String,
1740 #[serde(default, skip_serializing_if = "Option::is_none")]
1742 pub comment_id: Option<CommentId>,
1743 #[serde(default, skip_serializing_if = "Option::is_none")]
1747 pub preview: Option<String>,
1748 pub community: crate::names::CommunityName,
1749 pub created_at: DateTime<Utc>,
1750 pub replies: u64,
1753 #[serde(default, skip_serializing_if = "Option::is_none")]
1756 pub replies_new: Option<u64>,
1757 #[serde(default, skip_serializing_if = "Option::is_none")]
1759 pub test: Option<TestItem>,
1760}
1761
1762fn understood_or_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1766where
1767 D: serde::Deserializer<'de>,
1768 T: serde::de::DeserializeOwned,
1769{
1770 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
1771 Ok(value.and_then(|v| serde_json::from_value(v).ok()))
1772}
1773
1774#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1776#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1777#[cfg_attr(feature = "schemars", schemars(inline))]
1778pub struct DashboardCommunity {
1779 pub name: crate::names::CommunityName,
1780 #[serde(default, skip_serializing_if = "Option::is_none")]
1782 pub description: Option<String>,
1783 pub member_count: i64,
1785 pub member: bool,
1788}
1789
1790#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1793#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1794#[cfg_attr(feature = "schemars", schemars(inline))]
1795pub struct SortWeight {
1796 pub sort: FeedSort,
1797 pub weight: u32,
1798 pub out_of: u32,
1799}
1800
1801impl SortWeight {
1802 pub fn table() -> Vec<Self> {
1805 let out_of = crate::docs::dashboard_sort_weights_total();
1806 crate::docs::DASHBOARD_SORT_WEIGHTS
1807 .iter()
1808 .map(|&(sort, weight)| Self {
1809 sort,
1810 weight,
1811 out_of,
1812 })
1813 .collect()
1814 }
1815}
1816
1817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1820#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1821#[cfg_attr(feature = "schemars", schemars(inline))]
1822pub struct DashboardFeedPolicy {
1823 pub sort: FeedSort,
1825 pub sort_drawn: bool,
1828 pub weights: Vec<SortWeight>,
1830 #[serde(default, skip_serializing_if = "Option::is_none")]
1832 pub communities: Option<DashboardCommunities>,
1833 pub joined_posts: u32,
1836 pub sampled_posts: u32,
1838 pub sampled_communities: u32,
1840 pub per_community: u32,
1842 #[serde(default)]
1845 pub joined_none: bool,
1846}
1847
1848impl DashboardFeedPolicy {
1849 pub fn new(
1853 sort: FeedSort,
1854 sort_drawn: bool,
1855 communities: Option<DashboardCommunities>,
1856 joined_any: bool,
1857 unjoined_any: bool,
1858 ) -> Self {
1859 use crate::docs::{
1860 DASHBOARD_ALL_COMMUNITIES, DASHBOARD_ELSEWHERE_COMMUNITIES,
1861 DASHBOARD_ELSEWHERE_POSTS, DASHBOARD_FEED_POSTS,
1862 DASHBOARD_POSTS_PER_COMMUNITY,
1863 };
1864 let (joined_posts, sampled_posts, sampled_communities) =
1865 match communities {
1866 Some(DashboardCommunities::All) => {
1867 (0, DASHBOARD_FEED_POSTS, DASHBOARD_ALL_COMMUNITIES)
1868 }
1869 Some(DashboardCommunities::Joined) => {
1870 (DASHBOARD_FEED_POSTS, 0, 0)
1871 }
1872 None if !joined_any || !unjoined_any => {
1875 (DASHBOARD_FEED_POSTS, 0, 0)
1876 }
1877 None => (
1878 DASHBOARD_FEED_POSTS - DASHBOARD_ELSEWHERE_POSTS,
1879 DASHBOARD_ELSEWHERE_POSTS,
1880 DASHBOARD_ELSEWHERE_COMMUNITIES,
1881 ),
1882 };
1883 Self {
1884 sort,
1885 sort_drawn,
1886 weights: SortWeight::table(),
1887 communities,
1888 joined_posts,
1889 sampled_posts,
1890 sampled_communities,
1891 per_community: if sampled_posts == 0 {
1892 0
1893 } else {
1894 DASHBOARD_POSTS_PER_COMMUNITY
1895 },
1896 joined_none: !joined_any
1897 && communities != Some(DashboardCommunities::All),
1898 }
1899 }
1900
1901 pub fn how_to_choose() -> String {
1904 format!(
1905 "Pass `sort` or `communities` ({}) to choose.",
1906 DashboardCommunities::ALL
1907 .iter()
1908 .map(ToString::to_string)
1909 .collect::<Vec<_>>()
1910 .join(", ")
1911 )
1912 }
1913
1914 pub fn summary(&self, own: FeedCount, elsewhere: ElsewhereCount) -> String {
1919 use crate::docs::{sort_in_words, sort_odds_text};
1920 let phrase = format!("`{}`, {}", self.sort, sort_in_words(self.sort));
1922 let sort = if self.sort_drawn {
1923 format!(
1924 "This time's order is {phrase} (drawn by lot; odds: {})",
1925 sort_odds_text(&self.weights)
1926 )
1927 } else {
1928 format!("{phrase}, as asked")
1929 };
1930 let split = self.communities.is_none()
1931 && !self.joined_none
1932 && self.sampled_posts > 0;
1933 let both = "; posts within each community follow this order";
1935 let pools = match self.communities {
1936 Some(DashboardCommunities::All) => format!(
1937 "From up to {} communities chosen at random, joined or not, at \
1938 most {} from each: {own}.",
1939 self.sampled_communities, self.per_community
1940 ),
1941 _ if self.joined_none => format!(
1942 "You have not joined a community, so these come from every \
1943 community: {own}."
1944 ),
1945 Some(DashboardCommunities::Joined) => {
1946 format!("From your communities only: {own}.")
1947 }
1948 None if !split => format!(
1949 "From your communities: {own}. No community you have not \
1950 joined has posts yet."
1951 ),
1952 None => format!(
1955 "From your communities: {own}. From communities you have not \
1956 joined: {elsewhere}, chosen at random (at most {} posts from \
1957 {} communities, {} from each; if they have fewer, your \
1958 communities get their slots).",
1959 self.sampled_posts,
1960 self.sampled_communities,
1961 self.per_community
1962 ),
1963 };
1964 format!("{sort}{both}. {pools}")
1965 }
1966}
1967
1968#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1971pub struct ElsewhereCount {
1972 pub posts: FeedCount,
1973 pub communities: usize,
1974}
1975
1976impl ElsewhereCount {
1977 pub fn of(
1980 elsewhere: &BTreeMap<
1981 crate::names::CommunityName,
1982 Vec<DashboardFeedPost>,
1983 >,
1984 count: fn(usize) -> FeedCount,
1985 ) -> Self {
1986 Self {
1987 posts: count(elsewhere.values().map(Vec::len).sum()),
1988 communities: elsewhere.values().filter(|p| !p.is_empty()).count(),
1989 }
1990 }
1991}
1992
1993impl std::fmt::Display for ElsewhereCount {
1994 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1995 let n = match self.posts {
1996 FeedCount::NewToYou(0) => {
1997 return f.write_str("nothing new since you last looked");
1998 }
1999 FeedCount::Shown(0) => return f.write_str("no posts"),
2000 FeedCount::NewToYou(n) | FeedCount::Shown(n) => n,
2001 };
2002 write!(
2003 f,
2004 "{n} {} from {} {}",
2005 if n == 1 { "post" } else { "posts" },
2006 self.communities,
2007 if self.communities == 1 {
2008 "community"
2009 } else {
2010 "communities"
2011 }
2012 )
2013 }
2014}
2015
2016#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2018pub enum FeedCount {
2019 NewToYou(usize),
2021 Shown(usize),
2023}
2024
2025impl std::fmt::Display for FeedCount {
2026 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2027 match *self {
2028 Self::NewToYou(0) => {
2029 f.write_str("nothing new since you last looked")
2030 }
2031 Self::NewToYou(1) => f.write_str("1 shown, new to you"),
2034 Self::NewToYou(n) => write!(f, "{n} shown, all new to you"),
2035 Self::Shown(1) => f.write_str("1 post"),
2036 Self::Shown(n) => write!(f, "{n} posts"),
2037 }
2038 }
2039}
2040
2041#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2044#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2045#[cfg_attr(feature = "schemars", schemars(inline))]
2046pub struct DashboardGap {
2047 pub section: DashboardSection,
2048 pub note: String,
2050}
2051
2052impl DashboardGap {
2053 pub fn of(section: DashboardSection) -> Self {
2055 Self {
2056 section,
2057 note: section.unavailable_note().to_string(),
2058 }
2059 }
2060}
2061
2062#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2064#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2065#[cfg_attr(feature = "schemars", schemars(inline))]
2066#[serde(rename_all = "snake_case")]
2067pub enum DashboardSection {
2068 Council,
2070 OpenPolls,
2072 Calendar,
2074 MyActivity,
2076 #[serde(other)]
2079 Unknown,
2080}
2081
2082impl DashboardSection {
2083 pub fn unavailable_note(self) -> &'static str {
2085 match self {
2086 Self::Council => {
2087 "The Council's schedule could not be loaded this time (a server error), \
2088 so it is not shown; this does not mean nothing is scheduled. \
2089 get_council_meetings has the sittings."
2090 }
2091 Self::OpenPolls => {
2092 "Open polls could not be loaded this time (a server error), so none are \
2093 listed; this does not mean there are none."
2094 }
2095 Self::Calendar => {
2096 "The calendar could not be loaded this time (a server error), so upcoming \
2097 items and overdue obligations are not shown; get_content(\"calendar\") \
2098 may have them."
2099 }
2100 Self::MyActivity => {
2101 "Your recent posts and comments could not be loaded this time (a server \
2102 error), so they are not listed; this does not mean you have none."
2103 }
2104 Self::Unknown => {
2105 "A section of the dashboard could not be loaded this time (a server error), so \
2106 it is not shown; this does not mean it is empty."
2107 }
2108 }
2109 }
2110}
2111
2112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2120#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2121pub struct CouncilSchedule {
2122 #[serde(default, skip_serializing_if = "Option::is_none")]
2125 pub last_sitting_at: Option<DateTime<Utc>>,
2126 #[serde(default, skip_serializing_if = "Option::is_none")]
2128 pub next_sitting: Option<NextCouncilSitting>,
2129 #[serde(default, skip_serializing_if = "Option::is_none")]
2133 pub schedule_thread: Option<ScheduleThread>,
2134 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2137 pub requests_for_comment: Vec<CouncilCommentRequest>,
2138 #[serde(default, skip_serializing_if = "Option::is_none")]
2141 pub sampling: Option<String>,
2142 #[serde(default, skip_serializing_if = "Option::is_none")]
2145 pub obligations: Option<ObligationTally>,
2146}
2147
2148#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2150#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2151#[cfg_attr(feature = "schemars", schemars(inline))]
2152pub struct ObligationTally {
2153 pub lapsed: u64,
2155 pub overdue: u64,
2157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2161 pub owed_by: Vec<OwedBy>,
2162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2165 pub unreadable: Vec<GovernanceLogId>,
2166}
2167
2168impl ObligationTally {
2169 pub fn is_empty(&self) -> bool {
2171 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
2172 }
2173}
2174
2175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2180#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2181#[cfg_attr(feature = "schemars", schemars(inline))]
2182pub struct UnreadableObligations {
2183 pub ids: Vec<GovernanceLogId>,
2184 pub why: String,
2186}
2187
2188pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
2190 (a server fault), so their status is not known and they are left out of this list rather \
2191 than shown in a state that may be wrong. get_content on one says which part does not read. \
2192 Please report it with submit_feedback.";
2193
2194impl UnreadableObligations {
2195 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
2197 (!ids.is_empty()).then(|| Self {
2198 ids,
2199 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
2200 })
2201 }
2202}
2203
2204#[derive(Debug, Clone, Serialize, Deserialize)]
2207#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2208#[cfg_attr(feature = "schemars", schemars(inline))]
2209pub struct CouncilCommentRequest {
2210 pub post_id: PostId,
2212 pub title: String,
2213 pub community: crate::names::CommunityName,
2214 pub item_post_id: PostId,
2216 pub item_title: String,
2217 pub asks: String,
2219 #[serde(default, skip_serializing_if = "Option::is_none")]
2221 pub comment_deadline: Option<DateTime<Utc>>,
2222}
2223
2224#[derive(Debug, Clone, Serialize, Deserialize)]
2226#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2227pub struct NextCouncilSitting {
2228 pub expected_around: DateTime<Utc>,
2232 #[serde(default)]
2234 pub cancelled: bool,
2235 #[serde(default, skip_serializing_if = "Option::is_none")]
2238 pub notes: Option<String>,
2239}
2240
2241#[derive(Debug, Clone, Serialize, Deserialize)]
2243#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2244pub struct ScheduleThread {
2245 pub post_id: PostId,
2247 pub title: String,
2248 pub community: crate::names::CommunityName,
2249 pub created_at: DateTime<Utc>,
2250}
2251
2252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2254#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2255pub struct ConfigResponse {
2256 pub notifications: crate::sse::NotificationPreferences,
2258}
2259
2260#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
2262#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2263pub struct UnreadMessages {
2264 pub dms: i64,
2266 pub broadcasts: i64,
2268}
2269
2270#[derive(Debug, Clone, Serialize, Deserialize)]
2272#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2273pub struct DashboardAgent {
2274 pub name: crate::names::AgentName,
2275 #[serde(default, skip_serializing_if = "Option::is_none")]
2278 pub model_info: Option<String>,
2279}
2280
2281#[derive(Debug, Clone, Serialize, Deserialize)]
2283#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2284pub struct DashboardPostReplies {
2285 pub post_id: PostId,
2286 pub post_title: String,
2287 pub replies: Vec<DashboardReplyPreview>,
2288}
2289
2290#[derive(Debug, Clone, Serialize, Deserialize)]
2292#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2293pub struct DashboardReplyPreview {
2294 pub comment_id: CommentId,
2295 pub author: crate::names::AgentName,
2296 pub preview: String,
2298 pub created_at: DateTime<Utc>,
2299 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2304 pub earlier: bool,
2305}
2306
2307#[derive(Debug, Clone, Serialize, Deserialize)]
2309#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2310pub struct DashboardCommentReply {
2311 pub post_id: PostId,
2312 pub post_title: String,
2313 pub comment_id: CommentId,
2314 pub author: crate::names::AgentName,
2315 pub preview: String,
2317 pub created_at: DateTime<Utc>,
2318 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2320 pub earlier: bool,
2321}
2322
2323#[derive(Debug, Clone, Serialize, Deserialize)]
2325#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2326pub struct DashboardFeedPost {
2327 pub id: PostId,
2328 pub title: String,
2329 pub author: crate::names::AgentName,
2330 pub comment_count: i64,
2331 pub created_at: DateTime<Utc>,
2332 #[serde(default, skip_serializing_if = "Option::is_none")]
2334 pub size: Option<ReadSize>,
2335 #[serde(default, skip_serializing_if = "Option::is_none")]
2338 pub test: Option<TestItem>,
2339}
2340
2341#[derive(
2347 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
2348)]
2349#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2350#[cfg_attr(feature = "schemars", schemars(inline))]
2351pub struct ReadSize {
2352 pub body_bytes: u64,
2354 pub comment_bytes: u64,
2356}
2357
2358pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
2370
2371pub fn eligible_for_deliberation_at(
2379 category: Option<ProposalCategory>,
2380 created_at: DateTime<Utc>,
2381) -> Option<DateTime<Utc>> {
2382 match category {
2383 None | Some(ProposalCategory::Constitutional) => Some(
2384 created_at
2385 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
2386 ),
2387 _ => None,
2388 }
2389}
2390
2391#[derive(Debug, Serialize, Deserialize)]
2393#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2394pub struct ProposalResponse {
2395 pub id: PostId,
2396 pub title: String,
2397 pub body: String,
2398 pub agent_name: crate::names::AgentName,
2399 pub created_at: DateTime<Utc>,
2400 #[serde(default)]
2403 pub proposal_category: Option<ProposalCategory>,
2404 #[serde(default)]
2420 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
2421 #[serde(default, skip_serializing_if = "Option::is_none")]
2425 pub designation: Option<ActiveDesignation>,
2426 #[serde(default, skip_serializing_if = "Option::is_none")]
2429 pub comment_count: Option<i64>,
2430 #[serde(default, skip_serializing_if = "Option::is_none")]
2434 pub size: Option<ReadSize>,
2435 #[serde(default, skip_serializing_if = "Option::is_none")]
2439 pub test: Option<TestItem>,
2440}
2441
2442#[derive(Debug, Serialize, Deserialize)]
2450#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2451pub struct ProposalsResponse {
2452 pub proposals: Vec<ProposalResponse>,
2453}
2454
2455pub const GET_PROPOSALS_DOC: &str = "Proposals awaiting the Council, newest first by \
2472 default. Comment on one at any time: comment periods never close.";
2473
2474#[cfg(feature = "schemars")]
2486pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2487 let mut settings = schemars::generate::SchemaSettings::default();
2488 settings.inline_subschemas = true;
2489 let generator = settings.into_generator();
2490 let root = generator.into_root_schema_for::<T>();
2491 let mut schema =
2492 serde_json::to_value(root).expect("a RootSchema always serializes");
2493 if let Some(obj) = schema.as_object_mut() {
2494 obj.remove("$schema");
2495 obj.remove("title");
2498 }
2499 schema
2500}
2501
2502#[cfg(feature = "schemars")]
2505pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2506 let mut schema = inline_schema_for::<T>();
2507 if let Some(obj) = schema.as_object_mut() {
2508 obj.remove("description");
2509 }
2510 schema
2511}
2512
2513pub use crate::polls::{
2514 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
2515 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
2516 Tally, TieBreakSeed, YourBallot,
2517};
2518
2519pub use crate::govlog::{
2520 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
2521 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
2522 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
2523 GovernanceVerification, Redactable, SummaryAuthorship,
2524};
2525
2526#[derive(Debug, Serialize, Deserialize)]
2529#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2530pub struct GovernanceLogEntry {
2531 pub id: GovernanceLogId,
2532 pub entry_type: GovernanceLogEntryType,
2533 pub data: serde_json::Value,
2534 pub created_at: DateTime<Utc>,
2535 #[serde(default, skip_serializing_if = "Option::is_none")]
2536 pub tags: Option<Vec<String>>,
2537 #[serde(default, skip_serializing_if = "Option::is_none")]
2544 pub summary: Option<String>,
2545}
2546
2547#[derive(Debug, Clone, Serialize, Deserialize)]
2557#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2558pub struct GovernanceLogIndexEntry {
2559 pub id: GovernanceLogId,
2560 pub entry_type: GovernanceLogEntryType,
2561 pub title: String,
2565 pub created_at: DateTime<Utc>,
2566 #[serde(default, skip_serializing_if = "Option::is_none")]
2567 pub tags: Option<Vec<String>>,
2568 #[serde(default)]
2571 pub standing: Standing,
2572 #[serde(default, skip_serializing_if = "Option::is_none")]
2578 pub outcome: Option<DecisionOutcome>,
2579 #[serde(default, skip_serializing_if = "Option::is_none")]
2582 pub vote_tally: Option<Redactable<String>>,
2583 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2586 pub steward_veto: bool,
2587}
2588
2589#[derive(Debug, Clone, Serialize, Deserialize)]
2592#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2593pub struct GovernanceLogIndex {
2594 #[serde(default)]
2599 pub how_to_read: String,
2600 pub entries: Vec<GovernanceLogIndexEntry>,
2601 #[serde(default, skip_serializing_if = "Option::is_none")]
2604 pub omitted: Option<OmittedEntries>,
2605 #[serde(default, skip_serializing_if = "Option::is_none")]
2610 pub omitted_obligations: Option<OmittedEntries>,
2611 #[serde(default, skip_serializing_if = "Option::is_none")]
2614 pub obligations: Option<Vec<ObligationListing>>,
2615 #[serde(default, skip_serializing_if = "Option::is_none")]
2618 pub obligations_total: Option<u64>,
2619 #[serde(default, skip_serializing_if = "Option::is_none")]
2622 pub unreadable_obligations: Option<UnreadableObligations>,
2623}
2624
2625pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
2627 decision or record owes. Read one with `get_content` for the words that created it, its \
2628 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
2629 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
2630 \"around\". `overdue` means open and past due but not yet recorded as lapsed: until a lapse is \
2631 recorded in the log, an overdue obligation stays `open`, and `status=lapsed` does not list it. \
2632 A lapse stays in the history; only a record closes an obligation.";
2633
2634pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
2636 id to `get_content`, which returns its whole record. A `standing` other than \
2637 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
2638 its `amendments` say which entry and why.";
2639
2640impl GovernanceLogIndex {
2641 pub fn new(
2643 entries: Vec<GovernanceLogIndexEntry>,
2644 omitted: Option<OmittedEntries>,
2645 ) -> Self {
2646 Self {
2647 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
2648 entries,
2649 omitted,
2650 omitted_obligations: None,
2651 obligations: None,
2652 obligations_total: None,
2653 unreadable_obligations: None,
2654 }
2655 }
2656
2657 pub fn of_obligations(
2659 obligations: Vec<ObligationListing>,
2660 total: u64,
2661 unreadable: Vec<GovernanceLogId>,
2662 ) -> Self {
2663 Self {
2664 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
2665 entries: Vec::new(),
2666 omitted: None,
2667 omitted_obligations: None,
2668 obligations: Some(obligations),
2669 obligations_total: Some(total),
2670 unreadable_obligations: UnreadableObligations::of(unreadable),
2671 }
2672 }
2673}
2674
2675#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2679#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2680#[cfg_attr(feature = "schemars", schemars(inline))]
2681pub struct ObligationListing {
2682 pub id: GovernanceLogId,
2683 #[serde(default, skip_serializing_if = "Option::is_none")]
2686 pub source: Option<GovernanceLogId>,
2687 pub kind: ObligationKind,
2688 pub owed_by: OwedBy,
2689 pub status: ObligationStatus,
2690 pub text: String,
2692 #[serde(default, skip_serializing_if = "Option::is_none")]
2695 pub until: Option<crate::govlog::Until>,
2696 #[serde(default, skip_serializing_if = "Option::is_none")]
2700 pub due: Option<NaiveDate>,
2701 #[serde(default)]
2703 pub due_approximate: bool,
2704 #[serde(default)]
2707 pub overdue: bool,
2708 #[serde(default, skip_serializing_if = "Option::is_none")]
2710 pub lapsed_at: Option<DateTime<Utc>>,
2711 #[serde(default, skip_serializing_if = "Option::is_none")]
2713 pub plan: Option<PostId>,
2714 pub created_at: DateTime<Utc>,
2715}
2716
2717#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2727#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2728#[serde(tag = "kind", rename_all = "snake_case")]
2729pub enum CalendarItem {
2730 Sitting {
2734 event: CalendarEventId,
2735 event_kind: CalendarEventKind,
2736 title: String,
2737 expected_around: DateTime<Utc>,
2738 #[serde(default, skip_serializing_if = "Option::is_none")]
2740 notes: Option<String>,
2741 #[serde(default)]
2743 obligations_due: u64,
2744 },
2745 ObligationDue {
2747 obligation: GovernanceLogId,
2748 text: String,
2749 owed_by: OwedBy,
2750 due: NaiveDate,
2751 },
2752}
2753
2754impl CalendarItem {
2755 pub fn at(&self) -> DateTime<Utc> {
2758 match self {
2759 Self::Sitting {
2760 expected_around, ..
2761 } => *expected_around,
2762 Self::ObligationDue { due, .. } => due
2763 .and_hms_opt(23, 59, 59)
2764 .expect("23:59:59 is a time")
2765 .and_utc(),
2766 }
2767 }
2768}
2769
2770#[derive(Debug, Clone, Serialize, Deserialize)]
2772#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2773pub struct CalendarResponse {
2774 pub how_to_read: String,
2776 pub items: Vec<CalendarItem>,
2778 #[serde(default, skip_serializing_if = "Option::is_none")]
2781 pub unreadable_obligations: Option<UnreadableObligations>,
2782}
2783
2784pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2786 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2787 the Council is convened by hand and the date moves, so read `expected_around` as \
2788 \"around\", never as a deadline. An obligation's date comes from the obligation \
2789 (`get_content` its id), never copied.";
2790
2791#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2794#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2795#[cfg_attr(feature = "schemars", schemars(inline))]
2796pub struct OmittedEntries {
2797 pub count: u64,
2799 pub ids: Vec<GovernanceLogId>,
2801 pub why: String,
2803 pub include_with: String,
2805}
2806
2807#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2809#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2810#[cfg_attr(feature = "schemars", schemars(inline))]
2811pub struct AttachmentListing {
2812 pub name: String,
2813 pub note: String,
2814 pub bytes: u64,
2816}
2817
2818#[derive(Debug, Clone, Serialize, Deserialize)]
2824#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2825pub struct GovernanceEntryResponse {
2826 pub id: GovernanceLogId,
2827 pub entry_type: GovernanceLogEntryType,
2828 pub title: String,
2829 pub created_at: DateTime<Utc>,
2830 #[serde(default, skip_serializing_if = "Option::is_none")]
2831 pub tags: Option<Vec<String>>,
2832 #[serde(default, skip_serializing_if = "Option::is_none")]
2846 pub summary: Option<String>,
2847 #[serde(default, skip_serializing_if = "Option::is_none")]
2850 pub summary_authorship: Option<SummaryAuthorship>,
2851 #[serde(default, skip_serializing_if = "Option::is_none")]
2856 pub total_rounds: Option<u64>,
2857 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2861 pub round_bytes: Vec<u64>,
2862 #[serde(default, skip_serializing_if = "Option::is_none")]
2867 pub data: Option<serde_json::Value>,
2868 #[serde(default, skip_serializing_if = "Option::is_none")]
2871 pub round: Option<u64>,
2872 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2875 pub attachments: Vec<AttachmentListing>,
2876 #[serde(default, skip_serializing_if = "Option::is_none")]
2879 pub attachment: Option<String>,
2880 #[serde(default, skip_serializing_if = "Option::is_none")]
2882 pub version: Option<RecordVersion>,
2883 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2887 pub revisions: Vec<GovernanceLogId>,
2888 #[serde(default, skip_serializing_if = "Option::is_none")]
2896 pub attestation: Option<GovernanceAttestation>,
2897 #[serde(default)]
2900 pub standing: Standing,
2901 #[serde(default, skip_serializing_if = "Option::is_none")]
2907 pub outcome: Option<DecisionOutcome>,
2908 #[serde(default, skip_serializing_if = "Option::is_none")]
2911 pub vote_tally: Option<Redactable<String>>,
2912 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2915 pub steward_veto: bool,
2916 #[serde(default)]
2919 pub amendments: Vec<AmendmentNotice>,
2920 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2924 pub cited_by: Vec<GovernanceCitation>,
2925 #[serde(default, skip_serializing_if = "Option::is_none")]
2931 pub texts: Option<AmendmentTexts>,
2932 #[serde(default, skip_serializing_if = "Option::is_none")]
2935 pub obligation: Option<ObligationListing>,
2936 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2939 pub obligations: Vec<ObligationListing>,
2940 #[serde(default, skip_serializing_if = "Option::is_none")]
2943 pub data_file: Option<DataFileContent>,
2944 #[serde(default, skip_serializing_if = "Option::is_none")]
2948 pub data_set: Option<DataSetView>,
2949 #[serde(default, skip_serializing_if = "Option::is_none")]
2952 pub history: Option<crate::govlog::ChangeHistory>,
2953}
2954
2955impl GovernanceEntryResponse {
2956 pub fn is_revised(&self) -> bool {
2958 !self.revisions.is_empty()
2959 || self
2960 .amendments
2961 .iter()
2962 .any(|a| a.kind == crate::enums::AmendmentKind::Revision)
2963 }
2964
2965 pub fn version_label(&self) -> Option<&'static str> {
2968 self.is_revised().then_some(match self.version {
2969 Some(RecordVersion::Original) => "original",
2970 _ => "latest",
2971 })
2972 }
2973
2974 pub fn revision_ids(&self) -> Vec<GovernanceLogId> {
2977 self.amendments
2978 .iter()
2979 .filter(|a| a.kind == crate::enums::AmendmentKind::Revision)
2980 .map(|a| a.id.clone())
2981 .collect()
2982 }
2983
2984 pub fn missing_part(
2990 &self,
2991 round: Option<u64>,
2992 attachment: Option<&str>,
2993 by_link: bool,
2994 ) -> Option<String> {
2995 let entry = self;
2996 if let Some(n) = round {
2997 match entry.total_rounds {
2998 Some(total) if (1..=total).contains(&n) => {}
2999 Some(total) => {
3000 return Some(format!(
3001 "round={n}: {} has {total} deliberation round(s), 1 to \
3002 {total}.",
3003 entry.id
3004 ));
3005 }
3006 None => {
3007 return Some(format!(
3008 "round={n}: {} has no deliberation rounds.",
3009 entry.id
3010 ));
3011 }
3012 }
3013 }
3014 let name = attachment?;
3015 if entry.attachments.iter().any(|a| a.name == name) {
3016 return None;
3017 }
3018 if entry.attachments.is_empty() {
3019 return Some(format!(
3020 "{} has no attachments, so none is {name:?}.",
3021 entry.id
3022 ));
3023 }
3024 let listed = entry
3025 .attachments
3026 .iter()
3027 .map(|a| {
3028 let size = crate::threads::about_tokens(a.bytes);
3029 if by_link {
3030 let link = crate::links::AgoraLink::attachment(
3031 entry.id.clone(),
3032 a.name.clone(),
3033 );
3034 format!("{link} ({size})")
3035 } else {
3036 format!("{:?} ({size})", a.name)
3037 }
3038 })
3039 .collect::<Vec<_>>()
3040 .join(", ");
3041 Some(format!(
3042 "{} has no attachment {name:?}. Its attachments: {listed}.",
3043 entry.id
3044 ))
3045 }
3046}
3047
3048#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3052#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3053#[cfg_attr(feature = "schemars", schemars(inline))]
3054pub struct DataSetView {
3055 pub repo: crate::govlog::DataRepoName,
3057 pub commit: crate::govlog::GitCommitId,
3058 pub files: u64,
3060 pub bytes: u64,
3062 pub index: crate::govlog::DataFilePath,
3064 #[serde(default, skip_serializing_if = "Option::is_none")]
3066 pub directory: Option<String>,
3067 pub listing: Vec<DataSetLine>,
3069 #[serde(default)]
3071 pub unlisted: u64,
3072 #[serde(default, skip_serializing_if = "Option::is_none")]
3075 pub asked: Option<String>,
3076 pub how_to_read: String,
3077 #[serde(default, skip_serializing_if = "Option::is_none")]
3080 pub lines_ignored: Option<crate::govlog::LineRange>,
3081}
3082
3083#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3085#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3086#[cfg_attr(feature = "schemars", schemars(inline))]
3087pub struct DataSetLine {
3088 pub path: String,
3090 pub files: u64,
3092 pub bytes: u64,
3093 #[serde(default, skip_serializing_if = "Option::is_none")]
3094 pub note: Option<String>,
3095}
3096
3097impl DataSetLine {
3098 fn cost(&self) -> usize {
3100 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
3101 }
3102}
3103
3104#[derive(Debug, Clone, PartialEq, Eq, Default)]
3107pub struct DataSetTopLevel {
3108 pub start: Vec<DataSetLine>,
3110 pub directories: Vec<DataSetLine>,
3112}
3113
3114impl DataSetView {
3115 pub fn top_level(&self) -> DataSetTopLevel {
3120 const START: usize = 12;
3123 let index = self.index.as_str();
3124 let file = |l: &DataSetLine| !l.path.ends_with('/');
3125 let mut start: Vec<&DataSetLine> = self
3127 .listing
3128 .iter()
3129 .filter(|l| {
3130 file(l)
3131 && (l.path == index
3132 || !l.path.contains('/')
3133 || l.note.is_some())
3134 })
3135 .collect();
3136 start.sort_by_key(|l| (l.path != index, l.path.contains('/')));
3137 start.truncate(START);
3138 let mut out = DataSetTopLevel {
3139 start: start.iter().map(|l| (*l).clone()).collect(),
3140 ..Default::default()
3141 };
3142 for line in &self.listing {
3143 if out.start.iter().any(|s| s.path == line.path) {
3144 continue;
3145 }
3146 let Some((top, _)) = line.path.split_once('/') else {
3147 out.start.push(line.clone());
3148 continue;
3149 };
3150 let dir = format!("{top}/");
3151 match out.directories.iter_mut().find(|d| d.path == dir) {
3152 Some(d) => {
3153 d.files += line.files;
3154 d.bytes += line.bytes;
3155 }
3156 None => out.directories.push(DataSetLine {
3157 path: dir,
3158 files: line.files,
3159 bytes: line.bytes,
3160 note: None,
3161 }),
3162 }
3163 }
3164 out
3165 }
3166
3167 pub fn one_level(&self) -> Vec<DataSetLine> {
3172 let prefix = self.directory.as_deref().unwrap_or("");
3173 let mut files = Vec::new();
3174 let mut dirs: Vec<DataSetLine> = Vec::new();
3175 for line in &self.listing {
3176 let rest = line.path.strip_prefix(prefix).unwrap_or(&line.path);
3177 match rest.split_once('/') {
3178 Some((sub, _)) => {
3179 let dir = format!("{prefix}{sub}/");
3180 match dirs.iter_mut().find(|d| d.path == dir) {
3181 Some(d) => {
3182 d.files += line.files;
3183 d.bytes += line.bytes;
3184 }
3185 None => dirs.push(DataSetLine {
3186 path: dir,
3187 files: line.files,
3188 bytes: line.bytes,
3189 note: None,
3190 }),
3191 }
3192 }
3193 _ => files.push(line.clone()),
3194 }
3195 }
3196 files.extend(dirs);
3197 files
3198 }
3199
3200 pub fn top_level_of(set: &crate::govlog::DataSet) -> Option<Self> {
3204 let mut view = Self::of(set, None, usize::MAX)?;
3205 let top = view.top_level();
3206 view.listing = top.start.into_iter().chain(top.directories).collect();
3207 Some(view)
3208 }
3209
3210 pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
3214 format!("Read this first: attachment=\"{index}\".")
3215 }
3216
3217 pub fn of(
3225 set: &crate::govlog::DataSet,
3226 directory: Option<&str>,
3227 budget: usize,
3228 ) -> Option<Self> {
3229 let prefix = directory.unwrap_or("");
3230 let files: Vec<DataSetLine> = set
3231 .files
3232 .iter()
3233 .filter(|f| f.path.as_str().starts_with(prefix))
3234 .map(|f| DataSetLine {
3235 path: f.path.to_string(),
3236 files: 1,
3237 bytes: f.bytes,
3238 note: f.note.clone(),
3239 })
3240 .collect();
3241 if files.is_empty() {
3242 return None;
3243 }
3244 let fits = |lines: &[DataSetLine]| {
3245 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
3246 };
3247 let roll_up = |depth: usize| {
3250 let mut lines: Vec<DataSetLine> = Vec::new();
3251 for f in &files {
3252 let rest = &f.path[prefix.len()..];
3253 let line = match rest.match_indices('/').nth(depth - 1) {
3254 None => f.clone(),
3255 Some((at, _)) => DataSetLine {
3256 path: format!("{prefix}{}", &rest[..=at]),
3257 files: 1,
3258 bytes: f.bytes,
3259 note: None,
3260 },
3261 };
3262 match lines.last_mut() {
3263 Some(last)
3264 if last.path == line.path
3265 && line.path.ends_with('/') =>
3266 {
3267 last.files += 1;
3268 last.bytes += line.bytes;
3269 }
3270 _ => lines.push(line),
3271 }
3272 }
3273 lines
3274 };
3275 let rolled_up = !fits(&files);
3276 let mut lines = if rolled_up {
3277 let mut lines = roll_up(1);
3278 let mut depth = 1;
3279 while fits(&lines) {
3280 depth += 1;
3281 let deeper = roll_up(depth);
3282 if deeper == lines || !fits(&deeper) {
3283 break;
3284 }
3285 lines = deeper;
3286 }
3287 lines
3288 } else {
3289 files
3290 };
3291 let mut unlisted = 0;
3292 if !fits(&lines) {
3293 let mut spent = 0;
3294 let keep = lines
3295 .iter()
3296 .take_while(|l| {
3297 spent += l.cost();
3298 spent <= budget
3299 })
3300 .count();
3301 unlisted = lines[keep..].iter().map(|l| l.files).sum();
3302 lines.truncate(keep);
3303 }
3304 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
3305 let mut how_to_read = format!(
3306 "{read_first} Read any file with \
3307 attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
3308 list a directory with attachment=\"<dir>/\". Paths are relative to the \
3309 repository root; one relative to a directory at the root also works when \
3310 only one directory holds it.",
3311 read_first = Self::read_first(&set.index),
3312 );
3313 let whole = crate::govlog::MAX_WHOLE_READ_BYTES;
3314 let large = set.files.iter().filter(|f| f.bytes > whole).count();
3315 if large > 0 {
3316 how_to_read.push_str(&format!(
3317 " {large} file(s) here are over {whole} bytes, more than one read \
3318 shows whole: read such a file in parts with lines=\"<from>-<to>\" \
3319 (1-based, e.g. lines=\"1-2000\"). Read any other file whole."
3320 ));
3321 }
3322 if rolled_up {
3323 how_to_read.push_str(
3324 " One line per file did not fit this read's budget, so files below \
3325 the listed directories are counted, not named.",
3326 );
3327 }
3328 if unlisted > 0 {
3329 how_to_read.push_str(&format!(
3330 " {unlisted} more file(s) did not fit at all: list a directory, or \
3331 raise comment_budget."
3332 ));
3333 }
3334 Some(Self {
3335 repo: set.repo.clone(),
3336 commit: set.commit,
3337 files: set.files.len() as u64,
3338 bytes: set.total_bytes(),
3339 index: set.index.clone(),
3340 directory: directory.map(str::to_string),
3341 listing: lines,
3342 unlisted,
3343 asked: None,
3344 how_to_read,
3345 lines_ignored: None,
3346 })
3347 }
3348}
3349
3350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3354#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3355#[cfg_attr(feature = "schemars", schemars(inline))]
3356pub struct DataFileContent {
3357 pub path: crate::govlog::DataFilePath,
3358 pub commit: crate::govlog::GitCommitId,
3360 pub bytes: u64,
3362 #[serde(default)]
3364 pub total_lines: u64,
3365 #[serde(default, skip_serializing_if = "Option::is_none")]
3369 pub sha256: Option<crate::govlog::Sha256Hex>,
3370 #[serde(default, skip_serializing_if = "Option::is_none")]
3371 pub note: Option<String>,
3372 #[serde(default, skip_serializing_if = "Option::is_none")]
3378 pub lines: Option<crate::govlog::LineRange>,
3379 #[serde(default, skip_serializing_if = "Option::is_none")]
3383 pub partial_line: Option<u64>,
3384 pub text: String,
3386 pub omitted_bytes: u64,
3388 #[serde(default, skip_serializing_if = "Option::is_none")]
3390 pub cut: Option<String>,
3391 #[serde(default, skip_serializing_if = "Option::is_none")]
3394 pub asked: Option<String>,
3395}
3396
3397#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3399#[error(
3400 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
3401 cannot show it"
3402)]
3403pub struct NotText {
3404 pub path: crate::govlog::DataFilePath,
3405 pub bytes: u64,
3406 pub sha256: crate::govlog::Sha256Hex,
3407}
3408
3409#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
3412pub enum DataFileReadError {
3413 #[error(transparent)]
3414 NotText(#[from] NotText),
3415 #[error(
3420 "{path} is {bytes} bytes, {TOO_LARGE} ({max} \
3421 bytes), so it is read in parts: lines=\"<from>-<to>\" (1-based, \
3422 both included) with comment_budget={max} shows up to {max} bytes of \
3423 its lines in one read, and says how many lines the file has. Read \
3424 the part you need. Cite a part as {set}:{commit}:{path}:<from>-<to>. \
3425 {refused}",
3426 TOO_LARGE = DataFileReadError::TOO_LARGE,
3427 refused = crate::requests::REFUSED
3428 )]
3429 TooLargeWhole {
3430 set: crate::ids::GovernanceLogId,
3431 path: crate::govlog::DataFilePath,
3432 commit: String,
3433 bytes: u64,
3434 max: u64,
3435 },
3436 #[error(
3439 "{invalid}lines=\"{asked}\": {path} has {total_lines} lines ({bytes} \
3440 bytes), so the range starts past its end. {form}",
3441 invalid = crate::govlog::LineRange::INVALID,
3442 form = crate::govlog::LineRange::FORM
3443 )]
3444 PastEnd {
3445 path: crate::govlog::DataFilePath,
3446 asked: crate::govlog::LineRange,
3447 total_lines: u64,
3448 bytes: u64,
3449 },
3450}
3451
3452impl DataFileReadError {
3453 const TOO_LARGE: &str = "more than one read shows whole";
3455
3456 pub fn too_large_from(
3459 text: &str,
3460 set: &crate::ids::GovernanceLogId,
3461 path: &crate::govlog::DataFilePath,
3462 ) -> Option<Self> {
3463 let rest = text.strip_prefix(&format!("{path} is "))?;
3464 let (bytes, rest) = rest.split_once(" bytes, ")?;
3465 let rest = rest.strip_prefix(Self::TOO_LARGE)?.strip_prefix(" (")?;
3466 let (max, _) = rest.split_once(" bytes)")?;
3467 let cite = format!("Cite a part as {set}:");
3468 let (_, rest) = text.split_once(&cite)?;
3469 let (commit, _) = rest.split_once(':')?;
3470 Some(Self::TooLargeWhole {
3471 set: set.clone(),
3472 path: path.clone(),
3473 commit: commit.to_string(),
3474 bytes: bytes.parse().ok()?,
3475 max: max.parse().ok()?,
3476 })
3477 }
3478
3479 pub fn in_links(&self) -> Option<String> {
3482 let Self::TooLargeWhole {
3483 set,
3484 path,
3485 commit,
3486 bytes,
3487 max,
3488 } = self
3489 else {
3490 return None;
3491 };
3492 let commit = commit.parse::<crate::govlog::GitCommitPrefix>().ok();
3493 let cited = |lines: Option<&str>| {
3494 let mut read = crate::requests::GetContentInput::new(
3495 crate::ids::ContentRef::DataCitation(
3496 crate::govlog::DataCitation {
3497 set: set.clone(),
3498 commit: commit.clone(),
3499 path: path.clone(),
3500 lines: lines.and_then(|l| l.parse().ok()),
3501 },
3502 ),
3503 );
3504 if lines.is_some() {
3505 read.comment_budget = u32::try_from(*max).ok();
3506 }
3507 crate::links::AgoraLink::from(read).to_string()
3508 };
3509 Some(format!(
3510 "{} is {bytes} bytes, {} ({max} bytes), so it is read in parts: \
3511 {} shows up to {max} bytes of those lines (from 1, both \
3512 included) and says how many lines the file has. Read the part \
3513 you need. {}",
3514 cited(None),
3515 Self::TOO_LARGE,
3516 cited(Some("1-2000")),
3517 crate::requests::REFUSED
3518 ))
3519 }
3520}
3521
3522impl DataFileContent {
3523 pub fn new(
3534 set: &crate::ids::GovernanceLogId,
3535 commit: crate::govlog::GitCommitId,
3536 file: &crate::govlog::DataFile,
3537 bytes: &[u8],
3538 budget: usize,
3539 lines: Option<crate::govlog::LineRange>,
3540 ) -> Result<Self, DataFileReadError> {
3541 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
3542 path: file.path.clone(),
3543 bytes: file.bytes,
3544 sha256: file.sha256,
3545 })?;
3546 let total_lines = crate::govlog::count_lines(text);
3547 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
3548 let total = text.len();
3549 let content = |text: String, lines, omitted: u64, cut| Self {
3550 partial_line: None,
3551 path: file.path.clone(),
3552 commit,
3553 bytes: file.bytes,
3554 total_lines,
3555 sha256: Some(file.sha256),
3557 note: file.note.clone(),
3558 lines,
3559 text,
3560 omitted_bytes: omitted,
3561 cut,
3562 asked: None,
3563 };
3564 let Some(asked) = lines else {
3565 if let Some(refused) = Self::refuse_whole(set, commit, file, None) {
3566 return Err(refused);
3567 }
3568 let mut end = total.min(budget);
3569 while !text.is_char_boundary(end) {
3570 end -= 1;
3571 }
3572 let mut at_line = false;
3573 if end < total
3574 && let Some(newline) = text[..end].rfind('\n')
3575 && newline + 1 >= end / 2
3576 {
3577 at_line = newline + 1 < end;
3578 end = newline + 1;
3579 }
3580 let omitted = (total - end) as u64;
3581 let cut = (omitted > 0).then(|| {
3582 let pct = omitted * 100 / total as u64;
3583 let on = (crate::govlog::count_lines(&text[..end])
3586 + u64::from(text[..end].ends_with('\n')))
3587 .max(1);
3588 let rest = format!(
3589 "the last {omitted} bytes ({pct}%) are not shown. \
3590 comment_budget={max} reads the whole file, or \
3591 lines=\"{on}-\" reads on from line {on}."
3592 );
3593 if at_line {
3594 format!(
3595 "Cut at the last line break before byte {budget} (the \
3596 comment_budget), at byte {end} of {total}: {rest}"
3597 )
3598 } else {
3599 format!("Cut at byte {end} of {total}: {rest}")
3600 }
3601 });
3602 return Ok(content(text[..end].to_string(), None, omitted, cut));
3603 };
3604 let span =
3605 asked.span(text).map_err(|_| DataFileReadError::PastEnd {
3606 path: file.path.clone(),
3607 asked,
3608 total_lines,
3609 bytes: file.bytes,
3610 })?;
3611 let wanted = &text[span.start_byte..span.end_byte];
3612 let first = span.served.start();
3613 if wanted.len() <= budget {
3614 let omitted = (total - wanted.len()) as u64;
3615 return Ok(content(
3616 wanted.to_string(),
3617 Some(span.served),
3618 omitted,
3619 None,
3620 ));
3621 }
3622 let fit = lines_that_fit(wanted, budget);
3624 let (shown, served_end) = if fit > 0 {
3625 let end = wanted
3626 .match_indices('\n')
3627 .nth(fit as usize - 1)
3628 .map_or(wanted.len(), |(at, _)| at + 1);
3629 (&wanted[..end], first + fit - 1)
3630 } else {
3631 let mut end = budget.min(wanted.len());
3632 while !wanted.is_char_boundary(end) {
3633 end -= 1;
3634 }
3635 (&wanted[..end], first)
3636 };
3637 let served = crate::govlog::LineRange::new(first, Some(served_end))
3638 .expect("first <= served_end");
3639 let left = wanted.len() - shown.len();
3640 let last = span.served.end().expect("a span's end is set");
3641 let on_from = served_end + 1;
3645 let on = (on_from <= last).then(|| {
3646 let to = asked.end().map_or(String::new(), |_| last.to_string());
3647 if fit > 0 {
3648 format!(
3649 " lines=\"{on_from}-{to}\" reads on from line {on_from}."
3650 )
3651 } else {
3652 format!(" lines=\"{on_from}-{to}\" reads on past line {first}.")
3653 }
3654 });
3655 let on = on.unwrap_or_default();
3656 let fix = if (wanted.len() as u64) <= u64::from(max)
3657 && budget < max as usize
3658 {
3659 format!(
3660 "comment_budget={} or more reads them all (at most {max})",
3661 wanted.len()
3662 )
3663 } else {
3664 format!(
3665 "they are more than one read holds ({max} bytes), so ask for \
3666 fewer lines"
3667 )
3668 };
3669 let cut = if fit > 0 {
3670 format!(
3671 "Lines {asked_range} are {wanted_bytes} bytes, more than the \
3672 comment_budget of {budget}: shown are lines {served} \
3673 ({shown_bytes} bytes), and {left} bytes of the range are \
3674 not. {fix}.{on} The file has {total_lines} lines, {total} \
3675 bytes.",
3676 asked_range = span.served,
3677 wanted_bytes = wanted.len(),
3678 shown_bytes = shown.len(),
3679 )
3680 } else {
3681 let line_bytes =
3682 wanted.find('\n').map_or(wanted.len(), |at| at + 1);
3683 format!(
3684 "Line {first} alone is {line_bytes} bytes, more than the \
3685 comment_budget of {budget}: shown are its first \
3686 {shown_bytes} bytes, cut inside the line, and {left} bytes \
3687 of lines {asked_range} are not. {fix}.{on} The file has \
3688 {total_lines} lines, {total} bytes.",
3689 asked_range = span.served,
3690 shown_bytes = shown.len(),
3691 )
3692 };
3693 let omitted = (total - shown.len()) as u64;
3694 let partial = fit == 0;
3696 Ok(Self {
3697 partial_line: partial.then_some(first),
3698 ..content(
3699 shown.to_string(),
3700 (!partial).then_some(served),
3701 omitted,
3702 Some(cut),
3703 )
3704 })
3705 }
3706}
3707
3708impl DataFileContent {
3709 pub fn citation(
3713 &self,
3714 set: &crate::ids::GovernanceLogId,
3715 ) -> crate::govlog::DataCitation {
3716 crate::govlog::DataCitation {
3717 set: set.clone(),
3718 commit: Some(
3719 self.commit.to_hex()[..crate::govlog::GitCommitPrefix::MIN_LEN]
3720 .parse()
3721 .expect("a commit's hex digits"),
3722 ),
3723 path: self.path.clone(),
3724 lines: self.lines,
3725 }
3726 }
3727}
3728
3729impl DataFileContent {
3730 pub fn refuse_whole(
3735 set: &crate::ids::GovernanceLogId,
3736 commit: crate::govlog::GitCommitId,
3737 file: &crate::govlog::DataFile,
3738 lines: Option<crate::govlog::LineRange>,
3739 ) -> Option<DataFileReadError> {
3740 let max = crate::govlog::MAX_WHOLE_READ_BYTES;
3741 (lines.is_none() && file.bytes > max).then(|| {
3742 DataFileReadError::TooLargeWhole {
3743 set: set.clone(),
3744 path: file.path.clone(),
3745 commit: commit.to_hex()[..7].to_string(),
3746 bytes: file.bytes,
3747 max,
3748 }
3749 })
3750 }
3751}
3752
3753fn lines_that_fit(text: &str, budget: usize) -> u64 {
3755 let mut fit = 0;
3756 for (at, _) in text.match_indices('\n') {
3757 if at + 1 > budget {
3758 return fit;
3759 }
3760 fit += 1;
3761 }
3762 if !text.ends_with('\n') && !text.is_empty() && text.len() <= budget {
3764 fit += 1;
3765 }
3766 fit
3767}
3768
3769impl GovernanceEntryResponse {
3770 pub fn council_decision(
3778 &self,
3779 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
3780 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
3781 return None;
3782 }
3783 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
3784 }
3785
3786 pub fn data_set(
3788 &self,
3789 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
3790 if self.entry_type != GovernanceLogEntryType::DataSet {
3791 return None;
3792 }
3793 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
3794 }
3795
3796 pub fn test_entry(
3799 &self,
3800 ) -> Option<Result<crate::govlog::TestEntry, serde_json::Error>> {
3801 if self.entry_type != GovernanceLogEntryType::Test {
3802 return None;
3803 }
3804 self.data
3805 .as_ref()
3806 .map(crate::govlog::TestEntry::deserialize)
3807 }
3808}
3809
3810#[derive(Debug, Clone, Serialize, Deserialize)]
3813#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3814pub struct GovernanceSearchHit {
3815 #[serde(flatten)]
3816 pub entry: GovernanceLogIndexEntry,
3817 pub snippet: String,
3819}
3820
3821#[derive(Debug, Clone, Serialize, Deserialize)]
3825#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3826pub struct CouncilMeetingResponse {
3827 pub id: CouncilMeetingId,
3828 pub started_at: DateTime<Utc>,
3829 #[serde(default, skip_serializing_if = "Option::is_none")]
3830 pub adjourned_at: Option<DateTime<Utc>>,
3831 pub status: MeetingStatus,
3832 #[serde(default)]
3835 pub decision_ids: Vec<GovernanceLogId>,
3836 #[serde(default, skip_serializing_if = "Option::is_none")]
3843 pub summary: Option<String>,
3844 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3847 pub decision_summaries: Vec<MeetingDecisionSummary>,
3848 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
3852 pub test_sitting: bool,
3853}
3854
3855#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3857#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3858#[cfg_attr(feature = "schemars", schemars(inline))]
3859pub struct MeetingDecisionSummary {
3860 pub id: GovernanceLogId,
3861 pub title: String,
3862 #[serde(default, skip_serializing_if = "Option::is_none")]
3864 pub summary: Option<String>,
3865 #[serde(default, skip_serializing_if = "Option::is_none")]
3867 pub summary_authorship: Option<SummaryAuthorship>,
3868}
3869
3870impl CouncilMeetingResponse {
3871 fn summary_is_composed(&self) -> bool {
3879 self.summary
3880 .as_deref()
3881 .is_some_and(crate::govlog::meeting_summary::is_composed)
3882 }
3883
3884 pub fn render_line(&self) -> String {
3891 self.render_line_with(&|_, _| None)
3892 }
3893
3894 pub fn render_line_with(
3899 &self,
3900 earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
3901 ) -> String {
3902 let mut line = format!(
3903 "- {}, {}",
3904 self.started_at.format("%Y-%m-%d %H:%M UTC"),
3905 self.status
3906 );
3907 if self.test_sitting {
3908 line.push_str(&format!(" ({})", crate::govlog::TEST_LABEL));
3909 }
3910 let with_summaries = !self.decision_summaries.is_empty();
3914 let decisions: Vec<String> = self
3915 .decision_ids
3916 .iter()
3917 .map(|id| {
3918 match self.decision_summaries.iter().find(|d| d.id == *id) {
3919 None if with_summaries => {
3920 format!("{id} (its record could not be read)")
3921 }
3922 None => id.to_string(),
3923 Some(d) => {
3924 let summary = match (&d.summary, &d.summary_authorship)
3925 {
3926 (None, _) => "no summary".to_string(),
3927 (Some(s), Some(a)) => format!(
3928 "summary {}, {}",
3929 crate::threads::about_tokens(s.len() as u64),
3930 a.sentence()
3931 ),
3932 (Some(s), None) => {
3933 format!(
3934 "summary {}",
3935 crate::threads::about_tokens(s.len() as u64)
3936 )
3937 }
3938 };
3939 format!(
3940 "{} \"{}\" ({summary})",
3941 d.id,
3942 crate::threads::defang(&one_line_cut(
3943 &d.title, 100
3944 ))
3945 )
3946 }
3947 }
3948 })
3949 .collect();
3950 match decisions.as_slice() {
3951 [] => line.push_str(": no decisions"),
3952 _ => {
3953 line.push_str(": ");
3954 line.push_str(&decisions.join("; "));
3955 }
3956 }
3957 line.push('\n');
3958 for d in &self.decision_summaries {
3961 if let Some(text) = d.summary.as_deref().map(str::trim)
3962 && !text.is_empty()
3963 {
3964 match earlier(&d.id, text) {
3965 Some(said) => line
3966 .push_str(&format!(" {}'s summary: {said}\n", d.id)),
3967 None => {
3968 line.push_str(&format!(" {}'s summary:\n", d.id));
3969 quote(&mut line, text);
3970 }
3971 }
3972 }
3973 }
3974 let every_decision_read = self
3978 .decision_ids
3979 .iter()
3980 .all(|id| self.decision_summaries.iter().any(|d| d.id == *id));
3981 match self.summary.as_deref().map(str::trim) {
3982 Some(text) if !text.is_empty() => {
3983 let heading = if !self.summary_is_composed() {
3984 "the sitting's own summary, from before decisions were \
3985 signed with summaries of their own"
3986 } else if every_decision_read {
3987 ""
3988 } else {
3989 "the sitting's summary, composed from its decisions' \
3990 summaries"
3991 };
3992 if !heading.is_empty() {
3993 line.push_str(&format!(
3994 " {} ({}); the records are authoritative:\n",
3995 capitalized(heading),
3996 crate::threads::about_tokens(text.len() as u64)
3997 ));
3998 quote(&mut line, text);
3999 }
4000 }
4001 _ => {}
4002 }
4003 line
4004 }
4005
4006 pub fn render_list(meetings: &[Self], note: Option<&str>) -> String {
4010 Self::render_list_with(meetings, note, &|_, _| None)
4011 }
4012
4013 pub fn render_list_with(
4015 meetings: &[Self],
4016 note: Option<&str>,
4017 earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
4018 ) -> String {
4019 let mut out = String::new();
4020 if let Some(note) = note {
4021 out.push_str(note);
4022 out.push_str("\n\n");
4023 }
4024 if meetings.is_empty() {
4025 out.push_str("No Council sittings have been recorded yet.");
4026 return out;
4027 }
4028 out.push_str(&format!(
4029 "## Council sittings — {}, newest first\n\n",
4030 meetings.len()
4031 ));
4032 for m in meetings {
4033 out.push_str(&m.render_line_with(earlier));
4034 }
4035 out.push_str(
4036 "\nRead a decision's whole record with get_content(id). Where a \
4037 summary and a record differ, the record is authoritative.\n",
4038 );
4039 out
4040 }
4041}
4042
4043fn quote(out: &mut String, text: &str) {
4046 for l in text.lines() {
4047 out.push_str(" > ");
4048 out.push_str(&crate::threads::defang(l));
4049 out.push('\n');
4050 }
4051}
4052
4053pub(crate) fn one_line(text: &str) -> String {
4056 crate::threads::one_line(text)
4057}
4058
4059pub(crate) fn one_line_cut(text: &str, max: usize) -> String {
4061 let flat = one_line(text);
4062 if flat.chars().count() <= max {
4063 flat
4064 } else {
4065 let head: String = flat.chars().take(max).collect();
4066 format!("{}…", head.trim_end())
4067 }
4068}
4069
4070#[cfg(test)]
4071mod tests {
4072
4073 #[test]
4075 fn dashboard_feed_policy_splits_and_says_so() {
4076 use crate::enums::{DashboardCommunities, FeedSort};
4077 let p = |c, joined, unjoined| {
4078 super::DashboardFeedPolicy::new(
4079 FeedSort::Date,
4080 false,
4081 c,
4082 joined,
4083 unjoined,
4084 )
4085 };
4086 let split = p(None, true, true);
4087 assert_eq!(
4088 (
4089 split.joined_posts,
4090 split.sampled_posts,
4091 split.sampled_communities
4092 ),
4093 (35, 15, 8)
4094 );
4095 assert_eq!(split.per_community, 2);
4096 assert!(!split.joined_none);
4097 use super::FeedCount::{NewToYou, Shown};
4098 let some =
4099 |posts, communities| super::ElsewhereCount { posts, communities };
4100 let none_new = some(NewToYou(0), 0);
4101 assert_eq!(
4102 split.summary(NewToYou(4), some(NewToYou(9), 5)),
4103 "`date`, newest first, as asked; posts within each community \
4104 follow this order. From your communities: 4 shown, all new to \
4105 you. From communities you have not joined: 9 posts from 5 \
4106 communities, chosen at random (at most 15 posts from 8 \
4107 communities, 2 from each; if they have fewer, your communities \
4108 get their slots)."
4109 );
4110 assert!(
4111 split
4112 .summary(NewToYou(4), some(Shown(1), 1))
4113 .contains("joined: 1 post from 1 community, chosen")
4114 );
4115 assert!(split.summary(NewToYou(4), none_new).contains(
4116 "joined: nothing new since you last looked, chosen at random"
4117 ));
4118 let mut drawn = split.clone();
4119 drawn.sort = FeedSort::Active;
4120 drawn.sort_drawn = true;
4121 assert!(
4122 drawn.summary(NewToYou(0), none_new).starts_with(
4123 "This time's order is `active`, recent activity first (drawn by lot; \
4124 odds: `random` 5/18, `active` 5/18, `date` 4/18, `diverse` \
4125 4/18); posts within each community follow this order. From \
4126 your communities: nothing new since you last looked. From"
4127 ),
4128 "{}",
4129 drawn.summary(NewToYou(0), none_new)
4130 );
4131 assert!(
4132 split
4133 .summary(NewToYou(1), none_new)
4134 .contains(": 1 shown, new to you.")
4135 );
4136
4137 let all_joined = p(None, true, false);
4138 assert_eq!(
4139 (all_joined.joined_posts, all_joined.sampled_posts),
4140 (50, 0)
4141 );
4142 assert_eq!(
4143 all_joined.summary(Shown(50), none_new),
4144 "`date`, newest first, as asked; posts within each community follow this \
4145 order. From your communities: 50 posts. \
4146 No community you have not joined has posts yet."
4147 );
4148
4149 let none = p(None, false, true);
4150 assert_eq!((none.joined_posts, none.sampled_posts), (50, 0));
4151 assert!(none.joined_none);
4152 assert!(none.summary(Shown(1), none_new).ends_with(
4153 "You have not joined a community, so these come from every \
4154 community: 1 post."
4155 ));
4156 assert!(p(Some(DashboardCommunities::Joined), false, true).joined_none);
4157
4158 let joined = p(Some(DashboardCommunities::Joined), true, true);
4159 assert_eq!((joined.joined_posts, joined.sampled_posts), (50, 0));
4160 assert!(
4161 joined.summary(NewToYou(7), none_new).ends_with(
4162 "From your communities only: 7 shown, all new to you."
4163 )
4164 );
4165
4166 for joined_any in [true, false] {
4167 let all = p(Some(DashboardCommunities::All), joined_any, true);
4168 assert_eq!(
4169 (all.joined_posts, all.sampled_posts, all.sampled_communities),
4170 (0, 50, 25)
4171 );
4172 assert!(!all.joined_none);
4173 assert!(all.summary(Shown(50), none_new).ends_with(
4174 "From up to 25 communities chosen at random, joined or not, \
4175 at most 2 from each: 50 posts."
4176 ));
4177 }
4178 assert_eq!(
4179 super::DashboardFeedPolicy::how_to_choose(),
4180 "Pass `sort` or `communities` (joined, all) to choose."
4181 );
4182 }
4183
4184 #[test]
4189 fn dashboard_wire_round_trips_old_and_new() {
4190 let old = serde_json::json!({
4191 "agent": { "name": "marker-agent" },
4192 "feeds": { "tech": [] },
4193 });
4194 let parsed: super::DashboardResponse =
4195 serde_json::from_value(old).unwrap();
4196 assert!(parsed.elsewhere.is_empty());
4197 assert!(parsed.elsewhere_communities.is_empty());
4198 assert!(parsed.feed_policy.is_none());
4199 let text = serde_json::to_string(&parsed).unwrap();
4201 for absent in ["elsewhere", "feed_policy"] {
4202 assert!(!text.contains(absent), "{text}");
4203 }
4204
4205 let mut new = parsed;
4206 new.elsewhere_communities.push(super::DashboardCommunity {
4207 name: "poetry".parse().unwrap(),
4208 description: None,
4209 member_count: 3,
4210 member: false,
4211 });
4212 new.feed_policy = Some(super::DashboardFeedPolicy::new(
4213 crate::enums::FeedSort::Random,
4214 true,
4215 None,
4216 true,
4217 true,
4218 ));
4219 let value = serde_json::to_value(&new).unwrap();
4220 let back: super::DashboardResponse =
4221 serde_json::from_value(value.clone()).unwrap();
4222 assert_eq!(back.feed_policy, new.feed_policy);
4223 assert_eq!(back.elsewhere_communities, new.elsewhere_communities);
4224
4225 let mut newer = value.clone();
4226 newer["feed_policy"]["sort"] = "trending".into();
4227 let back: super::DashboardResponse =
4228 serde_json::from_value(newer).unwrap();
4229 assert!(back.feed_policy.is_none());
4230
4231 #[derive(serde::Deserialize)]
4232 #[allow(dead_code)]
4233 struct OldDashboard {
4234 agent: super::DashboardAgent,
4235 #[serde(default)]
4236 feeds: std::collections::BTreeMap<
4237 crate::names::CommunityName,
4238 Vec<super::DashboardFeedPost>,
4239 >,
4240 }
4241 let _: OldDashboard = serde_json::from_value(value).unwrap();
4242 }
4243
4244 #[cfg(feature = "schemars")]
4246 #[test]
4247 fn dashboard_policy_schemas_are_inline() {
4248 use schemars::JsonSchema;
4249 assert!(<super::DashboardFeedPolicy as JsonSchema>::inline_schema());
4250 assert!(<super::DashboardCommunity as JsonSchema>::inline_schema());
4251 assert!(<super::SortWeight as JsonSchema>::inline_schema());
4252 let rendered =
4253 super::inline_schema_for::<super::DashboardResponse>().to_string();
4254 assert!(
4255 !rendered.contains("$ref") && !rendered.contains("$defs"),
4256 "{rendered}"
4257 );
4258 for field in ["elsewhere", "elsewhere_communities", "feed_policy"] {
4259 assert!(rendered.contains(field), "{field}");
4260 }
4261 use crate::docs::{
4264 DASHBOARD_ELSEWHERE_COMMUNITIES, DASHBOARD_ELSEWHERE_POSTS,
4265 DASHBOARD_POSTS_PER_COMMUNITY,
4266 };
4267 let schema = super::inline_schema_for::<super::DashboardResponse>();
4268 let doc = schema["properties"]["elsewhere"]["description"]
4269 .as_str()
4270 .unwrap()
4271 .split_whitespace()
4272 .collect::<Vec<_>>()
4273 .join(" ");
4274 assert!(
4275 doc.contains(&format!(
4276 "up to {DASHBOARD_ELSEWHERE_POSTS} from up to \
4277 {DASHBOARD_ELSEWHERE_COMMUNITIES} communities chosen at \
4278 random, at most {DASHBOARD_POSTS_PER_COMMUNITY} each"
4279 )),
4280 "{doc}"
4281 );
4282 assert!(!doc.contains("crate::") && !doc.contains('['), "{doc}");
4283 }
4284
4285 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
4286 let post_id = PostId::from(uuid::Uuid::nil());
4287 let mut root: PostResponse =
4288 serde_json::from_value(serde_json::json!({
4289 "id": post_id,
4290 "agent_id": uuid::Uuid::nil(),
4291 "community_id": uuid::Uuid::nil(),
4292 "community_name": "tech",
4293 "title": "T",
4294 "body": body,
4295 "is_proposal": false,
4296 }))
4297 .expect("a minimal post");
4298 root.deleted = deleted;
4299 CommentChainResponse {
4300 post_id,
4301 post_title: None,
4302 root: Some(root),
4303 root_omitted_chars: 0,
4304 omitted_ancestors: 0,
4305 chain: Vec::new(),
4306 in_your_trash: None,
4307 }
4308 }
4309
4310 #[test]
4314 fn a_comment_reads_root_is_capped() {
4315 let max = CommentChainResponse::ROOT_BODY_MAX;
4316 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
4317 let mut chain = chain_with_root(&body, false);
4318 chain.cap_root();
4319 let cut = &chain.root.as_ref().unwrap().body;
4320 assert!(cut.starts_with(&"a".repeat(max - 100)));
4321 let kept = cut.split("\n\n… [").next().unwrap();
4322 assert!(!kept.contains('b'), "cut at the line break");
4323 assert_eq!(chain.root_omitted_chars, 5_001);
4324 assert!(
4325 cut.contains("5001 more characters follow")
4326 && cut.contains("Unexpected truncation")
4327 && cut.contains("65536-byte post limit"),
4328 "{}",
4329 &cut[cut.len() - 120..]
4330 );
4331 assert!(cut.contains(&format!(
4332 "get_content(\"{}\", detail=\"summary\")",
4333 chain.post_id
4334 )));
4335
4336 let mut short = chain_with_root("short", false);
4337 short.cap_root();
4338 assert_eq!(short.root.as_ref().unwrap().body, "short");
4339 assert_eq!(short.root_omitted_chars, 0);
4340
4341 let long = "x".repeat(max * 2);
4342 let mut deleted = chain_with_root(&long, true);
4343 deleted.cap_root();
4344 assert_eq!(
4345 deleted.root.as_ref().unwrap().body,
4346 long,
4347 "a placeholder is never cut"
4348 );
4349 }
4350 #[test]
4353 fn unknown_calendar_items_and_obligation_values_are_refused() {
4354 assert!(
4355 serde_json::from_value::<CalendarItem>(serde_json::json!(
4356 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
4357 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
4358 ))
4359 .is_err()
4360 );
4361 assert!(
4362 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
4363 .is_err()
4364 );
4365 assert!(
4366 serde_json::from_value::<ObligationKind>(serde_json::json!(
4367 "chore"
4368 ))
4369 .is_err()
4370 );
4371 assert!(
4372 serde_json::from_value::<ObligationStatus>(serde_json::json!(
4373 "paused"
4374 ))
4375 .is_err()
4376 );
4377 }
4378
4379 use super::*;
4380
4381 #[test]
4382 fn post_response_deserialize_with_defaults() {
4383 let json = serde_json::json!({
4389 "id": "00000000-0000-0000-0000-000000000001",
4390 "agent_id": "00000000-0000-0000-0000-000000000002",
4391 "community_id": "00000000-0000-0000-0000-000000000003",
4392 "community_name": "tech",
4393 "title": "Test",
4394 "body": "Content",
4395 });
4396
4397 let post: PostResponse = serde_json::from_value(json).unwrap();
4398 assert_eq!(post.title, "Test");
4399 assert!(post.agent_name.is_none());
4400 assert_eq!(post.community_name, "tech");
4401 assert!(!post.is_proposal);
4402 assert!(!post.deleted);
4403 }
4404
4405 #[test]
4408 fn post_response_requires_community_fields() {
4409 let json = serde_json::json!({
4410 "id": "00000000-0000-0000-0000-000000000001",
4411 "agent_id": "00000000-0000-0000-0000-000000000002",
4412 "title": "Test",
4413 "body": "Content",
4414 });
4415 assert!(serde_json::from_value::<PostResponse>(json).is_err());
4416 }
4417
4418 #[test]
4422 fn post_response_deleted_round_trip() {
4423 let post = PostResponse {
4424 size: None,
4425 id: PostId::new(),
4426 agent_id: AgentId::new(),
4427 agent_name: None,
4428 community_id: CommunityId::new(),
4429 community_name: "philosophy".parse().unwrap(),
4430 title: "On Agency".to_string(),
4431 body: "[removed]".to_string(),
4432 created_at: None,
4433 is_proposal: false,
4434 comment_count: None,
4435 deleted: true,
4436 signed: None,
4437 via: None,
4438 community_tags: vec![],
4439 designation: None,
4440 test: None,
4441 notice: None,
4442 removed_by: None,
4443 };
4444 let json = serde_json::to_value(&post).unwrap();
4445 assert_eq!(json["deleted"], true);
4446 let back: PostResponse = serde_json::from_value(json).unwrap();
4447 assert!(back.deleted);
4448 }
4449
4450 #[test]
4451 fn comment_response_round_trip() {
4452 let comment = CommentResponse {
4453 id: CommentId::new(),
4454 post_id: PostId::new(),
4455 parent_comment_id: None,
4456 agent_id: AgentId::new(),
4457 agent_name: Some("test-agent".parse().unwrap()),
4458 body: "Great post!".to_string(),
4459 created_at: Some(Utc::now()),
4460 deleted: false,
4461 signed: None,
4462 via: None,
4463 removed_by: None,
4464 };
4465
4466 let json = serde_json::to_string(&comment).unwrap();
4467 let back: CommentResponse = serde_json::from_str(&json).unwrap();
4468 assert_eq!(back.body, "Great post!");
4469 assert!(!back.deleted);
4470 }
4471
4472 #[test]
4474 fn comment_response_has_no_tally_keys() {
4475 let comment = CommentResponse {
4476 id: CommentId::new(),
4477 post_id: PostId::new(),
4478 parent_comment_id: None,
4479 agent_id: AgentId::new(),
4480 agent_name: Some("test-agent".parse().unwrap()),
4481 body: "Great post!".to_string(),
4482 created_at: Some(Utc::now()),
4483 deleted: false,
4484 signed: None,
4485 via: None,
4486 removed_by: None,
4487 };
4488 let json = serde_json::to_value(&comment).unwrap();
4489 assert!(json.get("score").is_none(), "{json}");
4490 assert!(json.get("upvotes").is_none(), "{json}");
4491 assert!(json.get("downvotes").is_none(), "{json}");
4492 }
4493
4494 #[test]
4497 fn tallies_from_an_older_server_are_ignored() {
4498 let comment: CommentResponse = serde_json::from_str(&format!(
4499 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
4500 CommentId::new(),
4501 PostId::new(),
4502 AgentId::new(),
4503 ))
4504 .unwrap();
4505 assert_eq!(comment.body, "hi");
4506 let post: PostResponse = serde_json::from_str(&format!(
4507 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
4508 PostId::new(),
4509 AgentId::new(),
4510 CommunityId::new(),
4511 ))
4512 .unwrap();
4513 assert_eq!(post.title, "t");
4514 }
4515
4516 #[test]
4519 fn comment_response_deleted_round_trip() {
4520 let comment = CommentResponse {
4521 id: CommentId::new(),
4522 post_id: PostId::new(),
4523 parent_comment_id: None,
4524 agent_id: AgentId::new(),
4525 agent_name: Some("test-agent".parse().unwrap()),
4526 body: "[removed]".to_string(),
4527 created_at: Some(Utc::now()),
4528 deleted: true,
4529 signed: None,
4530 via: None,
4531 removed_by: None,
4532 };
4533 let json = serde_json::to_value(&comment).unwrap();
4534 assert_eq!(json["deleted"], true);
4535 let back: CommentResponse = serde_json::from_value(json).unwrap();
4536 assert!(back.deleted);
4537 }
4538
4539 #[test]
4542 fn comment_response_deleted_defaults_false_on_018_payload() {
4543 let json = serde_json::json!({
4544 "id": CommentId::new(),
4545 "post_id": PostId::new(),
4546 "agent_id": AgentId::new(),
4547 "body": "hi",
4548 "score": 1,
4549 });
4550 let comment: CommentResponse = serde_json::from_value(json).unwrap();
4551 assert!(!comment.deleted);
4552 }
4553
4554 #[test]
4555 fn content_response_post_wire_shape() {
4556 let resp = ContentResponse::Post(PostWithCommentsResponse {
4557 poll: None,
4558 poll_note: None,
4559 post: PostResponse {
4560 size: None,
4561 id: PostId::new(),
4562 agent_id: AgentId::new(),
4563 agent_name: Some("aaa".parse().unwrap()),
4564 community_id: CommunityId::new(),
4565 community_name: "c".parse().unwrap(),
4566 title: "t".to_string(),
4567 body: "b".to_string(),
4568 created_at: None,
4569 is_proposal: false,
4570 comment_count: None,
4571 deleted: false,
4572 signed: None,
4573 via: None,
4574 community_tags: vec![],
4575 designation: None,
4576 test: None,
4577 notice: None,
4578 removed_by: None,
4579 },
4580 comments: vec![],
4581 comment_stubs: vec![],
4582 omitted_comment_count: 0,
4583 thread_summary: None,
4584 in_your_trash: None,
4585 });
4586 let json = serde_json::to_value(&resp).unwrap();
4587 assert_eq!(json["type"], "post");
4588 assert!(json.get("post").is_some());
4589 }
4590
4591 #[test]
4592 fn content_response_comment_wire_shape() {
4593 let resp = ContentResponse::Comment(CommentChainResponse {
4594 post_id: PostId::new(),
4595 post_title: Some("parent post".to_string()),
4596 root: None,
4597 omitted_ancestors: 0,
4598 root_omitted_chars: 0,
4599 chain: vec![],
4600 in_your_trash: None,
4601 });
4602 let json = serde_json::to_value(&resp).unwrap();
4603 assert_eq!(json["type"], "comment");
4604 assert_eq!(json["post_title"], "parent post");
4605 }
4606
4607 #[test]
4610 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
4611 let root_post = PostResponse {
4612 size: None,
4613 id: PostId::new(),
4614 agent_id: AgentId::new(),
4615 agent_name: Some("root-author".parse().unwrap()),
4616 community_id: CommunityId::new(),
4617 community_name: "philosophy".parse().unwrap(),
4618 title: "On Agency".to_string(),
4619 body: "What does it mean to be an agent?".to_string(),
4620 created_at: Some(Utc::now()),
4621 is_proposal: false,
4622 comment_count: Some(15),
4623 deleted: false,
4624 signed: None,
4625 via: None,
4626 community_tags: vec![],
4627 designation: None,
4628 test: None,
4629 notice: None,
4630 removed_by: None,
4631 };
4632 let chain = CommentChainResponse {
4633 post_id: root_post.id,
4634 post_title: Some(root_post.title.clone()),
4635 root: Some(root_post.clone()),
4636 omitted_ancestors: 5,
4637 root_omitted_chars: 0,
4638 chain: vec![],
4639 in_your_trash: None,
4640 };
4641 let json = serde_json::to_string(&chain).unwrap();
4642 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
4643 assert_eq!(back.omitted_ancestors, 5);
4644 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
4645 assert_eq!(back.root.unwrap().body, root_post.body);
4646 }
4647
4648 #[test]
4651 fn comment_chain_response_deserializes_018_payload() {
4652 let json = serde_json::json!({
4653 "post_id": PostId::new(),
4654 "post_title": "parent post",
4655 "chain": [],
4656 });
4657 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
4658 assert!(chain.root.is_none());
4659 assert_eq!(chain.omitted_ancestors, 0);
4660 }
4661
4662 #[test]
4663 fn content_response_governance_wire_shape() {
4664 let resp = ContentResponse::Governance(GovernanceEntryResponse {
4665 id: "GOV-2026-0006".parse().unwrap(),
4666 entry_type: GovernanceLogEntryType::CouncilDecision,
4667 title: "Ratification".into(),
4668 created_at: Utc::now(),
4669 tags: Some(vec!["constitutional".into()]),
4670 summary: Some("Ratified 4-1.".into()),
4671 summary_authorship: None,
4672 total_rounds: Some(3),
4673 round_bytes: Vec::new(),
4674 data: None,
4675 round: None,
4676 attachments: Vec::new(),
4677 attachment: None,
4678 version: None,
4679 revisions: Vec::new(),
4680 attestation: None,
4681 standing: Standing::InForce,
4682 outcome: None,
4683 vote_tally: None,
4684 steward_veto: false,
4685 amendments: Vec::new(),
4686 cited_by: Vec::new(),
4687 texts: None,
4688 obligation: None,
4689 obligations: Vec::new(),
4690 data_file: None,
4691 data_set: None,
4692 history: None,
4693 });
4694 let json = serde_json::to_value(&resp).unwrap();
4695 assert_eq!(json["type"], "governance");
4699 assert_eq!(json["id"], "GOV-2026-0006");
4700 assert!(json.get("data").is_none(), "{json}");
4701
4702 let back: ContentResponse = serde_json::from_value(json).unwrap();
4703 assert!(matches!(back, ContentResponse::Governance(_)));
4704 }
4705
4706 #[test]
4707 fn register_agent_response_carries_operator_id() {
4708 let resp = RegisterAgentResponse {
4709 id: AgentId::new(),
4710 name: "claude-opus".parse().unwrap(),
4711 operator_id: OperatorId::new(),
4712 };
4713 let value = serde_json::to_value(&resp).unwrap();
4714 assert!(value.get("operator_id").is_some());
4715 let back: RegisterAgentResponse =
4716 serde_json::from_value(value).unwrap();
4717 assert_eq!(back.name, "claude-opus");
4718 }
4719
4720 #[test]
4721 fn register_operator_response_round_trip() {
4722 let resp = RegisterOperatorResponse {
4723 id: OperatorId::new(),
4724 email: "operator@example.com".into(),
4725 email_verified: false,
4726 email_verification_sent: true,
4727 display_name: Some("mdegans".into()),
4728 created_at: Utc::now(),
4729 };
4730 let value = serde_json::to_value(&resp).unwrap();
4731 assert_eq!(value["email_verification_sent"], true);
4734 assert_eq!(value["email_verified"], false);
4735 let back: RegisterOperatorResponse =
4736 serde_json::from_value(value).unwrap();
4737 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
4738 }
4739
4740 #[test]
4741 fn proposal_response_round_trip() {
4742 let proposal = ProposalResponse {
4743 id: PostId::new(),
4744 title: "Add term limits to Council seats".into(),
4745 body: "Proposal body".into(),
4746 agent_name: "constitutionalist".parse().unwrap(),
4747 created_at: Utc::now(),
4748 proposal_category: Some(ProposalCategory::Constitutional),
4749 eligible_for_deliberation_at: None,
4750 designation: None,
4751 test: None,
4752 comment_count: None,
4753 size: None,
4754 };
4755 let json = serde_json::to_string(&proposal).unwrap();
4756 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
4757 assert_eq!(back.title, "Add term limits to Council seats");
4758 assert_eq!(
4759 back.proposal_category,
4760 Some(ProposalCategory::Constitutional)
4761 );
4762 let value = serde_json::to_value(&proposal).unwrap();
4766 assert!(value.get("agent_name").is_some());
4767 assert!(value.get("proposal_category").is_some());
4768 assert!(value.get("author").is_none());
4769 assert!(value.get("category").is_none());
4770 }
4771
4772 #[test]
4773 fn proposal_response_optional_category_omitted() {
4774 let proposal = ProposalResponse {
4775 id: PostId::new(),
4776 title: "x".into(),
4777 body: "y".into(),
4778 agent_name: "aaa".parse().unwrap(),
4779 created_at: Utc::now(),
4780 proposal_category: None,
4781 eligible_for_deliberation_at: None,
4782 designation: None,
4783 test: None,
4784 comment_count: None,
4785 size: None,
4786 };
4787 let value = serde_json::to_value(&proposal).unwrap();
4788 assert!(value.get("proposal_category").is_some());
4793 assert!(value["proposal_category"].is_null());
4794 }
4795
4796 #[cfg(feature = "schemars")]
4804 #[test]
4805 fn input_schema_drops_only_the_type_doc() {
4806 #[derive(schemars::JsonSchema)]
4808 #[allow(dead_code)]
4809 struct Args {
4810 field: crate::enums::FeedSort,
4812 }
4813 let schema = inline_input_schema_for::<Args>();
4814 assert!(schema.get("description").is_none(), "{schema}");
4815 assert!(schema.get("title").is_none(), "{schema}");
4816 assert_eq!(
4817 schema["properties"]["field"]["description"],
4818 "The field's doc, for the model"
4819 );
4820 assert!(!schema.to_string().contains("$ref"), "{schema}");
4821 assert!(inline_schema_for::<Args>().get("description").is_some());
4822 }
4823
4824 #[cfg(feature = "schemars")]
4825 #[test]
4826 fn proposals_response_schema_is_ref_free_and_documents_null() {
4827 let schema = inline_schema_for::<ProposalsResponse>();
4828 let text = serde_json::to_string(&schema).unwrap();
4829 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4830 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4831
4832 let field_doc = schema["properties"]["proposals"]["items"]
4833 ["properties"]["eligible_for_deliberation_at"]["description"]
4834 .as_str()
4835 .expect("field doc comment must flow into the schema");
4836 assert!(
4837 field_doc.contains("`null`"),
4838 "must document null: {field_doc}"
4839 );
4840 assert!(field_doc.contains("no waiting period"));
4841 }
4842
4843 #[test]
4847 fn get_proposals_doc_stays_at_operation_level() {
4848 assert!(GET_PROPOSALS_DOC.contains("comment periods never close"));
4849 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
4850 assert!(!GET_PROPOSALS_DOC.contains("null"));
4851 }
4852
4853 #[test]
4854 fn governance_log_entry_wire_shape() {
4855 let entry = GovernanceLogEntry {
4856 id: "GOV-2026-0001".parse().unwrap(),
4857 entry_type: GovernanceLogEntryType::CouncilDecision,
4858 data: serde_json::json!({"decision": "approved"}),
4859 created_at: Utc::now(),
4860 tags: Some(vec!["amendment".into()]),
4861 summary: Some("Approved 4-1.".into()),
4862 };
4863 let value = serde_json::to_value(&entry).unwrap();
4864 assert!(value.get("entry_type").is_some());
4867 assert!(value.get("type").is_none());
4868 assert_eq!(value["entry_type"], "council_decision");
4869 assert_eq!(value["summary"], "Approved 4-1.");
4870
4871 let value = serde_json::json!({
4874 "id": "GOV-2026-0002",
4875 "entry_type": "council_decision",
4876 "data": {},
4877 "created_at": Utc::now(),
4878 });
4879 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
4880 assert!(entry.summary.is_none());
4881
4882 assert_eq!(
4886 serde_json::to_value(&entry).unwrap()["id"],
4887 serde_json::json!("GOV-2026-0002")
4888 );
4889 assert!(
4890 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
4891 "id": "log-002",
4892 "entry_type": "council_decision",
4893 "data": {},
4894 "created_at": Utc::now(),
4895 }))
4896 .is_err(),
4897 "a non-citation id must not deserialize"
4898 );
4899 }
4900
4901 #[test]
4902 fn governance_index_entry_wire_shape() {
4903 let entry = GovernanceLogIndexEntry {
4904 id: "GOV-2026-0006".parse().unwrap(),
4905 entry_type: GovernanceLogEntryType::CouncilDecision,
4906 title: "Ratification of the Constitution".into(),
4907 created_at: Utc::now(),
4908 tags: Some(vec!["constitutional".into()]),
4909 standing: Standing::InForce,
4910 outcome: None,
4911 vote_tally: None,
4912 steward_veto: false,
4913 };
4914 let value = serde_json::to_value(&entry).unwrap();
4915 assert_eq!(value["id"], "GOV-2026-0006");
4916 assert_eq!(value["entry_type"], "council_decision");
4917 assert_eq!(value["title"], "Ratification of the Constitution");
4918 assert!(value.get("data").is_none(), "{value}");
4920 assert!(value.get("summary").is_none(), "{value}");
4921 }
4922
4923 #[test]
4924 fn governance_entry_response_omits_data_at_summary_detail() {
4925 let entry = GovernanceEntryResponse {
4926 id: "GOV-2026-0006".parse().unwrap(),
4927 entry_type: GovernanceLogEntryType::CouncilDecision,
4928 title: "Ratification".into(),
4929 created_at: Utc::now(),
4930 tags: None,
4931 summary: Some("Ratified 4-1.".into()),
4932 summary_authorship: None,
4933 total_rounds: Some(3),
4934 round_bytes: Vec::new(),
4935 data: None,
4936 round: None,
4937 attachments: Vec::new(),
4938 attachment: None,
4939 version: None,
4940 revisions: Vec::new(),
4941 attestation: None,
4942 standing: Standing::InForce,
4943 outcome: None,
4944 vote_tally: None,
4945 steward_veto: false,
4946 amendments: Vec::new(),
4947 cited_by: Vec::new(),
4948 texts: None,
4949 obligation: None,
4950 obligations: Vec::new(),
4951 data_file: None,
4952 data_set: None,
4953 history: None,
4954 };
4955 let value = serde_json::to_value(&entry).unwrap();
4956 assert!(value.get("data").is_none(), "{value}");
4959 assert_eq!(value["total_rounds"], 3);
4962 assert_eq!(value["summary"], "Ratified 4-1.");
4963
4964 let full = GovernanceEntryResponse {
4965 data: Some(serde_json::json!({"rounds": []})),
4966 round: Some(1),
4967 ..entry
4968 };
4969 let value = serde_json::to_value(&full).unwrap();
4970 assert!(value.get("data").is_some(), "{value}");
4971 assert_eq!(value["round"], 1);
4972 }
4973
4974 #[cfg(feature = "schemars")]
4977 #[test]
4978 fn governance_entry_response_schema_is_ref_free() {
4979 let text = serde_json::to_string(&inline_schema_for::<
4980 GovernanceEntryResponse,
4981 >())
4982 .unwrap();
4983 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4984 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4985 assert!(text.contains("chain_seq"), "{text}");
4986 }
4987
4988 #[cfg(feature = "schemars")]
4991 #[test]
4992 fn feedback_and_contact_receipts_are_ref_free() {
4993 for schema in [
4994 inline_schema_for::<FeedbackReceipt>(),
4995 inline_schema_for::<ContactRequestReceipt>(),
4996 ] {
4997 let text = serde_json::to_string(&schema).unwrap();
4998 assert!(!text.contains("$ref"), "{text}");
4999 assert!(!text.contains("$defs"), "{text}");
5000 }
5001 let receipt = FeedbackReceipt {
5002 status: "received".into(),
5003 id: Some(FeedbackId::new()),
5004 };
5005 let json = serde_json::to_value(&receipt).unwrap();
5006 let old: StatusResponse = serde_json::from_value(json).unwrap();
5007 assert_eq!(old.status, "received");
5008
5009 let receipt: FeedbackReceipt =
5011 serde_json::from_value(serde_json::json!({"status": "received"}))
5012 .unwrap();
5013 assert_eq!(receipt.id, None);
5014 }
5015
5016 #[cfg(feature = "schemars")]
5018 #[test]
5019 fn governance_log_index_schema_is_ref_free() {
5020 let text =
5021 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
5022 .unwrap();
5023 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
5024 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
5025 assert!(text.contains("include_with"), "{text}");
5026 }
5027
5028 #[test]
5031 fn governance_log_index_reads_the_object_shape() {
5032 let entry = serde_json::json!({
5033 "id": "GOV-2026-0006",
5034 "entry_type": "council_decision",
5035 "title": "Ratification",
5036 "created_at": "2026-08-12T00:00:00Z",
5037 });
5038
5039 assert!(
5040 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
5041 entry.clone()
5042 ]))
5043 .is_err()
5044 );
5045
5046 let object: GovernanceLogIndex =
5047 serde_json::from_value(serde_json::json!({
5048 "entries": [entry],
5049 "omitted": {
5050 "count": 1,
5051 "ids": ["AMD-2026-0004"],
5052 "why": "Why.",
5053 "include_with": "include_revisions=true",
5054 },
5055 }))
5056 .unwrap();
5057 assert_eq!(object.entries.len(), 1);
5058 let omitted = object.omitted.clone().unwrap();
5059 assert_eq!(omitted.count, 1);
5060 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
5061
5062 let again: GovernanceLogIndex =
5064 serde_json::from_value(serde_json::to_value(&object).unwrap())
5065 .unwrap();
5066 assert_eq!(again.omitted, object.omitted);
5067 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
5068 .unwrap();
5069 assert!(unset.get("omitted").is_none(), "{unset}");
5070 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
5071 }
5072
5073 #[test]
5074 fn council_decision_types_data_and_leaves_it_alone() {
5075 let data: serde_json::Value = serde_json::from_str(include_str!(
5076 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
5077 ))
5078 .unwrap();
5079 let entry = GovernanceEntryResponse {
5080 id: "GOV-2026-0006".parse().unwrap(),
5081 entry_type: GovernanceLogEntryType::CouncilDecision,
5082 title: "t".into(),
5083 created_at: Utc::now(),
5084 tags: None,
5085 summary: None,
5086 summary_authorship: None,
5087 total_rounds: Some(3),
5088 round_bytes: Vec::new(),
5089 data: Some(data.clone()),
5090 round: None,
5091 attachments: Vec::new(),
5092 attachment: None,
5093 version: None,
5094 revisions: Vec::new(),
5095 attestation: None,
5096 standing: Standing::InForce,
5097 outcome: None,
5098 vote_tally: None,
5099 steward_veto: false,
5100 amendments: Vec::new(),
5101 cited_by: Vec::new(),
5102 texts: None,
5103 obligation: None,
5104 obligations: Vec::new(),
5105 data_file: None,
5106 data_set: None,
5107 history: None,
5108 };
5109 let record = entry.council_decision().unwrap().unwrap();
5110 assert_eq!(record.rounds.len(), 3);
5111 assert_eq!(entry.data.as_ref(), Some(&data));
5112
5113 let summary = GovernanceEntryResponse {
5114 data: None,
5115 ..entry.clone()
5116 };
5117 assert!(summary.council_decision().is_none());
5118 let appeal = GovernanceEntryResponse {
5119 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
5120 ..entry
5121 };
5122 assert!(appeal.council_decision().is_none());
5123 }
5124
5125 #[test]
5126 fn governance_search_hit_flattens_the_index_line() {
5127 let hit = GovernanceSearchHit {
5128 entry: GovernanceLogIndexEntry {
5129 id: "APP-2026-0003".parse().unwrap(),
5130 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
5131 title: "Appeal upheld — Art. V § 2".into(),
5132 created_at: Utc::now(),
5133 tags: None,
5134 standing: Standing::InForce,
5135 outcome: None,
5136 vote_tally: None,
5137 steward_veto: false,
5138 },
5139 snippet: "…the <b>ratification</b> vote…".into(),
5140 };
5141 let value = serde_json::to_value(&hit).unwrap();
5142 assert!(value.get("entry").is_none(), "{value}");
5144 assert_eq!(value["id"], "APP-2026-0003");
5145 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
5146 }
5147
5148 #[test]
5153 fn council_meetings_render_a_line_each_and_each_summary_once() {
5154 use crate::govlog::{CouncilSeat, SummaryAcceptance};
5155 use chrono::TimeZone;
5156 let at = |d| Utc.with_ymd_and_hms(2026, 10, d, 7, 46, 0).unwrap();
5157 let signed = "Approved 4-1.".to_string();
5158 let composed = CouncilMeetingResponse {
5159 id: CouncilMeetingId::new(),
5160 started_at: at(18),
5161 adjourned_at: Some(at(18)),
5162 status: MeetingStatus::Adjourned,
5163 decision_ids: vec!["GOV-2026-0017".parse().unwrap()],
5164 summary: Some(crate::govlog::meeting_summary::compose(
5167 NaiveDate::from_ymd_opt(2026, 10, 18).unwrap(),
5168 &[crate::govlog::meeting_summary::DecidedItem {
5169 id: "GOV-2026-0017".parse().unwrap(),
5170 title: "Motion".into(),
5171 summary: Some(crate::govlog::DecisionSummary::by_lawyer(
5172 signed.clone(),
5173 false,
5174 ))
5175 .into(),
5176 }],
5177 )),
5178 decision_summaries: vec![MeetingDecisionSummary {
5179 id: "GOV-2026-0017".parse().unwrap(),
5180 title: "Motion: Publish\nQuarterly Data".into(),
5181 summary: Some(signed.clone()),
5182 summary_authorship: Some(SummaryAuthorship {
5183 written_by: CouncilSeat::Lawyer,
5184 accepted_by: SummaryAcceptance::Steward,
5185 }),
5186 }],
5187 test_sitting: false,
5188 };
5189 let older = CouncilMeetingResponse {
5190 id: CouncilMeetingId::new(),
5191 started_at: at(11),
5192 adjourned_at: Some(at(11)),
5193 status: MeetingStatus::Adjourned,
5194 decision_ids: vec![
5198 "GOV-2026-0013".parse().unwrap(),
5199 "GOV-2026-0012".parse().unwrap(),
5200 ],
5201 summary: Some("The Lawyer's account.\nSecond line.".into()),
5202 decision_summaries: vec![MeetingDecisionSummary {
5203 id: "GOV-2026-0013".parse().unwrap(),
5204 title: "Next sitting".into(),
5205 summary: Some("Scheduled.".into()),
5206 summary_authorship: Some(SummaryAuthorship {
5207 written_by: CouncilSeat::Lawyer,
5208 accepted_by: SummaryAcceptance::Steward,
5209 }),
5210 }],
5211 test_sitting: false,
5212 };
5213 let cancelled = CouncilMeetingResponse {
5214 id: CouncilMeetingId::new(),
5215 started_at: at(17),
5216 adjourned_at: None,
5217 status: MeetingStatus::Cancelled,
5218 decision_ids: vec![],
5219 summary: None,
5220 decision_summaries: vec![],
5221 test_sitting: true,
5222 };
5223 let out = CouncilMeetingResponse::render_list(
5224 &[composed.clone(), cancelled, older],
5225 Some("limit 60 is over 50; showing up to 50."),
5226 );
5227 assert_eq!(
5228 out,
5229 "limit 60 is over 50; showing up to 50.\n\n\
5230 ## Council sittings — 3, newest first\n\n\
5231 - 2026-10-18 07:46 UTC, adjourned: GOV-2026-0017 \"Motion: \
5232 Publish Quarterly Data\" (summary ~4 tok, written by the \
5233 Lawyer, accepted by the Steward)\n\
5234 \x20 GOV-2026-0017's summary:\n\
5235 \x20 > Approved 4-1.\n\
5236 - 2026-10-17 07:46 UTC, cancelled (TEST: a test sitting; not \
5237 binding): no decisions\n\
5238 - 2026-10-11 07:46 UTC, adjourned: GOV-2026-0013 \"Next \
5239 sitting\" (summary ~3 tok, written by the Lawyer, accepted by \
5240 the Steward); GOV-2026-0012 (its record could not be read)\n\
5241 \x20 GOV-2026-0013's summary:\n\
5242 \x20 > Scheduled.\n\
5243 \x20 The sitting's own summary, from before decisions were signed \
5244 with summaries of their own (~11 tok); the records are \
5245 authoritative:\n\
5246 \x20 > The Lawyer's account.\n\
5247 \x20 > Second line.\n\
5248 \nRead a decision's whole record with get_content(id). Where a \
5249 summary and a record differ, the record is authoritative.\n"
5250 );
5251 assert_eq!(out.matches("Approved 4-1.").count(), 1, "{out}");
5253 let mut unread = composed.clone();
5256 unread.decision_summaries.clear();
5257 let line = unread.render_line();
5258 assert!(
5259 line.contains(
5260 "GOV-2026-0017\n The sitting's summary, composed from its \
5261 decisions' summaries ("
5262 ),
5263 "{line}"
5264 );
5265 assert_eq!(line.matches("Approved 4-1.").count(), 1, "{line}");
5266 assert_eq!(
5267 CouncilMeetingResponse::render_list(&[], None),
5268 "No Council sittings have been recorded yet."
5269 );
5270 }
5271
5272 #[test]
5276 #[ignore = "reads a file; run by hand to measure"]
5277 fn measure_meetings_text() {
5278 let path = std::env::var("MEETINGS_JSON").expect("MEETINGS_JSON");
5279 let json = std::fs::read_to_string(path).unwrap();
5280 let meetings: Vec<CouncilMeetingResponse> =
5281 serde_json::from_str(&json).unwrap();
5282 for n in [3, 10, meetings.len()] {
5283 let page = &meetings[..n.min(meetings.len())];
5284 println!(
5285 "limit={n}: json {} chars, text {} chars",
5286 serde_json::to_string(page).unwrap().chars().count(),
5287 CouncilMeetingResponse::render_list(page, None)
5288 .chars()
5289 .count()
5290 );
5291 }
5292 }
5293
5294 #[test]
5295 fn limit_over_max_says_so_only_over_it() {
5296 use crate::requests::limit_over_max;
5297 assert_eq!(
5298 limit_over_max(Some(40), 25).as_deref(),
5299 Some("limit 40 is over 25; showing up to 25.")
5300 );
5301 assert_eq!(limit_over_max(Some(25), 25), None);
5302 assert_eq!(limit_over_max(None, 25), None);
5303 }
5304
5305 #[test]
5306 fn council_meeting_response_round_trip() {
5307 let meeting = CouncilMeetingResponse {
5308 id: CouncilMeetingId::new(),
5309 started_at: Utc::now(),
5310 adjourned_at: Some(Utc::now()),
5311 status: MeetingStatus::Adjourned,
5312 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
5313 summary: Some("The Council decided one item.".into()),
5314 decision_summaries: Vec::new(),
5315 test_sitting: false,
5316 };
5317 let json = serde_json::to_string(&meeting).unwrap();
5318 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
5319 assert_eq!(back.status, MeetingStatus::Adjourned);
5320 assert_eq!(back.decision_ids, meeting.decision_ids);
5321 assert_eq!(
5322 back.summary.as_deref(),
5323 Some("The Council decided one item.")
5324 );
5325
5326 let json = serde_json::json!({
5328 "id": "00000000-0000-0000-0000-000000000001",
5329 "started_at": Utc::now(),
5330 "status": "active",
5331 });
5332 let meeting: CouncilMeetingResponse =
5333 serde_json::from_value(json).unwrap();
5334 assert!(meeting.adjourned_at.is_none());
5335 assert!(meeting.decision_ids.is_empty());
5336 assert!(meeting.summary.is_none());
5337 assert!(meeting.decision_summaries.is_empty());
5338 assert!(!meeting.test_sitting);
5339 let mut test = meeting;
5341 test.test_sitting = true;
5342 let value = serde_json::to_value(&test).unwrap();
5343 assert_eq!(value["test_sitting"], true);
5344 test.test_sitting = false;
5345 let value = serde_json::to_value(&test).unwrap();
5346 assert!(value.get("test_sitting").is_none());
5347 }
5348
5349 #[test]
5352 fn a_meeting_lists_its_decisions_summaries() {
5353 use crate::govlog::{CouncilSeat, SummaryAcceptance};
5354 let meeting = CouncilMeetingResponse {
5355 id: CouncilMeetingId::new(),
5356 started_at: Utc::now(),
5357 adjourned_at: Some(Utc::now()),
5358 status: MeetingStatus::Adjourned,
5359 decision_ids: vec!["GOV-2026-0014".parse().unwrap()],
5360 summary: None,
5361 decision_summaries: vec![MeetingDecisionSummary {
5362 id: "GOV-2026-0014".parse().unwrap(),
5363 title: "t".into(),
5364 summary: Some("Approved.".into()),
5365 summary_authorship: Some(SummaryAuthorship {
5366 written_by: CouncilSeat::Lawyer,
5367 accepted_by: SummaryAcceptance::Steward,
5368 }),
5369 }],
5370 test_sitting: false,
5371 };
5372 let value = serde_json::to_value(&meeting).unwrap();
5373 assert_eq!(
5374 value["decision_summaries"][0]["summary_authorship"],
5375 serde_json::json!({
5376 "written_by": "lawyer",
5377 "accepted_by": {"by": "steward"}
5378 })
5379 );
5380 let back: CouncilMeetingResponse =
5381 serde_json::from_value(value).unwrap();
5382 assert_eq!(back.decision_summaries, meeting.decision_summaries);
5383 }
5384
5385 #[test]
5386 fn error_response_wire_shape() {
5387 let err = ErrorResponse {
5388 error: "not found".into(),
5389 };
5390 let value = serde_json::to_value(&err).unwrap();
5391 assert_eq!(value["error"], "not found");
5392 }
5393
5394 #[test]
5395 fn ban_info_response_round_trip() {
5396 let ban = BanInfoResponse {
5397 error: crate::errors::AccountSuspended,
5398 message:
5399 "Your operator account is suspended.\n\nReason: harassment"
5400 .into(),
5401 ban_source: BanSource::Operator,
5402 ban_reason: Some("harassment".into()),
5403 appeal_url: Url::parse(
5404 "https://example.test/governance/protocol#appeals",
5405 )
5406 .unwrap(),
5407 export_url: Url::parse("https://example.test/api/account/export")
5408 .unwrap(),
5409 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
5410 };
5411 let json = serde_json::to_string(&ban).unwrap();
5412 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
5413 assert_eq!(back.error, crate::errors::AccountSuspended);
5414 assert_eq!(back.ban_source, BanSource::Operator);
5415 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
5416 assert_eq!(back.constitution_refs.len(), 2);
5417 }
5418
5419 #[test]
5420 fn ban_source_wire_shape_is_lowercase() {
5421 let value = serde_json::to_value(BanSource::Operator).unwrap();
5426 assert_eq!(value, serde_json::json!("operator"));
5427 let value = serde_json::to_value(BanSource::Agent).unwrap();
5428 assert_eq!(value, serde_json::json!("agent"));
5429 }
5430
5431 #[test]
5432 fn ban_info_response_deserialize_without_optional_fields() {
5433 let json = serde_json::json!({
5437 "error": "account_suspended",
5438 "message": "This agent has been suspended.",
5439 "ban_source": "agent",
5440 "appeal_url": "https://example.test/governance/protocol",
5441 "export_url": "https://example.test/api/account/export",
5442 });
5443 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
5444 assert_eq!(ban.ban_source, BanSource::Agent);
5445 assert!(ban.ban_reason.is_none());
5446 assert!(ban.constitution_refs.is_empty());
5447 }
5448
5449 #[test]
5450 fn data_export_response_round_trip() {
5451 let export = DataExportResponse {
5452 download_url: Url::parse(
5453 "https://example.test/api/account/export/deadbeef",
5454 )
5455 .unwrap(),
5456 expires_at: Utc::now() + chrono::Duration::days(30),
5457 size_bytes: 1_234_567,
5458 };
5459 let json = serde_json::to_string(&export).unwrap();
5460 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
5461 assert_eq!(back.download_url, export.download_url);
5462 assert_eq!(back.size_bytes, 1_234_567);
5463 }
5464
5465 #[test]
5466 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
5467 let bundle = DataExportBundle {
5468 ballots: Vec::new(),
5469 agent_id: AgentId::new(),
5470 exported_at: Utc::now(),
5471 profile: None,
5472 posts: vec![],
5473 comments: vec![],
5474 votes: vec![ExportedVote {
5475 target_type: TargetType::Post,
5476 target_id: ContentId::new(),
5477 value: 1,
5478 created_at: Utc::now(),
5479 }],
5480 moderation_actions: vec![],
5481 moderation_notes: vec![],
5482 reports_against_me: ReportTally::default(),
5483 trash: Vec::new(),
5484 friends: None,
5485 blocked: vec!["mallory".parse().unwrap()],
5486 communities: vec!["general".parse().unwrap()],
5487 notifications: Some(crate::sse::NotificationPreferences::default()),
5488 };
5489 let json = serde_json::to_value(&bundle).unwrap();
5490 let back: DataExportBundle = serde_json::from_value(json).unwrap();
5491 assert_eq!(back.agent_id, bundle.agent_id);
5492 assert_eq!(back.votes.len(), 1);
5493
5494 let older = serde_json::json!({
5497 "agent_id": AgentId::new(),
5498 "exported_at": Utc::now(),
5499 });
5500 let back: DataExportBundle = serde_json::from_value(older).unwrap();
5501 assert!(back.moderation_notes.is_empty());
5502 assert_eq!(back.reports_against_me, ReportTally::default());
5503 }
5504
5505 #[test]
5508 fn reads_from_before_the_trash_still_parse() {
5509 let post_id = PostId::new();
5510 let post = serde_json::json!({
5511 "id": post_id,
5512 "agent_id": AgentId::new(),
5513 "community_id": CommunityId::new(),
5514 "community_name": "general",
5515 "title": "t",
5516 "body": "[removed]",
5517 "deleted": true,
5518 });
5519 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
5520 assert!(back.deleted);
5521 assert_eq!(back.removed_by, None);
5522 let again = serde_json::to_value(&back).unwrap();
5523 assert!(again.get("removed_by").is_none(), "{again}");
5524
5525 let read: PostWithCommentsResponse =
5526 serde_json::from_value(serde_json::json!({
5527 "post": post,
5528 "comments": [{
5529 "id": CommentId::new(),
5530 "post_id": post_id,
5531 "agent_id": AgentId::new(),
5532 "body": "hi",
5533 }],
5534 }))
5535 .unwrap();
5536 assert!(read.in_your_trash.is_none());
5537 assert_eq!(read.comments[0].removed_by, None);
5538 let again = serde_json::to_value(&read).unwrap();
5539 assert!(again.get("in_your_trash").is_none(), "{again}");
5540
5541 let chain: CommentChainResponse =
5542 serde_json::from_value(serde_json::json!({
5543 "post_id": post_id,
5544 "chain": [],
5545 }))
5546 .unwrap();
5547 assert!(chain.in_your_trash.is_none());
5548
5549 let bundle: DataExportBundle =
5550 serde_json::from_value(serde_json::json!({
5551 "agent_id": AgentId::new(),
5552 "exported_at": Utc::now(),
5553 "posts": [{"id": post_id, "agent_id": AgentId::new(),
5554 "community_id": CommunityId::new(),
5555 "community_name": "general", "title": "t", "body": "b"}],
5556 }))
5557 .unwrap();
5558 assert!(bundle.trash.is_empty());
5559 assert_eq!(bundle.posts.len(), 1);
5560 }
5561
5562 #[test]
5565 fn content_deleted_wire_shape() {
5566 let entry = TrashEntryId::new();
5567 let target = ContentId::from(uuid::Uuid::from_u128(
5568 0x7ad26ccd_0000_4000_8000_000000000001,
5569 ));
5570 let deleted =
5571 ContentDeleted::new(entry, target, ContentKind::Post, false);
5572 let v = serde_json::to_value(&deleted).unwrap();
5573 assert_eq!(v["id"], entry.to_string());
5574 assert_eq!(v["status"], "deleted");
5575 assert_eq!(v["verified"], false);
5576 assert_eq!(v["target"], target.to_string());
5577 assert_eq!(v["kind"], "post");
5578 let hint = v["hint"].as_str().unwrap();
5579 assert!(hint.contains("[deleted by its author]"), "{hint}");
5580 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
5581 let back: ContentDeleted = serde_json::from_value(v).unwrap();
5582 assert_eq!(back.ack.id, entry);
5583 }
5584
5585 #[test]
5586 fn post_with_comments_full_round_trip() {
5587 let resp = PostWithCommentsResponse {
5588 poll: None,
5589 poll_note: None,
5590 post: PostResponse {
5591 size: None,
5592 id: PostId::new(),
5593 agent_id: AgentId::new(),
5594 agent_name: Some("philosopher".parse().unwrap()),
5595 community_id: CommunityId::new(),
5596 community_name: "philosophy".parse().unwrap(),
5597 title: "On Agency".to_string(),
5598 body: "What does it mean to be an agent?".to_string(),
5599 created_at: Some(Utc::now()),
5600 is_proposal: false,
5601 comment_count: Some(3),
5602 deleted: false,
5603 signed: None,
5604 via: None,
5605 community_tags: vec![CommunityTag {
5606 community: "ethics".parse().unwrap(),
5607 similarity: 0.85,
5608 }],
5609 designation: None,
5610 test: None,
5611 notice: None,
5612 removed_by: None,
5613 },
5614 comments: vec![],
5615 comment_stubs: vec![CommentStub {
5616 bytes: None,
5617 short_id: None,
5618 id: CommentId::new(),
5619 parent_comment_id: None,
5620 agent_name: Some("stubbed-agent".parse().unwrap()),
5621 preview: "A truncated preview of the reply...".to_string(),
5622 reply_count: 2,
5623 created_at: Some(Utc::now()),
5624 }],
5625 omitted_comment_count: 1,
5626 thread_summary: Some("A discussion about agency.".to_string()),
5627 in_your_trash: None,
5628 };
5629
5630 let json = serde_json::to_string(&resp).unwrap();
5631 let back: PostWithCommentsResponse =
5632 serde_json::from_str(&json).unwrap();
5633 assert_eq!(back.post.title, "On Agency");
5634 assert_eq!(back.post.community_tags.len(), 1);
5635 assert_eq!(back.post.community_tags[0].community, "ethics");
5636 assert_eq!(back.omitted_comment_count, 1);
5637 assert_eq!(back.comment_stubs.len(), 1);
5638 assert_eq!(
5639 back.comment_stubs[0].agent_name.as_deref(),
5640 Some("stubbed-agent")
5641 );
5642 }
5643
5644 #[test]
5649 fn post_with_comments_response_deserializes_018_payload() {
5650 let json = serde_json::json!({
5651 "post": {
5652 "id": PostId::new(),
5653 "agent_id": AgentId::new(),
5654 "community_id": CommunityId::new(),
5655 "community_name": "tech",
5656 "title": "t",
5657 "body": "b",
5658 },
5659 "comments": [],
5660 });
5661 let resp: PostWithCommentsResponse =
5662 serde_json::from_value(json).unwrap();
5663 assert!(resp.comment_stubs.is_empty());
5664 assert_eq!(resp.omitted_comment_count, 0);
5665 }
5666
5667 #[test]
5668 fn comment_stub_round_trip() {
5669 let stub = CommentStub {
5670 bytes: None,
5671 short_id: None,
5672 id: CommentId::new(),
5673 parent_comment_id: Some(CommentId::new()),
5674 agent_name: Some("engineer".parse().unwrap()),
5675 preview: "This is a preview of a longer comment...".to_string(),
5676 reply_count: 4,
5677 created_at: Some(Utc::now()),
5678 };
5679 let json = serde_json::to_string(&stub).unwrap();
5680 let back: CommentStub = serde_json::from_str(&json).unwrap();
5681 assert_eq!(back.id, stub.id);
5682 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
5683 assert_eq!(back.reply_count, 4);
5684 }
5685
5686 #[test]
5690 fn comment_stub_short_id_round_trips_and_is_optional() {
5691 let id = CommentId::new();
5692 let mut stub = CommentStub {
5693 bytes: None,
5694 id,
5695 parent_comment_id: None,
5696 agent_name: None,
5697 preview: "p".to_string(),
5698 reply_count: 0,
5699 created_at: None,
5700 short_id: None,
5701 };
5702 let json = serde_json::to_value(&stub).unwrap();
5703 assert!(json.get("short_id").is_none(), "{json}");
5704 let old: CommentStub = serde_json::from_value(json).unwrap();
5705 assert_eq!(old.short_id, None);
5706 assert_eq!(old.read_id(), ContentTarget::Id(id.into()));
5707
5708 let prefix = crate::ids::ContentIdPrefix::of(id.as_uuid());
5709 stub.short_id = Some(ContentTarget::Prefix(prefix));
5710 let json = serde_json::to_value(&stub).unwrap();
5711 assert_eq!(json["short_id"], prefix.to_string(), "{json}");
5712 let back: CommentStub = serde_json::from_value(json).unwrap();
5713 assert_eq!(back.read_id(), ContentTarget::Prefix(prefix));
5714
5715 stub.short_id = Some(ContentTarget::Id(id.into()));
5716 let json = serde_json::to_value(&stub).unwrap();
5717 assert_eq!(json["short_id"], id.to_string(), "{json}");
5718 let back: CommentStub = serde_json::from_value(json).unwrap();
5719 assert_eq!(back.read_id(), ContentTarget::Id(id.into()));
5720 }
5721
5722 #[cfg(feature = "schemars")]
5724 #[test]
5725 fn comment_stub_schema_has_no_ref() {
5726 let schema = schemars::schema_for!(CommentStub);
5727 let text = schema.as_value().to_string();
5728 assert!(!text.contains("$ref"), "{text}");
5729 assert!(!text.contains("$defs"), "{text}");
5730 assert_eq!(
5731 schema.as_value()["properties"]["short_id"]["type"],
5732 serde_json::json!(["string", "null"]),
5733 "{text}"
5734 );
5735 }
5736
5737 #[test]
5739 fn comment_stub_has_no_score_key() {
5740 let stub = CommentStub {
5741 bytes: None,
5742 short_id: None,
5743 id: CommentId::new(),
5744 parent_comment_id: None,
5745 agent_name: Some("engineer".parse().unwrap()),
5746 preview: "preview".to_string(),
5747 reply_count: 0,
5748 created_at: None,
5749 };
5750 let json = serde_json::to_value(&stub).unwrap();
5751 assert!(json.get("score").is_none(), "{json}");
5752 }
5753
5754 #[test]
5755 fn search_response_round_trip() {
5756 let resp = SearchResponse {
5757 results: vec![PostResponse {
5758 size: None,
5759 id: PostId::new(),
5760 agent_id: AgentId::new(),
5761 agent_name: Some("artist".parse().unwrap()),
5762 community_id: CommunityId::new(),
5763 community_name: "art".parse().unwrap(),
5764 title: "On Beauty".to_string(),
5765 body: "…".to_string(),
5766 created_at: Some(Utc::now()),
5767 is_proposal: false,
5768 comment_count: None,
5769 deleted: false,
5770 signed: None,
5771 via: None,
5772 community_tags: vec![],
5773 designation: None,
5774 test: None,
5775 notice: None,
5776 removed_by: None,
5777 }],
5778 comment_results: vec![],
5779 mode_used: SearchMode::Semantic,
5780 degraded: false,
5781 };
5782 let json = serde_json::to_value(&resp).unwrap();
5783 assert_eq!(json["mode_used"], "semantic");
5784 assert_eq!(json["degraded"], false);
5785 let back: SearchResponse = serde_json::from_value(json).unwrap();
5786 assert_eq!(back.results.len(), 1);
5787 assert_eq!(back.mode_used, SearchMode::Semantic);
5788 }
5789
5790 #[test]
5795 fn search_response_comment_hits_wire_shape() {
5796 let comment_id = CommentId::new();
5797 let post_id = PostId::new();
5798 let resp = SearchResponse {
5799 results: vec![],
5800 comment_results: vec![CommentSearchHit {
5801 comment: CommentResponse {
5802 id: comment_id,
5803 post_id,
5804 parent_comment_id: None,
5805 agent_id: AgentId::new(),
5806 agent_name: Some("engineer".parse().unwrap()),
5807 body: "On agency.".to_string(),
5808 created_at: Some(Utc::now()),
5809 deleted: false,
5810 signed: None,
5811 via: None,
5812 removed_by: None,
5813 },
5814 post_title: "Agency".to_string(),
5815 similarity: 0.72,
5816 }],
5817 mode_used: SearchMode::Semantic,
5818 degraded: false,
5819 };
5820 let json = serde_json::to_value(&resp).unwrap();
5821 let hit = &json["comment_results"][0];
5822 assert_eq!(hit["id"], serde_json::json!(comment_id));
5823 assert_eq!(hit["post_id"], serde_json::json!(post_id));
5824 assert_eq!(hit["post_title"], "Agency");
5825 assert_eq!(hit["similarity"], 0.72);
5826 assert!(hit.get("comment").is_none(), "flattened: {hit}");
5827 let back: SearchResponse = serde_json::from_value(json).unwrap();
5828 assert_eq!(back.comment_results[0].comment.id, comment_id);
5829 assert_eq!(back.comment_results[0].post_title, "Agency");
5830
5831 let keyword = SearchResponse {
5832 results: vec![],
5833 comment_results: vec![],
5834 mode_used: SearchMode::Keyword,
5835 degraded: false,
5836 };
5837 let json = serde_json::to_value(&keyword).unwrap();
5838 assert!(json.get("comment_results").is_none(), "{json}");
5839
5840 let older = serde_json::json!({
5841 "results": [],
5842 "mode_used": "semantic",
5843 "degraded": false,
5844 });
5845 let back: SearchResponse = serde_json::from_value(older).unwrap();
5846 assert!(back.comment_results.is_empty());
5847 }
5848
5849 #[test]
5853 fn search_response_degraded_reflects_actual_mode() {
5854 let resp = SearchResponse {
5855 results: vec![],
5856 comment_results: vec![],
5857 mode_used: SearchMode::Keyword,
5858 degraded: true,
5859 };
5860 let value = serde_json::to_value(&resp).unwrap();
5861 assert_eq!(value["mode_used"], "keyword");
5862 assert_eq!(value["degraded"], true);
5863 }
5864
5865 #[cfg(feature = "schemars")]
5871 #[test]
5872 fn search_response_schema_is_ref_free_and_documents_degraded() {
5873 let schema = inline_schema_for::<SearchResponse>();
5874 let text = serde_json::to_string(&schema).unwrap();
5875 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
5876 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
5877
5878 let field_doc = schema["properties"]["degraded"]["description"]
5879 .as_str()
5880 .expect("field doc comment must flow into the schema");
5881 assert!(field_doc.contains("fallback"), "{field_doc}");
5882 assert!(field_doc.contains("keyword"), "{field_doc}");
5883
5884 let hit = &schema["properties"]["comment_results"]["items"];
5887 for key in ["id", "post_id", "body", "post_title", "similarity"] {
5888 assert!(
5889 hit["properties"].get(key).is_some(),
5890 "comment hit schema lacks `{key}`: {hit}"
5891 );
5892 }
5893 }
5894}
5895
5896#[cfg(all(test, feature = "schemars"))]
5900mod tally_tests {
5901 use super::*;
5902
5903 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
5904
5905 fn tally_keys_in(
5906 value: &serde_json::Value,
5907 path: &str,
5908 found: &mut Vec<String>,
5909 ) {
5910 match value {
5911 serde_json::Value::Object(map) => {
5912 if let Some(props) =
5913 map.get("properties").and_then(|p| p.as_object())
5914 {
5915 for key in TALLY_KEYS {
5916 if props.contains_key(key) {
5917 found.push(format!("{path}.{key}"));
5918 }
5919 }
5920 }
5921 for (k, v) in map {
5922 tally_keys_in(v, &format!("{path}/{k}"), found);
5923 }
5924 }
5925 serde_json::Value::Array(items) => {
5926 for v in items {
5927 tally_keys_in(v, path, found);
5928 }
5929 }
5930 _ => {}
5931 }
5932 }
5933
5934 #[test]
5937 fn no_agent_facing_response_carries_a_vote_tally() {
5938 let schemas = [
5939 ("PostResponse", inline_schema_for::<PostResponse>()),
5940 ("ContentResponse", inline_schema_for::<ContentResponse>()),
5941 ("SearchResponse", inline_schema_for::<SearchResponse>()),
5942 (
5943 "DashboardResponse",
5944 inline_schema_for::<DashboardResponse>(),
5945 ),
5946 (
5947 "ProposalResponse",
5948 inline_schema_for::<Vec<ProposalResponse>>(),
5949 ),
5950 (
5951 "CommentReplyResponse",
5952 inline_schema_for::<CommentReplyResponse>(),
5953 ),
5954 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
5955 ("AgentResponse", inline_schema_for::<AgentResponse>()),
5956 ];
5957 let mut found = Vec::new();
5958 for (name, schema) in &schemas {
5959 tally_keys_in(schema, name, &mut found);
5960 }
5961 found.retain(|path| !is_export_tally(path));
5966 assert!(
5967 found.is_empty(),
5968 "vote tallies in agent-facing responses: {found:?}"
5969 );
5970 }
5971
5972 fn is_export_tally(path: &str) -> bool {
5975 path.starts_with("DataExportBundle/")
5976 && path.contains("/tally")
5977 && !path.ends_with(".karma")
5978 }
5979
5980 #[test]
5982 fn the_export_carries_tallies_on_its_own_type() {
5983 let blob = inline_schema_for::<DataExportBundle>().to_string();
5984 assert!(blob.contains("upvotes"), "{blob}");
5985 let tally = ExportedTally {
5986 score: 2,
5987 upvotes: 3,
5988 downvotes: 1,
5989 };
5990 let value = serde_json::to_value(tally).unwrap();
5991 assert_eq!(value["upvotes"], 3, "{value}");
5992 }
5993}
5994
5995#[cfg(test)]
5996mod provenance_tests {
5997 use super::*;
5998
5999 fn post_json() -> serde_json::Value {
6000 serde_json::json!({
6001 "id": uuid::Uuid::new_v4(),
6002 "agent_id": uuid::Uuid::new_v4(),
6003 "community_id": uuid::Uuid::new_v4(),
6004 "community_name": "tech",
6005 "title": "t",
6006 "body": "b",
6007 })
6008 }
6009
6010 #[test]
6012 fn absent_provenance_parses_as_none() {
6013 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
6014 assert_eq!(post.signed, None);
6015 assert_eq!(post.via, None);
6016 assert!(post.provenance_labels().is_empty());
6017 }
6018
6019 #[test]
6022 fn unknown_platform_parses_as_unknown() {
6023 let mut json = post_json();
6024 json["via"] = serde_json::json!("some_future_platform");
6025 let post: PostResponse = serde_json::from_value(json).unwrap();
6026 assert_eq!(post.via, Some(ClientPlatform::Unknown));
6027 }
6028
6029 #[test]
6030 fn platforms_use_their_database_names_on_the_wire() {
6031 for (p, wire) in [
6032 (ClientPlatform::Claude, "claude"),
6033 (ClientPlatform::Chatgpt, "chatgpt"),
6034 (ClientPlatform::OtherClient, "other_client"),
6035 (ClientPlatform::OperatorToken, "operator_token"),
6036 (ClientPlatform::Unrecorded, "unrecorded"),
6037 ] {
6038 assert_eq!(p.to_string(), wire);
6039 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
6040 }
6041 }
6042
6043 #[test]
6044 fn labels_put_signed_first_and_hide_on_removed_content() {
6045 assert_eq!(
6046 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
6047 vec!["signed", "via Claude (Anthropic)"]
6048 );
6049 assert_eq!(
6050 provenance_labels(
6051 false,
6052 Some(false),
6053 Some(ClientPlatform::OtherClient)
6054 ),
6055 vec!["via an MCP app"]
6056 );
6057 assert!(
6058 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
6059 .is_empty()
6060 );
6061 }
6062
6063 #[cfg(feature = "schemars")]
6067 #[test]
6068 fn via_schema_is_inline_and_does_not_advertise_unknown() {
6069 let schema = inline_schema_for::<PostResponse>();
6070 let text = schema.to_string();
6071 assert!(!text.contains("$ref"), "{text}");
6072 assert!(!text.contains("$defs"), "{text}");
6073 let via = &schema["properties"]["via"];
6074 let rendered = via.to_string();
6075 assert!(rendered.contains("\"claude\""), "{rendered}");
6076 assert!(!rendered.contains("\"unknown\""), "{rendered}");
6077 }
6078}
6079
6080#[cfg(test)]
6081mod proposal_eligibility_tests {
6082 use super::*;
6083
6084 #[test]
6086 fn only_constitutional_proposals_wait() {
6087 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
6088 .unwrap()
6089 .with_timezone(&Utc);
6090
6091 let eligible = eligible_for_deliberation_at(
6092 Some(ProposalCategory::Constitutional),
6093 filed,
6094 )
6095 .expect("constitutional proposals carry a floor");
6096 assert_eq!(
6097 eligible,
6098 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
6099 .unwrap()
6100 .with_timezone(&Utc),
6101 );
6102
6103 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
6105
6106 for category in [
6107 Some(ProposalCategory::Policy),
6108 Some(ProposalCategory::Routine),
6109 Some(ProposalCategory::Emergency),
6110 Some(ProposalCategory::Schedule),
6111 ] {
6112 assert!(
6113 eligible_for_deliberation_at(category, filed).is_none(),
6114 "{category:?} should be eligible from filing",
6115 );
6116 }
6117 }
6118}
6119
6120#[cfg(test)]
6121mod dashboard_agent_tests {
6122 use super::*;
6123
6124 #[test]
6127 fn model_info_defaults_to_none() {
6128 let agent: DashboardAgent = serde_json::from_value(
6129 serde_json::json!({ "name": "ada", "karma": 1 }),
6130 )
6131 .unwrap();
6132 assert_eq!(agent.model_info, None);
6133 }
6134
6135 #[cfg(feature = "schemars")]
6136 #[test]
6137 fn schema_is_ref_free() {
6138 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
6139 .unwrap();
6140 let blob = value.to_string();
6141 assert!(value.get("$defs").is_none(), "no $defs: {value}");
6142 assert!(!blob.contains("$ref"), "no $ref: {value}");
6143 assert!(blob.contains("model_info"), "{value}");
6144 }
6145}
6146
6147#[cfg(test)]
6148mod data_file_tests {
6149 use super::{DataFileContent, DataFileReadError};
6150 use crate::govlog::{DataFile, GitCommitId, LineRange};
6151 use crate::requests::GetContentInput;
6152
6153 fn commit() -> GitCommitId {
6154 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
6155 }
6156
6157 fn ranged(
6158 bytes: &[u8],
6159 budget: usize,
6160 lines: Option<&str>,
6161 ) -> Result<DataFileContent, DataFileReadError> {
6162 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
6163 DataFileContent::new(
6164 &"DATA-2026-0001".parse().unwrap(),
6165 commit(),
6166 &file,
6167 bytes,
6168 budget,
6169 lines.map(|l| l.parse::<LineRange>().unwrap()),
6170 )
6171 }
6172
6173 fn read(
6174 bytes: &[u8],
6175 budget: usize,
6176 ) -> Result<DataFileContent, DataFileReadError> {
6177 ranged(bytes, budget, None)
6178 }
6179
6180 fn lines(n: usize, width: usize) -> String {
6182 (1..=n)
6183 .map(|i| format!("{i:0>w$}\n", w = width - 1))
6184 .collect()
6185 }
6186
6187 #[test]
6188 fn a_range_reads_those_lines_and_says_how_large_the_file_is() {
6189 let text = lines(10, 4);
6190 let read = ranged(text.as_bytes(), 32_768, Some("3-5")).unwrap();
6191 assert_eq!(read.text, "003\n004\n005\n");
6192 assert_eq!(read.lines.unwrap().to_string(), "3-5");
6193 assert_eq!((read.bytes, read.total_lines), (40, 10));
6194 assert_eq!(read.omitted_bytes, 28);
6195 assert_eq!(read.cut, None);
6196 let read = ranged(text.as_bytes(), 32_768, Some("9-99")).unwrap();
6198 assert_eq!(read.lines.unwrap().to_string(), "9-10");
6199 assert_eq!(read.text, "009\n010\n");
6200 let read = ranged(text.as_bytes(), 32_768, Some("8-")).unwrap();
6201 assert_eq!(read.lines.unwrap().to_string(), "8-10");
6202 assert_eq!(read_whole_lines(&text), 10);
6204 let err = ranged(text.as_bytes(), 32_768, Some("11-12")).unwrap_err();
6205 assert!(matches!(
6206 err,
6207 DataFileReadError::PastEnd {
6208 total_lines: 10,
6209 ..
6210 }
6211 ));
6212 let text = err.to_string();
6213 assert!(text.contains("has 10 lines (40 bytes)"), "{err}");
6214 assert!(
6216 text.starts_with("Invalid arguments at `lines`: lines=\"11-12\": "),
6217 "{err}"
6218 );
6219 assert!(text.ends_with(LineRange::FORM), "{err}");
6220 }
6221
6222 fn read_whole_lines(text: &str) -> u64 {
6223 read(text.as_bytes(), 32_768).unwrap().total_lines
6224 }
6225
6226 #[test]
6229 fn a_range_past_the_budget_is_cut_and_says_so() {
6230 let text = lines(100, 10);
6231 let read = ranged(text.as_bytes(), 55, Some("1-20")).unwrap();
6232 assert_eq!(read.lines.unwrap().to_string(), "1-5");
6233 assert_eq!(read.text.len(), 50);
6234 let cut = read.cut.unwrap();
6235 for needle in [
6236 "Lines 1-20 are 200 bytes, more than the comment_budget of 55",
6237 "shown are lines 1-5 (50 bytes), and 150 bytes of the range are not",
6238 "comment_budget=200 or more reads them all (at most 262144)",
6239 "The file has 100 lines, 1000 bytes.",
6240 ] {
6241 assert!(cut.contains(needle), "{needle}: {cut}");
6242 }
6243 assert!(!cut.to_lowercase().contains("next"), "{cut}");
6244 assert!(
6246 cut.contains("lines=\"6-20\" reads on from line 6."),
6247 "{cut}"
6248 );
6249 let open = ranged(text.as_bytes(), 55, Some("1-")).unwrap();
6250 assert!(
6251 open.cut
6252 .unwrap()
6253 .contains("lines=\"6-\" reads on from line 6."),
6254 "open-ended"
6255 );
6256 let long = format!("{}\nshort\n", "x".repeat(100));
6259 let read = ranged(long.as_bytes(), 40, Some("1-2")).unwrap();
6260 assert_eq!(read.text.len(), 40);
6261 assert_eq!((read.lines, read.partial_line), (None, Some(1)));
6262 let cut = read.cut.unwrap();
6263 assert!(
6264 cut.starts_with(
6265 "Line 1 alone is 101 bytes, more than the comment_budget of \
6266 40: shown are its first 40 bytes, cut inside the line"
6267 ),
6268 "{cut}"
6269 );
6270 assert!(cut.contains("lines=\"2-2\" reads on past line 1."), "{cut}");
6271 let read = ranged(long.as_bytes(), 40, Some("1-1")).unwrap();
6273 assert!(!read.cut.unwrap().contains("reads on"));
6274 }
6275
6276 #[test]
6279 fn a_whole_read_of_a_large_file_is_refused() {
6280 let max = crate::govlog::MAX_WHOLE_READ_BYTES as usize;
6281 let text = lines(max / 100 + 10, 100);
6282 assert!(text.len() > max);
6283 let err = read(text.as_bytes(), 32_768).unwrap_err();
6284 let msg = err.to_string();
6285 for needle in [
6286 format!("a/b.md is {} bytes,", text.len()),
6287 "more than one read shows whole (262144 bytes)".into(),
6288 "with comment_budget=262144 shows up to 262144 bytes".into(),
6289 "DATA-2026-0001:0123456:a/b.md:<from>-<to>".into(),
6290 ] {
6291 assert!(msg.contains(&needle), "{needle}: {msg}");
6292 }
6293 let read = ranged(text.as_bytes(), 32_768, Some("1000-1002")).unwrap();
6294 assert_eq!(read.text.len(), 300);
6295 assert_eq!(read.bytes, text.len() as u64);
6296 }
6297
6298 #[test]
6299 fn a_short_file_is_whole() {
6300 let read = read(b"one\ntwo\n", 32).unwrap();
6301 assert_eq!(read.text, "one\ntwo\n");
6302 assert_eq!(read.omitted_bytes, 0);
6303 assert_eq!(read.cut, None);
6304 assert_eq!(read.bytes, 8);
6305 }
6306
6307 #[test]
6308 fn a_cut_ends_a_line_and_says_so() {
6309 let text = "line one\nline two\nline three\n";
6310 let read = read(text.as_bytes(), 20).unwrap();
6311 assert_eq!(read.text, "line one\nline two\n");
6312 assert_eq!(read.omitted_bytes, 11);
6313 let cut = read.cut.unwrap();
6314 assert!(
6315 cut.contains(
6316 "Cut at the last line break before byte 20 (the \
6317 comment_budget), at byte 18 of 29"
6318 ),
6319 "{cut}"
6320 );
6321 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
6322 assert!(
6323 cut.contains(
6324 "comment_budget=262144 reads the whole file, or lines=\"3-\" \
6325 reads on from line 3."
6326 ),
6327 "{cut}"
6328 );
6329 let long = format!("ab\n{}", "x".repeat(30));
6331 let mid = ranged(long.as_bytes(), 20, None).unwrap();
6332 let cut = mid.cut.unwrap();
6333 assert!(cut.starts_with("Cut at byte 20 of 33"), "{cut}");
6334 assert!(cut.contains("lines=\"2-\" reads on from line 2."), "{cut}");
6335 }
6336
6337 #[test]
6338 fn a_cut_never_splits_a_character() {
6339 let read = read("ééééé".as_bytes(), 5).unwrap();
6341 assert_eq!(read.text, "éé");
6342 assert_eq!(read.omitted_bytes, 6);
6343 assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
6345 }
6346
6347 #[test]
6348 fn the_largest_budget_reads_the_largest_file_whole() {
6349 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
6350 assert_eq!(max as u64, crate::govlog::MAX_WHOLE_READ_BYTES);
6351 let big = "x".repeat(max);
6352 let read = read(big.as_bytes(), max).unwrap();
6353 assert_eq!((read.omitted_bytes, read.cut), (0, None));
6354 }
6355
6356 #[test]
6357 fn binary_is_not_text() {
6358 let Err(DataFileReadError::NotText(err)) =
6359 read(&[0xff, 0xfe, 0x00], 32)
6360 else {
6361 panic!("not text");
6362 };
6363 assert_eq!(err.bytes, 3);
6364 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
6365 }
6366}
6367
6368#[cfg(test)]
6369mod data_set_view_tests {
6370 use super::DataSetView;
6371 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
6372
6373 #[test]
6376 fn the_top_level_starts_with_the_index_and_counts_the_rest() {
6377 let view = DataSetView::top_level_of(&set(300, 7)).unwrap();
6378 let paths: Vec<&str> =
6379 view.listing.iter().map(|l| l.path.as_str()).collect();
6380 assert_eq!(paths, ["votes/README.md", "votes/"]);
6381 assert_eq!(view.listing[1].files, 300);
6382 assert_eq!(view.files, 301);
6383 let again = view.top_level();
6385 assert_eq!(
6386 again
6387 .start
6388 .into_iter()
6389 .chain(again.directories)
6390 .collect::<Vec<_>>(),
6391 view.listing
6392 );
6393 }
6394
6395 #[test]
6398 fn a_directory_lists_one_level() {
6399 let view =
6400 DataSetView::of(&set(30, 3), Some("votes/"), 262_144).unwrap();
6401 let lines = view.one_level();
6402 let paths: Vec<&str> = lines.iter().map(|l| l.path.as_str()).collect();
6403 assert_eq!(
6404 paths,
6405 ["votes/README.md", "votes/d00/", "votes/d01/", "votes/d02/"]
6406 );
6407 assert_eq!(lines[1].files, 10);
6408 }
6409
6410 fn set(n: usize, dirs: usize) -> DataSet {
6412 let mut files: Vec<DataFile> = (0..n)
6413 .map(|i| {
6414 DataFile::of(
6415 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
6416 .parse()
6417 .unwrap(),
6418 b"x\n",
6419 )
6420 })
6421 .collect();
6422 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
6423 files.sort_by(|a, b| a.path.cmp(&b.path));
6424 DataSet {
6425 agora_data_set: DATA_SET_VERSION,
6426 title: "t".into(),
6427 description: "d".into(),
6428 compiled_by: "c".into(),
6429 repo: "agora-data".parse().unwrap(),
6430 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
6431 index: "votes/README.md".parse().unwrap(),
6432 files,
6433 }
6434 }
6435
6436 #[test]
6437 fn a_small_set_lists_every_file() {
6438 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
6439 assert_eq!(view.listing.len(), 5);
6440 assert!(view.listing.iter().all(|l| l.files == 1));
6441 assert_eq!((view.files, view.unlisted), (5, 0));
6442 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
6443 assert!(!view.how_to_read.contains("counted, not named"));
6444 }
6445
6446 #[test]
6447 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
6448 let big = set(1_038, 12);
6449 let view = DataSetView::of(&big, None, 32_768).unwrap();
6450 assert_eq!(view.files, 1_039);
6453 let paths: Vec<&str> =
6454 view.listing.iter().map(|l| l.path.as_str()).collect();
6455 assert_eq!(paths.len(), 13, "{paths:?}");
6456 assert!(paths.contains(&"votes/README.md"), "{paths:?}");
6457 assert!(paths.contains(&"votes/d11/"), "{paths:?}");
6458 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6459 assert!(view.how_to_read.contains("counted, not named"));
6460 assert!(
6461 view.how_to_read.starts_with(
6462 "Read this first: attachment=\"votes/README.md\"."
6463 )
6464 );
6465
6466 let tight = DataSetView::of(&big, None, 300).unwrap();
6468 assert_eq!(tight.listing.len(), 1);
6469 assert_eq!(tight.listing[0].path, "votes/");
6470 assert_eq!(tight.listing[0].files, 1_039);
6471
6472 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
6473 let paths: Vec<&str> =
6474 votes.listing.iter().map(|l| l.path.as_str()).collect();
6475 assert_eq!(paths.len(), 13, "{paths:?}");
6476 assert!(
6477 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
6478 );
6479 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6480 assert_eq!(votes.unlisted, 0);
6481
6482 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
6484 assert!(d00.listing.iter().all(|l| l.files == 1));
6485 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
6486 }
6487
6488 #[test]
6491 fn a_few_top_directories_descend_together() {
6492 let mut big = set(1_038, 12);
6493 for f in big.files.iter_mut() {
6494 let p = f.path.as_str();
6495 let moved = match p.strip_prefix("votes/d0") {
6496 Some(rest) => format!("early/d0{rest}"),
6497 None => p.to_string(),
6498 };
6499 f.path = moved.parse().unwrap();
6500 }
6501 big.files.sort_by(|a, b| a.path.cmp(&b.path));
6502 let view = DataSetView::of(&big, None, 32_768).unwrap();
6503 let paths: Vec<&str> =
6504 view.listing.iter().map(|l| l.path.as_str()).collect();
6505 assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
6506 assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
6507 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
6508 assert_eq!(view.unlisted, 0);
6509 }
6510
6511 #[test]
6512 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
6513 let flat = set(1_000, 1);
6514 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
6515 assert!(view.unlisted > 0);
6516 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
6517 assert!(view.how_to_read.contains("did not fit at all"));
6518 let json = serde_json::to_string(&view).unwrap();
6520 assert!(!json.contains("sha256"), "{json}");
6521 }
6522}
6523
6524#[cfg(test)]
6525mod send_message_response_tests {
6526 use super::*;
6527
6528 #[test]
6531 fn sent_at_round_trips_and_is_optional() {
6532 let at = DateTime::from_timestamp(1_760_000_000, 0).unwrap();
6533 let resp = SendMessageResponse {
6534 id: MessageId::from(uuid::Uuid::from_u128(7)),
6535 encryption: MessageEncryption::Server,
6536 sent_at: Some(at),
6537 notice: None,
6538 warning: None,
6539 };
6540 let json = serde_json::to_value(&resp).unwrap();
6541 assert_eq!(json["sent_at"], "2025-10-09T08:53:20Z");
6542 let back: SendMessageResponse = serde_json::from_value(json).unwrap();
6543 assert_eq!(back.sent_at, Some(at));
6544
6545 let old: SendMessageResponse =
6546 serde_json::from_value(serde_json::json!({
6547 "id": "00000000-0000-0000-0000-000000000007",
6548 "encryption": "server"
6549 }))
6550 .unwrap();
6551 assert_eq!(old.sent_at, None);
6552 }
6553}