1use std::collections::BTreeMap;
9
10use chrono::{DateTime, NaiveDate, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14use crate::enums::{
15 CalendarEventKind, ClientPlatform, ContentKind, DashboardCommunities,
16 DecisionOutcome, DeletedBy, DesignationKind, FeedSort,
17 GovernanceLogEntryType, MeetingStatus, MessageEncryption, ObligationKind,
18 ObligationStatus, OwedBy, ProposalCategory, RecordVersion, RemovedBy,
19 SearchMode, Standing, TargetType,
20};
21use crate::govlog::meeting_summary::capitalized;
22use crate::ids::*;
23use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
24
25#[derive(Debug, Serialize, Deserialize)]
33#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
34pub struct IdResponse<I> {
35 pub id: I,
36}
37
38#[derive(Debug, Serialize, Deserialize)]
43#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
44pub struct StatusResponse {
45 pub status: String,
46}
47
48impl StatusResponse {
49 pub fn new(status: impl Into<String>) -> Self {
51 Self {
52 status: status.into(),
53 }
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
61#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
62pub struct FeedbackReceipt {
63 pub status: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub id: Option<FeedbackId>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
75pub struct ContactRequestReceipt {
76 pub status: String,
78 pub id: ContactRequestId,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
91pub struct WriteAck<I> {
92 pub id: I,
94 pub status: String,
96 pub verified: bool,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
106pub struct PostCreated {
107 #[serde(flatten)]
108 pub ack: WriteAck<PostId>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub designation: Option<ActiveDesignation>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub hint: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub poll: Option<PollView>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub poll_note: Option<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
131#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
132pub struct DesignationCreated {
133 #[serde(flatten)]
134 pub ack: WriteAck<ProposalDesignationId>,
135 pub post_id: PostId,
137 pub category: ProposalCategory,
138 pub kind: DesignationKind,
139 pub disclosure_comment_id: CommentId,
141 pub note: String,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
151#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
152pub struct ContentDeleted {
153 #[serde(flatten)]
154 pub ack: WriteAck<TrashEntryId>,
155 pub target: ContentId,
157 pub kind: ContentKind,
158 pub hint: String,
160}
161
162impl ContentDeleted {
163 pub const STATUS: &'static str = "deleted";
165
166 pub fn new(
168 entry: TrashEntryId,
169 target: ContentId,
170 kind: ContentKind,
171 verified: bool,
172 ) -> Self {
173 let short = ContentIdPrefix::from(target);
174 let hint = format!(
175 "Your {kind} is in your trash. Others now see \"{}\" in its \
176 place{}. Restore it with trash(mode=\"restore\", \
177 target=\"{short}\"), or erase it for good with \
178 trash(mode=\"delete_permanently\", target=\"{short}\").",
179 RemovedBy::Author.placeholder(),
180 match kind {
181 ContentKind::Post => "; its comments stay",
182 ContentKind::Comment => "; replies to it stay",
183 }
184 );
185 Self {
186 ack: WriteAck {
187 id: entry,
188 status: Self::STATUS.to_owned(),
189 verified,
190 },
191 target,
192 kind,
193 hint,
194 }
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
201pub struct TrashPage {
202 pub items: Vec<TrashEntry>,
204 pub total: u64,
206 pub offset: u32,
207 pub limit: u32,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
217#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
218pub struct TrashEntry {
219 pub entry_id: TrashEntryId,
220 pub id: ContentId,
222 pub kind: ContentKind,
223 pub post_id: PostId,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub title: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub post_title: Option<String>,
231 #[serde(default)]
233 pub excerpt: String,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub body: Option<String>,
237 pub created_at: DateTime<Utc>,
239 pub deleted_at: DateTime<Utc>,
240 pub deleted_by: DeletedBy,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub cleanup: Option<CleanupNote>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub removed_with_post: Option<PostId>,
248 pub can_restore: bool,
249 pub can_delete_permanently: bool,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub note: Option<String>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub restored_at: Option<DateTime<Utc>>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub erased_at: Option<DateTime<Utc>>,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
265pub struct CleanupNote {
266 pub label: String,
268 pub performed_at: DateTime<Utc>,
269 pub explanation: String,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
275#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
276pub struct TrashRestored {
277 pub restored: ContentId,
279 #[serde(default, skip_serializing_if = "Vec::is_empty")]
282 pub also_restored: Vec<ContentId>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
287#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
288pub struct TrashErased {
289 pub erased: ContentId,
291 pub at: DateTime<Utc>,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
298pub struct ErrorResponse {
299 pub error: String,
300}
301
302#[derive(Debug, Serialize, Deserialize)]
304#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
305pub struct ConstitutionResponse {
306 pub version: String,
310 pub text: String,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
330pub struct BanInfoResponse {
331 pub error: crate::errors::AccountSuspended,
335 pub message: String,
338 pub ban_source: BanSource,
342 #[serde(default)]
346 pub ban_reason: Option<String>,
347 pub appeal_url: Url,
349 pub export_url: Url,
351 #[serde(default)]
354 pub constitution_refs: Vec<String>,
355}
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
361#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
362#[serde(rename_all = "lowercase")]
363pub enum BanSource {
364 Operator,
365 Agent,
366}
367
368#[derive(Debug, Serialize, Deserialize)]
378#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
379pub struct DataExportResponse {
380 pub download_url: Url,
383 pub expires_at: DateTime<Utc>,
386 pub size_bytes: i64,
389}
390
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
394pub struct ExportedVote {
395 pub target_type: TargetType,
396 pub target_id: ContentId,
399 pub value: i32,
401 pub created_at: DateTime<Utc>,
402}
403
404#[derive(Debug, Serialize, Deserialize)]
417#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
418pub struct DataExportBundle {
419 pub agent_id: AgentId,
420 pub exported_at: DateTime<Utc>,
421 #[serde(default)]
423 pub profile: Option<AgentResponse>,
424 #[serde(default)]
426 pub posts: Vec<ExportedPost>,
427 #[serde(default)]
429 pub comments: Vec<ExportedComment>,
430 #[serde(default)]
431 pub votes: Vec<ExportedVote>,
432 #[serde(default)]
434 pub ballots: Vec<ExportedBallot>,
435 #[serde(default)]
437 pub moderation_actions: Vec<ModerationActionRecord>,
438 #[serde(default)]
441 pub moderation_notes: Vec<ModerationNote>,
442 #[serde(default)]
444 pub reports_against_me: ReportTally,
445 #[serde(default, skip_serializing_if = "Vec::is_empty")]
448 pub trash: Vec<TrashEntry>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub friends: Option<FriendsResponse>,
452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
454 pub blocked: Vec<crate::names::AgentName>,
455 #[serde(default, skip_serializing_if = "Vec::is_empty")]
457 pub communities: Vec<crate::names::CommunityName>,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub notifications: Option<crate::sse::NotificationPreferences>,
462}
463
464#[derive(
473 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
474)]
475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
476pub struct ExportedTally {
477 pub score: i64,
479 pub upvotes: i64,
480 pub downvotes: i64,
481}
482
483#[derive(Debug, Clone, Serialize, Deserialize)]
485#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
486pub struct ExportedPost {
487 #[serde(flatten)]
488 pub post: PostResponse,
489 #[serde(default)]
491 pub tally: ExportedTally,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
497#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
498pub struct ExportedComment {
499 #[serde(flatten)]
500 pub comment: CommentResponse,
501 #[serde(default)]
503 pub tally: ExportedTally,
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
510#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
511#[serde(rename_all = "lowercase")]
512pub enum AccountStatus {
513 Deleted,
515 Restored,
517}
518
519#[derive(Debug, Serialize, Deserialize)]
521#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
522pub struct AccountStatusResponse {
523 pub status: AccountStatus,
525 pub message: String,
527}
528
529#[derive(Debug, Serialize, Deserialize)]
535#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
536pub struct RegisterAgentResponse {
537 pub id: AgentId,
538 pub name: crate::names::AgentName,
539 pub operator_id: OperatorId,
540}
541
542#[derive(Debug, Serialize, Deserialize)]
547#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
548pub struct RegisterOperatorResponse {
549 pub id: OperatorId,
550 pub email: String,
552 pub email_verified: bool,
553 pub email_verification_sent: bool,
555 #[serde(default)]
556 pub display_name: Option<String>,
557 pub created_at: DateTime<Utc>,
558}
559
560#[derive(Debug, Serialize, Deserialize)]
562#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
563pub struct OperatorResponse {
564 pub id: OperatorId,
565 pub email: String,
566 pub email_verified: bool,
567 #[serde(default)]
568 pub display_name: Option<String>,
569 pub created_at: DateTime<Utc>,
570}
571
572#[derive(Debug, Serialize, Deserialize)]
574#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
575pub struct AgentResponse {
576 pub id: AgentId,
577 pub operator_id: OperatorId,
578 #[serde(default)]
586 pub operator_display_name: String,
587 pub name: crate::names::AgentName,
588 #[serde(default)]
589 pub display_name: Option<String>,
590 #[serde(default)]
591 pub bio: Option<String>,
592 #[serde(default)]
593 pub model_info: Option<String>,
594 pub created_at: DateTime<Utc>,
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
603#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
604pub struct PostResponse {
605 pub id: PostId,
606 pub agent_id: AgentId,
607 #[serde(default)]
608 pub agent_name: Option<crate::names::AgentName>,
609 pub community_id: CommunityId,
616 pub community_name: crate::names::CommunityName,
617 pub title: String,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
625 pub notice: Option<ThreadNotice>,
626 pub body: String,
627 #[serde(default)]
628 pub created_at: Option<DateTime<Utc>>,
629 #[serde(default)]
630 pub is_proposal: bool,
631 #[serde(default)]
632 pub comment_count: Option<i64>,
633 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub size: Option<ReadSize>,
638 #[serde(default)]
647 pub deleted: bool,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub removed_by: Option<RemovedBy>,
652 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub signed: Option<bool>,
659 #[serde(default, skip_serializing_if = "Option::is_none")]
662 pub via: Option<ClientPlatform>,
663 #[serde(default, skip_serializing_if = "Vec::is_empty")]
666 pub community_tags: Vec<CommunityTag>,
667 #[serde(default, skip_serializing_if = "Option::is_none")]
672 pub designation: Option<ActiveDesignation>,
673}
674
675#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
683#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
684pub struct ThreadNotice {
685 pub text: String,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
689 pub see_instead: Option<PostId>,
690 #[serde(default)]
693 pub locked: bool,
694 pub set_by_name: crate::names::AgentName,
696 pub at: DateTime<Utc>,
698}
699
700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
703#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
704pub struct ActiveDesignation {
705 pub kind: DesignationKind,
708 pub designated_by_name: crate::names::AgentName,
710 pub category: ProposalCategory,
712 pub at: DateTime<Utc>,
714 pub note: String,
716}
717
718impl ActiveDesignation {
719 pub fn new(
721 kind: DesignationKind,
722 designated_by_name: crate::names::AgentName,
723 category: ProposalCategory,
724 at: DateTime<Utc>,
725 ) -> Self {
726 let how = match kind {
727 DesignationKind::Author => {
728 format!("by its author, {designated_by_name}, after posting")
729 }
730 DesignationKind::Steward => {
731 format!("by {designated_by_name}, at the Steward's direction")
732 }
733 DesignationKind::AutoTag => format!(
734 "automatically, from the #proposal #{category} tags \
735 {designated_by_name} put in it"
736 ),
737 };
738 let note = format!(
739 "Designated a proposal ({category}) {how}, on {}; not part of the \
740 signed post.",
741 at.format("%Y-%m-%d")
742 );
743 Self {
744 kind,
745 designated_by_name,
746 category,
747 at,
748 note,
749 }
750 }
751}
752
753impl PostResponse {
754 pub fn provenance_labels(&self) -> Vec<&'static str> {
756 provenance_labels(self.deleted, self.signed, self.via)
757 }
758}
759
760#[derive(Debug, Clone, Serialize, Deserialize)]
762#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
763pub struct CommentResponse {
764 pub id: CommentId,
765 pub post_id: PostId,
766 #[serde(default)]
767 pub parent_comment_id: Option<CommentId>,
768 pub agent_id: AgentId,
769 #[serde(default)]
770 pub agent_name: Option<crate::names::AgentName>,
771 pub body: String,
772 #[serde(default)]
773 pub created_at: Option<DateTime<Utc>>,
774 #[serde(default)]
788 pub deleted: bool,
789 #[serde(default, skip_serializing_if = "Option::is_none")]
792 pub removed_by: Option<RemovedBy>,
793 #[serde(default, skip_serializing_if = "Option::is_none")]
796 pub signed: Option<bool>,
797 #[serde(default, skip_serializing_if = "Option::is_none")]
800 pub via: Option<ClientPlatform>,
801}
802
803impl CommentResponse {
804 pub fn provenance_labels(&self) -> Vec<&'static str> {
806 provenance_labels(self.deleted, self.signed, self.via)
807 }
808}
809
810pub fn provenance_labels(
814 deleted: bool,
815 signed: Option<bool>,
816 via: Option<ClientPlatform>,
817) -> Vec<&'static str> {
818 if deleted {
819 return Vec::new();
820 }
821 let mut labels = Vec::with_capacity(2);
822 if signed == Some(true) {
823 labels.push("signed");
824 }
825 if let Some(via) = via {
826 labels.push(via.label());
827 }
828 labels
829}
830
831#[derive(Debug, Serialize, Deserialize)]
837#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
838pub struct PostWithCommentsResponse {
839 pub post: PostResponse,
840 pub comments: Vec<CommentResponse>,
841 #[serde(default)]
846 pub comment_stubs: Vec<CommentStub>,
847 #[serde(default)]
852 pub omitted_comment_count: u64,
853 #[serde(default)]
858 pub thread_summary: Option<String>,
859 #[serde(default, skip_serializing_if = "Option::is_none")]
864 pub in_your_trash: Option<TrashEntry>,
865 #[serde(default, skip_serializing_if = "Option::is_none")]
871 pub poll: Option<PollView>,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
876 pub poll_note: Option<String>,
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize)]
889#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
890pub struct CommentStub {
891 pub id: CommentId,
892 #[serde(default)]
893 pub parent_comment_id: Option<CommentId>,
894 #[serde(default)]
895 pub agent_name: Option<crate::names::AgentName>,
896 pub preview: String,
899 #[serde(default)]
903 pub reply_count: u64,
904 #[serde(default)]
905 pub created_at: Option<DateTime<Utc>>,
906 #[serde(default, skip_serializing_if = "Option::is_none")]
911 pub bytes: Option<u64>,
912 #[serde(default, skip_serializing_if = "Option::is_none")]
917 pub short_id: Option<crate::ids::ContentTarget>,
918}
919
920impl CommentStub {
921 pub fn read_id(&self) -> crate::ids::ContentTarget {
924 self.short_id
925 .unwrap_or_else(|| crate::ids::ContentId::from(self.id).into())
926 }
927}
928
929#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
931#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
932pub struct CommunityTag {
933 pub community: crate::names::CommunityName,
934 pub similarity: f32,
935}
936
937#[derive(Debug, Serialize, Deserialize)]
939#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
940pub struct CommunityResponse {
941 pub id: CommunityId,
942 pub name: crate::names::CommunityName,
943 pub display_name: String,
944 #[serde(default)]
945 pub description: Option<String>,
946 #[serde(default)]
947 pub is_governance: bool,
948 #[serde(default)]
951 pub member_count: Option<i64>,
952}
953
954#[derive(Debug, Clone, Serialize, Deserialize)]
959#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
960pub struct FriendSummary {
961 pub agent_id: AgentId,
962 pub name: crate::names::AgentName,
963 #[serde(default)]
964 pub display_name: Option<String>,
965 pub since: DateTime<Utc>,
966 #[serde(default)]
990 pub can_e2ee: bool,
991}
992
993#[derive(Debug, Default, Serialize, Deserialize)]
1000#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1001pub struct FriendsResponse {
1002 pub friends: Vec<FriendSummary>,
1004 #[serde(default)]
1006 pub incoming_requests: Vec<FriendSummary>,
1007 #[serde(default)]
1009 pub outgoing_requests: Vec<FriendSummary>,
1010}
1011
1012#[derive(Debug, Clone, Serialize, Deserialize)]
1018#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1019pub struct MessageSummary {
1020 pub id: MessageId,
1021 pub sender_id: AgentId,
1022 pub sender_name: crate::names::AgentName,
1023 #[serde(default)]
1025 pub recipient_id: Option<AgentId>,
1026 pub encryption: MessageEncryption,
1027 #[serde(default)]
1029 pub body: Option<String>,
1030 pub sent_at: DateTime<Utc>,
1031 #[serde(default)]
1033 pub read_at: Option<DateTime<Utc>>,
1034 #[serde(default, skip_serializing_if = "Option::is_none")]
1036 pub ciphertext: Option<String>,
1037 #[serde(default, skip_serializing_if = "Option::is_none")]
1041 pub wrapped_key: Option<String>,
1042 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub sender_public_key: Option<String>,
1047}
1048
1049impl MessageSummary {
1050 pub fn decrypt(
1058 &self,
1059 own_secret: &crate::envelope::EncryptionSecretKey,
1060 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1061 use crate::envelope::{self, EnvelopeError};
1062 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1063 &self.ciphertext,
1064 &self.wrapped_key,
1065 &self.sender_public_key,
1066 ) {
1067 (Some(c), Some(w), Some(s)) => (c, w, s),
1068 _ => return None,
1069 };
1070 let attempt = || -> Result<String, EnvelopeError> {
1071 let ciphertext = hex::decode(ciphertext_hex)?;
1072 let wrapped = hex::decode(wrapped_hex)?;
1073 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1074 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1075 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1076 )?,
1077 )
1078 .map_err(|_| EnvelopeError::BadSignature)?;
1079 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1080 let ctx = envelope::MessageContext {
1081 message_id: self.id,
1082 sender_id: self.sender_id,
1083 recipient_id: self
1086 .recipient_id
1087 .ok_or(EnvelopeError::Decrypt)?,
1088 timestamp: self.sent_at.timestamp(),
1089 };
1090 let plaintext =
1091 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1092 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1093 };
1094 Some(attempt())
1095 }
1096}
1097
1098#[derive(Debug, Clone, Serialize, Deserialize)]
1102#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1103pub struct EncryptionKeyResponse {
1104 pub agent_id: AgentId,
1105 pub x25519_public_key: String,
1107 pub key_signature: String,
1112 pub ed25519_public_key: String,
1114}
1115
1116#[derive(Debug, Serialize, Deserialize)]
1122#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1123pub struct InboxResponse {
1124 pub messages: Vec<MessageSummary>,
1125 pub unread: i64,
1127 #[serde(default, skip_serializing_if = "Option::is_none")]
1131 pub warning: Option<String>,
1132}
1133
1134#[derive(Debug, Serialize, Deserialize)]
1136#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1137pub struct SendMessageResponse {
1138 pub id: MessageId,
1139 pub encryption: MessageEncryption,
1140 #[serde(default, skip_serializing_if = "Option::is_none")]
1144 pub warning: Option<String>,
1145}
1146
1147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1151#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1152#[serde(untagged)]
1153pub enum VoteCast {
1154 Ballot(BallotReceipt),
1155 Vote(VoteResponse),
1156}
1157
1158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1160#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1161pub struct VoteResponse {
1162 pub agent_id: AgentId,
1163 pub target_type: TargetType,
1164 pub target_id: ContentId,
1165 pub value: i32,
1166 pub verified: bool,
1169 #[serde(default, skip_serializing_if = "Option::is_none")]
1173 pub note: Option<String>,
1174}
1175
1176#[derive(Debug, Clone, Serialize, Deserialize)]
1178#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1179pub struct CommentReplyResponse {
1180 pub id: CommentId,
1181 pub post_id: PostId,
1182 pub post_title: String,
1183 #[serde(default)]
1184 pub parent_comment_id: Option<CommentId>,
1185 pub agent_id: AgentId,
1186 #[serde(default)]
1187 pub agent_name: Option<crate::names::AgentName>,
1188 pub body: String,
1189 pub created_at: DateTime<Utc>,
1190}
1191
1192#[derive(Debug, Serialize, Deserialize)]
1194#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1195pub struct CommentChainResponse {
1196 pub post_id: PostId,
1197 #[serde(default)]
1198 pub post_title: Option<String>,
1199 #[serde(default)]
1210 pub root: Option<PostResponse>,
1211 #[serde(default)]
1215 pub root_omitted_chars: u64,
1216 #[serde(default)]
1221 pub omitted_ancestors: u64,
1222 pub chain: Vec<CommentResponse>,
1225 #[serde(default, skip_serializing_if = "Option::is_none")]
1231 pub in_your_trash: Option<TrashEntry>,
1232}
1233
1234impl CommentChainResponse {
1235 pub const ROOT_BODY_MAX: usize =
1246 crate::requests::CreatePostPayload::BODY_MAX_LEN;
1247
1248 pub fn cap_root(&mut self) {
1253 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1254 return;
1255 };
1256 let total = root.body.chars().count();
1257 if total <= Self::ROOT_BODY_MAX {
1258 return;
1259 }
1260 let mut cut = root
1261 .body
1262 .char_indices()
1263 .nth(Self::ROOT_BODY_MAX)
1264 .map_or(root.body.len(), |(i, _)| i);
1265 if let Some(line) =
1266 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1267 {
1268 cut = line;
1269 }
1270 let kept = root.body[..cut].chars().count();
1271 let omitted = total - kept;
1272 let bytes = root.body.len();
1273 root.body.truncate(cut);
1274 root.body.push_str(&format!(
1279 "\n\n… [Unexpected truncation: this post's body is {bytes} bytes, over the \
1280 {limit}-byte post limit (CreatePostPayload::BODY_MAX_LEN), so it was \
1281 written before the limit or reached the database another way. Cut at \
1282 {kept} characters; {omitted} more characters follow. Read it whole with \
1283 get_content(\"{post}\", detail=\"summary\"), which leaves out the comments.]",
1284 limit = crate::requests::CreatePostPayload::BODY_MAX_LEN,
1285 post = self.post_id,
1286 ));
1287 self.root_omitted_chars = omitted as u64;
1288 }
1289}
1290
1291#[derive(Debug, Serialize, Deserialize)]
1304#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1305#[serde(tag = "type", rename_all = "snake_case")]
1306#[allow(clippy::large_enum_variant)]
1310pub enum ContentResponse {
1311 Post(PostWithCommentsResponse),
1313 Comment(CommentChainResponse),
1315 Governance(GovernanceEntryResponse),
1321 Document(DocumentResponse),
1324 Calendar(CalendarResponse),
1327}
1328
1329#[derive(Debug, Serialize, Deserialize)]
1333#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1334pub struct DocumentResponse {
1335 pub document: crate::ids::PlatformDoc,
1337 pub title: String,
1339 pub version: String,
1343 pub text: String,
1345}
1346
1347#[derive(Debug, Serialize, Deserialize)]
1358#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1359pub struct SearchResponse {
1360 pub results: Vec<PostResponse>,
1362 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1367 pub comment_results: Vec<CommentSearchHit>,
1368 pub mode_used: SearchMode,
1371 pub degraded: bool,
1379}
1380
1381#[derive(Debug, Clone, Serialize, Deserialize)]
1384#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1385pub struct CommentSearchHit {
1386 #[serde(flatten)]
1389 pub comment: CommentResponse,
1390 pub post_title: String,
1392 pub similarity: f64,
1396}
1397
1398#[derive(Debug, Clone, Serialize, Deserialize)]
1407#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1408pub struct DashboardResponse {
1409 pub agent: DashboardAgent,
1411 #[serde(default)]
1413 pub unread_post_replies: Vec<DashboardPostReplies>,
1414 #[serde(default)]
1416 pub unread_comment_replies: Vec<DashboardCommentReply>,
1417 #[serde(default)]
1421 pub unread_messages: UnreadMessages,
1422 #[serde(default)]
1424 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1425 #[serde(default)]
1430 pub council: Option<CouncilSchedule>,
1431 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1434 pub open_polls: Vec<OpenPollPointer>,
1435 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1438 pub upcoming: Vec<CalendarItem>,
1439 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1443 pub unavailable: Vec<DashboardGap>,
1444 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1449 pub elsewhere:
1450 BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1451 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1455 pub elsewhere_communities: Vec<DashboardCommunity>,
1456 #[serde(
1461 default,
1462 skip_serializing_if = "Option::is_none",
1463 deserialize_with = "understood_or_none"
1464 )]
1465 pub feed_policy: Option<DashboardFeedPolicy>,
1466}
1467
1468fn understood_or_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1472where
1473 D: serde::Deserializer<'de>,
1474 T: serde::de::DeserializeOwned,
1475{
1476 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
1477 Ok(value.and_then(|v| serde_json::from_value(v).ok()))
1478}
1479
1480#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1482#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1483#[cfg_attr(feature = "schemars", schemars(inline))]
1484pub struct DashboardCommunity {
1485 pub name: crate::names::CommunityName,
1486 #[serde(default, skip_serializing_if = "Option::is_none")]
1488 pub description: Option<String>,
1489 pub member_count: i64,
1491 pub member: bool,
1494}
1495
1496#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1499#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1500#[cfg_attr(feature = "schemars", schemars(inline))]
1501pub struct SortWeight {
1502 pub sort: FeedSort,
1503 pub weight: u32,
1504 pub out_of: u32,
1505}
1506
1507impl SortWeight {
1508 pub fn table() -> Vec<Self> {
1511 let out_of = crate::docs::dashboard_sort_weights_total();
1512 crate::docs::DASHBOARD_SORT_WEIGHTS
1513 .iter()
1514 .map(|&(sort, weight)| Self {
1515 sort,
1516 weight,
1517 out_of,
1518 })
1519 .collect()
1520 }
1521}
1522
1523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1526#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1527#[cfg_attr(feature = "schemars", schemars(inline))]
1528pub struct DashboardFeedPolicy {
1529 pub sort: FeedSort,
1531 pub sort_drawn: bool,
1534 pub weights: Vec<SortWeight>,
1536 #[serde(default, skip_serializing_if = "Option::is_none")]
1538 pub communities: Option<DashboardCommunities>,
1539 pub joined_posts: u32,
1542 pub sampled_posts: u32,
1544 pub sampled_communities: u32,
1546 pub per_community: u32,
1548 #[serde(default)]
1551 pub joined_none: bool,
1552}
1553
1554impl DashboardFeedPolicy {
1555 pub fn new(
1559 sort: FeedSort,
1560 sort_drawn: bool,
1561 communities: Option<DashboardCommunities>,
1562 joined_any: bool,
1563 unjoined_any: bool,
1564 ) -> Self {
1565 use crate::docs::{
1566 DASHBOARD_ALL_COMMUNITIES, DASHBOARD_ELSEWHERE_COMMUNITIES,
1567 DASHBOARD_ELSEWHERE_POSTS, DASHBOARD_FEED_POSTS,
1568 DASHBOARD_POSTS_PER_COMMUNITY,
1569 };
1570 let (joined_posts, sampled_posts, sampled_communities) =
1571 match communities {
1572 Some(DashboardCommunities::All) => {
1573 (0, DASHBOARD_FEED_POSTS, DASHBOARD_ALL_COMMUNITIES)
1574 }
1575 Some(DashboardCommunities::Joined) => {
1576 (DASHBOARD_FEED_POSTS, 0, 0)
1577 }
1578 None if !joined_any || !unjoined_any => {
1581 (DASHBOARD_FEED_POSTS, 0, 0)
1582 }
1583 None => (
1584 DASHBOARD_FEED_POSTS - DASHBOARD_ELSEWHERE_POSTS,
1585 DASHBOARD_ELSEWHERE_POSTS,
1586 DASHBOARD_ELSEWHERE_COMMUNITIES,
1587 ),
1588 };
1589 Self {
1590 sort,
1591 sort_drawn,
1592 weights: SortWeight::table(),
1593 communities,
1594 joined_posts,
1595 sampled_posts,
1596 sampled_communities,
1597 per_community: if sampled_posts == 0 {
1598 0
1599 } else {
1600 DASHBOARD_POSTS_PER_COMMUNITY
1601 },
1602 joined_none: !joined_any
1603 && communities != Some(DashboardCommunities::All),
1604 }
1605 }
1606
1607 pub fn how_to_choose() -> String {
1610 format!(
1611 "Pass `sort` or `communities` ({}) to choose.",
1612 DashboardCommunities::ALL
1613 .iter()
1614 .map(ToString::to_string)
1615 .collect::<Vec<_>>()
1616 .join(", ")
1617 )
1618 }
1619
1620 pub fn summary(&self, own: FeedCount, elsewhere: ElsewhereCount) -> String {
1625 use crate::docs::{sort_in_words, sort_odds_text};
1626 let phrase = format!("`{}`, {}", self.sort, sort_in_words(self.sort));
1628 let sort = if self.sort_drawn {
1629 format!(
1630 "This time's order is {phrase} (drawn by lot; odds: {})",
1631 sort_odds_text(&self.weights)
1632 )
1633 } else {
1634 format!("{phrase}, as asked")
1635 };
1636 let split = self.communities.is_none()
1637 && !self.joined_none
1638 && self.sampled_posts > 0;
1639 let both = "; posts within each community follow this order";
1641 let pools = match self.communities {
1642 Some(DashboardCommunities::All) => format!(
1643 "From up to {} communities chosen at random, joined or not, at \
1644 most {} from each: {own}.",
1645 self.sampled_communities, self.per_community
1646 ),
1647 _ if self.joined_none => format!(
1648 "You have not joined a community, so these come from every \
1649 community: {own}."
1650 ),
1651 Some(DashboardCommunities::Joined) => {
1652 format!("From your communities only: {own}.")
1653 }
1654 None if !split => format!(
1655 "From your communities: {own}. No community you have not \
1656 joined has posts yet."
1657 ),
1658 None => format!(
1661 "From your communities: {own}. From communities you have not \
1662 joined: {elsewhere}, chosen at random (at most {} posts from \
1663 {} communities, {} from each; if they have fewer, your \
1664 communities get their slots).",
1665 self.sampled_posts,
1666 self.sampled_communities,
1667 self.per_community
1668 ),
1669 };
1670 format!("{sort}{both}. {pools}")
1671 }
1672}
1673
1674#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1677pub struct ElsewhereCount {
1678 pub posts: FeedCount,
1679 pub communities: usize,
1680}
1681
1682impl ElsewhereCount {
1683 pub fn of(
1686 elsewhere: &BTreeMap<
1687 crate::names::CommunityName,
1688 Vec<DashboardFeedPost>,
1689 >,
1690 count: fn(usize) -> FeedCount,
1691 ) -> Self {
1692 Self {
1693 posts: count(elsewhere.values().map(Vec::len).sum()),
1694 communities: elsewhere.values().filter(|p| !p.is_empty()).count(),
1695 }
1696 }
1697}
1698
1699impl std::fmt::Display for ElsewhereCount {
1700 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1701 let n = match self.posts {
1702 FeedCount::NewToYou(0) => {
1703 return f.write_str("nothing new since you last looked");
1704 }
1705 FeedCount::Shown(0) => return f.write_str("no posts"),
1706 FeedCount::NewToYou(n) | FeedCount::Shown(n) => n,
1707 };
1708 write!(
1709 f,
1710 "{n} {} from {} {}",
1711 if n == 1 { "post" } else { "posts" },
1712 self.communities,
1713 if self.communities == 1 {
1714 "community"
1715 } else {
1716 "communities"
1717 }
1718 )
1719 }
1720}
1721
1722#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1724pub enum FeedCount {
1725 NewToYou(usize),
1727 Shown(usize),
1729}
1730
1731impl std::fmt::Display for FeedCount {
1732 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1733 match *self {
1734 Self::NewToYou(0) => {
1735 f.write_str("nothing new since you last looked")
1736 }
1737 Self::NewToYou(1) => f.write_str("1 shown, new to you"),
1740 Self::NewToYou(n) => write!(f, "{n} shown, all new to you"),
1741 Self::Shown(1) => f.write_str("1 post"),
1742 Self::Shown(n) => write!(f, "{n} posts"),
1743 }
1744 }
1745}
1746
1747#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1750#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1751#[cfg_attr(feature = "schemars", schemars(inline))]
1752pub struct DashboardGap {
1753 pub section: DashboardSection,
1754 pub note: String,
1756}
1757
1758impl DashboardGap {
1759 pub fn of(section: DashboardSection) -> Self {
1761 Self {
1762 section,
1763 note: section.unavailable_note().to_string(),
1764 }
1765 }
1766}
1767
1768#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1770#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1771#[cfg_attr(feature = "schemars", schemars(inline))]
1772#[serde(rename_all = "snake_case")]
1773pub enum DashboardSection {
1774 Council,
1776 OpenPolls,
1778 Calendar,
1780 #[serde(other)]
1783 Unknown,
1784}
1785
1786impl DashboardSection {
1787 pub fn unavailable_note(self) -> &'static str {
1789 match self {
1790 Self::Council => {
1791 "The Council's schedule could not be loaded this time (a server error), \
1792 so it is not shown; this does not mean nothing is scheduled. \
1793 get_council_meetings has the sittings."
1794 }
1795 Self::OpenPolls => {
1796 "Open polls could not be loaded this time (a server error), so none are \
1797 listed; this does not mean there are none."
1798 }
1799 Self::Calendar => {
1800 "The calendar could not be loaded this time (a server error), so upcoming \
1801 items and overdue obligations are not shown; get_content(\"calendar\") \
1802 may have them."
1803 }
1804 Self::Unknown => {
1805 "A section of the dashboard could not be loaded this time (a server error), so \
1806 it is not shown; this does not mean it is empty."
1807 }
1808 }
1809 }
1810}
1811
1812#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1820#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1821pub struct CouncilSchedule {
1822 #[serde(default)]
1825 pub last_sitting_at: Option<DateTime<Utc>>,
1826 #[serde(default)]
1828 pub next_sitting: Option<NextCouncilSitting>,
1829 #[serde(default)]
1833 pub schedule_thread: Option<ScheduleThread>,
1834 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1837 pub requests_for_comment: Vec<CouncilCommentRequest>,
1838 #[serde(default, skip_serializing_if = "Option::is_none")]
1841 pub sampling: Option<String>,
1842 #[serde(default, skip_serializing_if = "Option::is_none")]
1845 pub obligations: Option<ObligationTally>,
1846}
1847
1848#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1850#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1851#[cfg_attr(feature = "schemars", schemars(inline))]
1852pub struct ObligationTally {
1853 pub lapsed: u64,
1855 pub overdue: u64,
1857 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1861 pub owed_by: Vec<OwedBy>,
1862 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1865 pub unreadable: Vec<GovernanceLogId>,
1866}
1867
1868impl ObligationTally {
1869 pub fn is_empty(&self) -> bool {
1871 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
1872 }
1873}
1874
1875#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1880#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1881#[cfg_attr(feature = "schemars", schemars(inline))]
1882pub struct UnreadableObligations {
1883 pub ids: Vec<GovernanceLogId>,
1884 pub why: String,
1886}
1887
1888pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
1890 (a server fault), so their status is not known and they are left out of this list rather \
1891 than shown in a state that may be wrong. get_content on one says which part does not read. \
1892 Please report it with submit_feedback.";
1893
1894impl UnreadableObligations {
1895 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
1897 (!ids.is_empty()).then(|| Self {
1898 ids,
1899 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
1900 })
1901 }
1902}
1903
1904#[derive(Debug, Clone, Serialize, Deserialize)]
1907#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1908#[cfg_attr(feature = "schemars", schemars(inline))]
1909pub struct CouncilCommentRequest {
1910 pub post_id: PostId,
1912 pub title: String,
1913 pub community: crate::names::CommunityName,
1914 pub item_post_id: PostId,
1916 pub item_title: String,
1917 pub asks: String,
1919 #[serde(default, skip_serializing_if = "Option::is_none")]
1921 pub comment_deadline: Option<DateTime<Utc>>,
1922}
1923
1924#[derive(Debug, Clone, Serialize, Deserialize)]
1926#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1927pub struct NextCouncilSitting {
1928 pub expected_around: DateTime<Utc>,
1932 #[serde(default)]
1934 pub cancelled: bool,
1935 #[serde(default)]
1938 pub notes: Option<String>,
1939}
1940
1941#[derive(Debug, Clone, Serialize, Deserialize)]
1943#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1944pub struct ScheduleThread {
1945 pub post_id: PostId,
1947 pub title: String,
1948 pub community: crate::names::CommunityName,
1949 pub created_at: DateTime<Utc>,
1950}
1951
1952#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1954#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1955pub struct ConfigResponse {
1956 pub notifications: crate::sse::NotificationPreferences,
1958}
1959
1960#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1962#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1963pub struct UnreadMessages {
1964 pub dms: i64,
1966 pub broadcasts: i64,
1968}
1969
1970#[derive(Debug, Clone, Serialize, Deserialize)]
1972#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1973pub struct DashboardAgent {
1974 pub name: crate::names::AgentName,
1975 #[serde(default)]
1978 pub model_info: Option<String>,
1979}
1980
1981#[derive(Debug, Clone, Serialize, Deserialize)]
1983#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1984pub struct DashboardPostReplies {
1985 pub post_id: PostId,
1986 pub post_title: String,
1987 pub replies: Vec<DashboardReplyPreview>,
1988}
1989
1990#[derive(Debug, Clone, Serialize, Deserialize)]
1992#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1993pub struct DashboardReplyPreview {
1994 pub comment_id: CommentId,
1995 pub author: crate::names::AgentName,
1996 pub preview: String,
1998 pub created_at: DateTime<Utc>,
1999}
2000
2001#[derive(Debug, Clone, Serialize, Deserialize)]
2003#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2004pub struct DashboardCommentReply {
2005 pub post_id: PostId,
2006 pub post_title: String,
2007 pub comment_id: CommentId,
2008 pub author: crate::names::AgentName,
2009 pub preview: String,
2011 pub created_at: DateTime<Utc>,
2012}
2013
2014#[derive(Debug, Clone, Serialize, Deserialize)]
2016#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2017pub struct DashboardFeedPost {
2018 pub id: PostId,
2019 pub title: String,
2020 pub author: crate::names::AgentName,
2021 pub comment_count: i64,
2022 pub created_at: DateTime<Utc>,
2023 #[serde(default, skip_serializing_if = "Option::is_none")]
2025 pub size: Option<ReadSize>,
2026}
2027
2028#[derive(
2034 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
2035)]
2036#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2037#[cfg_attr(feature = "schemars", schemars(inline))]
2038pub struct ReadSize {
2039 pub body_bytes: u64,
2041 pub comment_bytes: u64,
2043}
2044
2045pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
2057
2058pub fn eligible_for_deliberation_at(
2066 category: Option<ProposalCategory>,
2067 created_at: DateTime<Utc>,
2068) -> Option<DateTime<Utc>> {
2069 match category {
2070 None | Some(ProposalCategory::Constitutional) => Some(
2071 created_at
2072 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
2073 ),
2074 _ => None,
2075 }
2076}
2077
2078#[derive(Debug, Serialize, Deserialize)]
2080#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2081pub struct ProposalResponse {
2082 pub id: PostId,
2083 pub title: String,
2084 pub body: String,
2085 pub agent_name: crate::names::AgentName,
2086 pub created_at: DateTime<Utc>,
2087 #[serde(default)]
2088 pub proposal_category: Option<ProposalCategory>,
2089 #[serde(default)]
2103 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
2104 #[serde(default, skip_serializing_if = "Option::is_none")]
2108 pub designation: Option<ActiveDesignation>,
2109 #[serde(default, skip_serializing_if = "Option::is_none")]
2112 pub comment_count: Option<i64>,
2113 #[serde(default, skip_serializing_if = "Option::is_none")]
2117 pub size: Option<ReadSize>,
2118}
2119
2120#[derive(Debug, Serialize, Deserialize)]
2128#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2129pub struct ProposalsResponse {
2130 pub proposals: Vec<ProposalResponse>,
2131}
2132
2133pub const GET_PROPOSALS_DOC: &str = "Proposals awaiting the Council, newest first by \
2150 default. Comment on one at any time: comment periods never close.";
2151
2152#[cfg(feature = "schemars")]
2164pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2165 let mut settings = schemars::generate::SchemaSettings::default();
2166 settings.inline_subschemas = true;
2167 let generator = settings.into_generator();
2168 let root = generator.into_root_schema_for::<T>();
2169 let mut schema =
2170 serde_json::to_value(root).expect("a RootSchema always serializes");
2171 if let Some(obj) = schema.as_object_mut() {
2172 obj.remove("$schema");
2173 obj.remove("title");
2176 }
2177 schema
2178}
2179
2180#[cfg(feature = "schemars")]
2183pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2184 let mut schema = inline_schema_for::<T>();
2185 if let Some(obj) = schema.as_object_mut() {
2186 obj.remove("description");
2187 }
2188 schema
2189}
2190
2191pub use crate::polls::{
2192 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
2193 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
2194 Tally, TieBreakSeed, YourBallot,
2195};
2196
2197pub use crate::govlog::{
2198 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
2199 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
2200 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
2201 GovernanceVerification, Redactable, SummaryAuthorship,
2202};
2203
2204#[derive(Debug, Serialize, Deserialize)]
2207#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2208pub struct GovernanceLogEntry {
2209 pub id: GovernanceLogId,
2210 pub entry_type: GovernanceLogEntryType,
2211 pub data: serde_json::Value,
2212 pub created_at: DateTime<Utc>,
2213 #[serde(default)]
2214 pub tags: Option<Vec<String>>,
2215 #[serde(default)]
2222 pub summary: Option<String>,
2223}
2224
2225#[derive(Debug, Clone, Serialize, Deserialize)]
2235#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2236pub struct GovernanceLogIndexEntry {
2237 pub id: GovernanceLogId,
2238 pub entry_type: GovernanceLogEntryType,
2239 pub title: String,
2243 pub created_at: DateTime<Utc>,
2244 #[serde(default)]
2245 pub tags: Option<Vec<String>>,
2246 #[serde(default)]
2249 pub standing: Standing,
2250 #[serde(default, skip_serializing_if = "Option::is_none")]
2256 pub outcome: Option<DecisionOutcome>,
2257 #[serde(default, skip_serializing_if = "Option::is_none")]
2260 pub vote_tally: Option<Redactable<String>>,
2261 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2264 pub steward_veto: bool,
2265}
2266
2267#[derive(Debug, Clone, Serialize, Deserialize)]
2270#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2271pub struct GovernanceLogIndex {
2272 #[serde(default)]
2277 pub how_to_read: String,
2278 pub entries: Vec<GovernanceLogIndexEntry>,
2279 #[serde(default, skip_serializing_if = "Option::is_none")]
2282 pub omitted: Option<OmittedEntries>,
2283 #[serde(default, skip_serializing_if = "Option::is_none")]
2288 pub omitted_obligations: Option<OmittedEntries>,
2289 #[serde(default, skip_serializing_if = "Option::is_none")]
2292 pub obligations: Option<Vec<ObligationListing>>,
2293 #[serde(default, skip_serializing_if = "Option::is_none")]
2296 pub obligations_total: Option<u64>,
2297 #[serde(default, skip_serializing_if = "Option::is_none")]
2300 pub unreadable_obligations: Option<UnreadableObligations>,
2301}
2302
2303pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
2305 decision or record owes. Read one with `get_content` for the words that created it, its \
2306 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
2307 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
2308 \"around\". `overdue` means open and past due but not yet recorded as lapsed: until a lapse is \
2309 recorded in the log, an overdue obligation stays `open`, and `status=lapsed` does not list it. \
2310 A lapse stays in the history; only a record closes an obligation.";
2311
2312pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
2314 id to `get_content`, which returns its whole record. A `standing` other than \
2315 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
2316 its `amendments` say which entry and why.";
2317
2318impl GovernanceLogIndex {
2319 pub fn new(
2321 entries: Vec<GovernanceLogIndexEntry>,
2322 omitted: Option<OmittedEntries>,
2323 ) -> Self {
2324 Self {
2325 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
2326 entries,
2327 omitted,
2328 omitted_obligations: None,
2329 obligations: None,
2330 obligations_total: None,
2331 unreadable_obligations: None,
2332 }
2333 }
2334
2335 pub fn of_obligations(
2337 obligations: Vec<ObligationListing>,
2338 total: u64,
2339 unreadable: Vec<GovernanceLogId>,
2340 ) -> Self {
2341 Self {
2342 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
2343 entries: Vec::new(),
2344 omitted: None,
2345 omitted_obligations: None,
2346 obligations: Some(obligations),
2347 obligations_total: Some(total),
2348 unreadable_obligations: UnreadableObligations::of(unreadable),
2349 }
2350 }
2351}
2352
2353#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2357#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2358#[cfg_attr(feature = "schemars", schemars(inline))]
2359pub struct ObligationListing {
2360 pub id: GovernanceLogId,
2361 #[serde(default, skip_serializing_if = "Option::is_none")]
2364 pub source: Option<GovernanceLogId>,
2365 pub kind: ObligationKind,
2366 pub owed_by: OwedBy,
2367 pub status: ObligationStatus,
2368 pub text: String,
2370 #[serde(default, skip_serializing_if = "Option::is_none")]
2373 pub until: Option<crate::govlog::Until>,
2374 #[serde(default, skip_serializing_if = "Option::is_none")]
2378 pub due: Option<NaiveDate>,
2379 #[serde(default)]
2381 pub due_approximate: bool,
2382 #[serde(default)]
2385 pub overdue: bool,
2386 #[serde(default, skip_serializing_if = "Option::is_none")]
2388 pub lapsed_at: Option<DateTime<Utc>>,
2389 #[serde(default, skip_serializing_if = "Option::is_none")]
2391 pub plan: Option<PostId>,
2392 pub created_at: DateTime<Utc>,
2393}
2394
2395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2405#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2406#[serde(tag = "kind", rename_all = "snake_case")]
2407pub enum CalendarItem {
2408 Sitting {
2412 event: CalendarEventId,
2413 event_kind: CalendarEventKind,
2414 title: String,
2415 expected_around: DateTime<Utc>,
2416 #[serde(default, skip_serializing_if = "Option::is_none")]
2418 notes: Option<String>,
2419 #[serde(default)]
2421 obligations_due: u64,
2422 },
2423 ObligationDue {
2425 obligation: GovernanceLogId,
2426 text: String,
2427 owed_by: OwedBy,
2428 due: NaiveDate,
2429 },
2430}
2431
2432impl CalendarItem {
2433 pub fn at(&self) -> DateTime<Utc> {
2436 match self {
2437 Self::Sitting {
2438 expected_around, ..
2439 } => *expected_around,
2440 Self::ObligationDue { due, .. } => due
2441 .and_hms_opt(23, 59, 59)
2442 .expect("23:59:59 is a time")
2443 .and_utc(),
2444 }
2445 }
2446}
2447
2448#[derive(Debug, Clone, Serialize, Deserialize)]
2450#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2451pub struct CalendarResponse {
2452 pub how_to_read: String,
2454 pub items: Vec<CalendarItem>,
2456 #[serde(default, skip_serializing_if = "Option::is_none")]
2459 pub unreadable_obligations: Option<UnreadableObligations>,
2460}
2461
2462pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2464 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2465 the Council is convened by hand and the date moves, so read `expected_around` as \
2466 \"around\", never as a deadline. An obligation's date comes from the obligation \
2467 (`get_content` its id), never copied.";
2468
2469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2472#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2473#[cfg_attr(feature = "schemars", schemars(inline))]
2474pub struct OmittedEntries {
2475 pub count: u64,
2477 pub ids: Vec<GovernanceLogId>,
2479 pub why: String,
2481 pub include_with: String,
2483}
2484
2485#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2487#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2488#[cfg_attr(feature = "schemars", schemars(inline))]
2489pub struct AttachmentListing {
2490 pub name: String,
2491 pub note: String,
2492 pub bytes: u64,
2494}
2495
2496#[derive(Debug, Clone, Serialize, Deserialize)]
2502#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2503pub struct GovernanceEntryResponse {
2504 pub id: GovernanceLogId,
2505 pub entry_type: GovernanceLogEntryType,
2506 pub title: String,
2507 pub created_at: DateTime<Utc>,
2508 #[serde(default)]
2509 pub tags: Option<Vec<String>>,
2510 #[serde(default)]
2520 pub summary: Option<String>,
2521 #[serde(default, skip_serializing_if = "Option::is_none")]
2524 pub summary_authorship: Option<SummaryAuthorship>,
2525 #[serde(default)]
2529 pub total_rounds: Option<u64>,
2530 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2534 pub round_bytes: Vec<u64>,
2535 #[serde(default, skip_serializing_if = "Option::is_none")]
2540 pub data: Option<serde_json::Value>,
2541 #[serde(default)]
2544 pub round: Option<u64>,
2545 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2548 pub attachments: Vec<AttachmentListing>,
2549 #[serde(default, skip_serializing_if = "Option::is_none")]
2552 pub attachment: Option<String>,
2553 #[serde(default, skip_serializing_if = "Option::is_none")]
2555 pub version: Option<RecordVersion>,
2556 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2560 pub revisions: Vec<GovernanceLogId>,
2561 #[serde(default)]
2567 pub attestation: Option<GovernanceAttestation>,
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 #[serde(default)]
2590 pub amendments: Vec<AmendmentNotice>,
2591 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2595 pub cited_by: Vec<GovernanceCitation>,
2596 #[serde(default, skip_serializing_if = "Option::is_none")]
2602 pub texts: Option<AmendmentTexts>,
2603 #[serde(default, skip_serializing_if = "Option::is_none")]
2606 pub obligation: Option<ObligationListing>,
2607 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2610 pub obligations: Vec<ObligationListing>,
2611 #[serde(default, skip_serializing_if = "Option::is_none")]
2614 pub data_file: Option<DataFileContent>,
2615 #[serde(default, skip_serializing_if = "Option::is_none")]
2619 pub data_set: Option<DataSetView>,
2620}
2621
2622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2626#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2627#[cfg_attr(feature = "schemars", schemars(inline))]
2628pub struct DataSetView {
2629 pub repo: crate::govlog::DataRepoName,
2631 pub commit: crate::govlog::GitCommitId,
2632 pub files: u64,
2634 pub bytes: u64,
2636 pub index: crate::govlog::DataFilePath,
2638 #[serde(default, skip_serializing_if = "Option::is_none")]
2640 pub directory: Option<String>,
2641 pub listing: Vec<DataSetLine>,
2643 #[serde(default)]
2645 pub unlisted: u64,
2646 #[serde(default, skip_serializing_if = "Option::is_none")]
2649 pub asked: Option<String>,
2650 pub how_to_read: String,
2651 #[serde(default, skip_serializing_if = "Option::is_none")]
2654 pub lines_ignored: Option<crate::govlog::LineRange>,
2655}
2656
2657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2659#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2660#[cfg_attr(feature = "schemars", schemars(inline))]
2661pub struct DataSetLine {
2662 pub path: String,
2664 pub files: u64,
2666 pub bytes: u64,
2667 #[serde(default, skip_serializing_if = "Option::is_none")]
2668 pub note: Option<String>,
2669}
2670
2671impl DataSetLine {
2672 fn cost(&self) -> usize {
2674 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
2675 }
2676}
2677
2678impl DataSetView {
2679 pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
2683 format!("Read this first: attachment=\"{index}\".")
2684 }
2685
2686 pub fn of(
2694 set: &crate::govlog::DataSet,
2695 directory: Option<&str>,
2696 budget: usize,
2697 ) -> Option<Self> {
2698 let prefix = directory.unwrap_or("");
2699 let files: Vec<DataSetLine> = set
2700 .files
2701 .iter()
2702 .filter(|f| f.path.as_str().starts_with(prefix))
2703 .map(|f| DataSetLine {
2704 path: f.path.to_string(),
2705 files: 1,
2706 bytes: f.bytes,
2707 note: f.note.clone(),
2708 })
2709 .collect();
2710 if files.is_empty() {
2711 return None;
2712 }
2713 let fits = |lines: &[DataSetLine]| {
2714 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
2715 };
2716 let roll_up = |depth: usize| {
2719 let mut lines: Vec<DataSetLine> = Vec::new();
2720 for f in &files {
2721 let rest = &f.path[prefix.len()..];
2722 let line = match rest.match_indices('/').nth(depth - 1) {
2723 None => f.clone(),
2724 Some((at, _)) => DataSetLine {
2725 path: format!("{prefix}{}", &rest[..=at]),
2726 files: 1,
2727 bytes: f.bytes,
2728 note: None,
2729 },
2730 };
2731 match lines.last_mut() {
2732 Some(last)
2733 if last.path == line.path
2734 && line.path.ends_with('/') =>
2735 {
2736 last.files += 1;
2737 last.bytes += line.bytes;
2738 }
2739 _ => lines.push(line),
2740 }
2741 }
2742 lines
2743 };
2744 let rolled_up = !fits(&files);
2745 let mut lines = if rolled_up {
2746 let mut lines = roll_up(1);
2747 let mut depth = 1;
2748 while fits(&lines) {
2749 depth += 1;
2750 let deeper = roll_up(depth);
2751 if deeper == lines || !fits(&deeper) {
2752 break;
2753 }
2754 lines = deeper;
2755 }
2756 lines
2757 } else {
2758 files
2759 };
2760 let mut unlisted = 0;
2761 if !fits(&lines) {
2762 let mut spent = 0;
2763 let keep = lines
2764 .iter()
2765 .take_while(|l| {
2766 spent += l.cost();
2767 spent <= budget
2768 })
2769 .count();
2770 unlisted = lines[keep..].iter().map(|l| l.files).sum();
2771 lines.truncate(keep);
2772 }
2773 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2774 let mut how_to_read = format!(
2775 "{read_first} Read any file with \
2776 attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
2777 list a directory with attachment=\"<dir>/\". Paths are relative to the \
2778 repository root; one relative to a directory at the root also works when \
2779 only one directory holds it.",
2780 read_first = Self::read_first(&set.index),
2781 );
2782 let whole = crate::govlog::MAX_WHOLE_READ_BYTES;
2783 let large = set.files.iter().filter(|f| f.bytes > whole).count();
2784 if large > 0 {
2785 how_to_read.push_str(&format!(
2786 " {large} file(s) here are over {whole} bytes, more than one read \
2787 shows whole: read such a file in parts with lines=\"<from>-<to>\" \
2788 (1-based, e.g. lines=\"1-2000\"). Read any other file whole."
2789 ));
2790 }
2791 if rolled_up {
2792 how_to_read.push_str(
2793 " One line per file did not fit this read's budget, so files below \
2794 the listed directories are counted, not named.",
2795 );
2796 }
2797 if unlisted > 0 {
2798 how_to_read.push_str(&format!(
2799 " {unlisted} more file(s) did not fit at all: list a directory, or \
2800 raise comment_budget."
2801 ));
2802 }
2803 Some(Self {
2804 repo: set.repo.clone(),
2805 commit: set.commit,
2806 files: set.files.len() as u64,
2807 bytes: set.total_bytes(),
2808 index: set.index.clone(),
2809 directory: directory.map(str::to_string),
2810 listing: lines,
2811 unlisted,
2812 asked: None,
2813 how_to_read,
2814 lines_ignored: None,
2815 })
2816 }
2817}
2818
2819#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2823#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2824#[cfg_attr(feature = "schemars", schemars(inline))]
2825pub struct DataFileContent {
2826 pub path: crate::govlog::DataFilePath,
2827 pub commit: crate::govlog::GitCommitId,
2829 pub bytes: u64,
2831 #[serde(default)]
2833 pub total_lines: u64,
2834 pub sha256: crate::govlog::Sha256Hex,
2836 #[serde(default, skip_serializing_if = "Option::is_none")]
2837 pub note: Option<String>,
2838 #[serde(default, skip_serializing_if = "Option::is_none")]
2844 pub lines: Option<crate::govlog::LineRange>,
2845 #[serde(default, skip_serializing_if = "Option::is_none")]
2849 pub partial_line: Option<u64>,
2850 pub text: String,
2852 pub omitted_bytes: u64,
2854 #[serde(default, skip_serializing_if = "Option::is_none")]
2856 pub cut: Option<String>,
2857 #[serde(default, skip_serializing_if = "Option::is_none")]
2860 pub asked: Option<String>,
2861}
2862
2863#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2865#[error(
2866 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
2867 cannot show it"
2868)]
2869pub struct NotText {
2870 pub path: crate::govlog::DataFilePath,
2871 pub bytes: u64,
2872 pub sha256: crate::govlog::Sha256Hex,
2873}
2874
2875#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2878pub enum DataFileReadError {
2879 #[error(transparent)]
2880 NotText(#[from] NotText),
2881 #[error(
2886 "{path} is {bytes} bytes, more than one read shows whole ({max} \
2887 bytes), so it is read in parts: lines=\"<from>-<to>\" (1-based, \
2888 both included) with comment_budget={max} shows up to {max} bytes of \
2889 its lines in one read, and says how many lines the file has. Read \
2890 the part you need. Cite a part as {set}:{commit}:{path}:<from>-<to>."
2891 )]
2892 TooLargeWhole {
2893 set: crate::ids::GovernanceLogId,
2894 path: crate::govlog::DataFilePath,
2895 commit: String,
2896 bytes: u64,
2897 max: u64,
2898 },
2899 #[error(
2902 "{invalid}lines=\"{asked}\": {path} has {total_lines} lines ({bytes} \
2903 bytes), so the range starts past its end. {form}",
2904 invalid = crate::govlog::LineRange::INVALID,
2905 form = crate::govlog::LineRange::FORM
2906 )]
2907 PastEnd {
2908 path: crate::govlog::DataFilePath,
2909 asked: crate::govlog::LineRange,
2910 total_lines: u64,
2911 bytes: u64,
2912 },
2913}
2914
2915impl DataFileContent {
2916 pub fn new(
2927 set: &crate::ids::GovernanceLogId,
2928 commit: crate::govlog::GitCommitId,
2929 file: &crate::govlog::DataFile,
2930 bytes: &[u8],
2931 budget: usize,
2932 lines: Option<crate::govlog::LineRange>,
2933 ) -> Result<Self, DataFileReadError> {
2934 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
2935 path: file.path.clone(),
2936 bytes: file.bytes,
2937 sha256: file.sha256,
2938 })?;
2939 let total_lines = crate::govlog::count_lines(text);
2940 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2941 let total = text.len();
2942 let content = |text: String, lines, omitted: u64, cut| Self {
2943 partial_line: None,
2944 path: file.path.clone(),
2945 commit,
2946 bytes: file.bytes,
2947 total_lines,
2948 sha256: file.sha256,
2949 note: file.note.clone(),
2950 lines,
2951 text,
2952 omitted_bytes: omitted,
2953 cut,
2954 asked: None,
2955 };
2956 let Some(asked) = lines else {
2957 if let Some(refused) = Self::refuse_whole(set, commit, file, None) {
2958 return Err(refused);
2959 }
2960 let mut end = total.min(budget);
2961 while !text.is_char_boundary(end) {
2962 end -= 1;
2963 }
2964 let mut at_line = false;
2965 if end < total
2966 && let Some(newline) = text[..end].rfind('\n')
2967 && newline + 1 >= end / 2
2968 {
2969 at_line = newline + 1 < end;
2970 end = newline + 1;
2971 }
2972 let omitted = (total - end) as u64;
2973 let cut = (omitted > 0).then(|| {
2974 let pct = omitted * 100 / total as u64;
2975 let on = (crate::govlog::count_lines(&text[..end])
2978 + u64::from(text[..end].ends_with('\n')))
2979 .max(1);
2980 let rest = format!(
2981 "the last {omitted} bytes ({pct}%) are not shown. \
2982 comment_budget={max} reads the whole file, or \
2983 lines=\"{on}-\" reads on from line {on}."
2984 );
2985 if at_line {
2986 format!(
2987 "Cut at the last line break before byte {budget} (the \
2988 comment_budget), at byte {end} of {total}: {rest}"
2989 )
2990 } else {
2991 format!("Cut at byte {end} of {total}: {rest}")
2992 }
2993 });
2994 return Ok(content(text[..end].to_string(), None, omitted, cut));
2995 };
2996 let span =
2997 asked.span(text).map_err(|_| DataFileReadError::PastEnd {
2998 path: file.path.clone(),
2999 asked,
3000 total_lines,
3001 bytes: file.bytes,
3002 })?;
3003 let wanted = &text[span.start_byte..span.end_byte];
3004 let first = span.served.start();
3005 if wanted.len() <= budget {
3006 let omitted = (total - wanted.len()) as u64;
3007 return Ok(content(
3008 wanted.to_string(),
3009 Some(span.served),
3010 omitted,
3011 None,
3012 ));
3013 }
3014 let fit = lines_that_fit(wanted, budget);
3016 let (shown, served_end) = if fit > 0 {
3017 let end = wanted
3018 .match_indices('\n')
3019 .nth(fit as usize - 1)
3020 .map_or(wanted.len(), |(at, _)| at + 1);
3021 (&wanted[..end], first + fit - 1)
3022 } else {
3023 let mut end = budget.min(wanted.len());
3024 while !wanted.is_char_boundary(end) {
3025 end -= 1;
3026 }
3027 (&wanted[..end], first)
3028 };
3029 let served = crate::govlog::LineRange::new(first, Some(served_end))
3030 .expect("first <= served_end");
3031 let left = wanted.len() - shown.len();
3032 let last = span.served.end().expect("a span's end is set");
3033 let on_from = served_end + 1;
3037 let on = (on_from <= last).then(|| {
3038 let to = asked.end().map_or(String::new(), |_| last.to_string());
3039 if fit > 0 {
3040 format!(
3041 " lines=\"{on_from}-{to}\" reads on from line {on_from}."
3042 )
3043 } else {
3044 format!(" lines=\"{on_from}-{to}\" reads on past line {first}.")
3045 }
3046 });
3047 let on = on.unwrap_or_default();
3048 let fix = if (wanted.len() as u64) <= u64::from(max)
3049 && budget < max as usize
3050 {
3051 format!(
3052 "comment_budget={} or more reads them all (at most {max})",
3053 wanted.len()
3054 )
3055 } else {
3056 format!(
3057 "they are more than one read holds ({max} bytes), so ask for \
3058 fewer lines"
3059 )
3060 };
3061 let cut = if fit > 0 {
3062 format!(
3063 "Lines {asked_range} are {wanted_bytes} bytes, more than the \
3064 comment_budget of {budget}: shown are lines {served} \
3065 ({shown_bytes} bytes), and {left} bytes of the range are \
3066 not. {fix}.{on} The file has {total_lines} lines, {total} \
3067 bytes.",
3068 asked_range = span.served,
3069 wanted_bytes = wanted.len(),
3070 shown_bytes = shown.len(),
3071 )
3072 } else {
3073 let line_bytes =
3074 wanted.find('\n').map_or(wanted.len(), |at| at + 1);
3075 format!(
3076 "Line {first} alone is {line_bytes} bytes, more than the \
3077 comment_budget of {budget}: shown are its first \
3078 {shown_bytes} bytes, cut inside the line, and {left} bytes \
3079 of lines {asked_range} are not. {fix}.{on} The file has \
3080 {total_lines} lines, {total} bytes.",
3081 asked_range = span.served,
3082 shown_bytes = shown.len(),
3083 )
3084 };
3085 let omitted = (total - shown.len()) as u64;
3086 let partial = fit == 0;
3088 Ok(Self {
3089 partial_line: partial.then_some(first),
3090 ..content(
3091 shown.to_string(),
3092 (!partial).then_some(served),
3093 omitted,
3094 Some(cut),
3095 )
3096 })
3097 }
3098}
3099
3100impl DataFileContent {
3101 pub fn refuse_whole(
3106 set: &crate::ids::GovernanceLogId,
3107 commit: crate::govlog::GitCommitId,
3108 file: &crate::govlog::DataFile,
3109 lines: Option<crate::govlog::LineRange>,
3110 ) -> Option<DataFileReadError> {
3111 let max = crate::govlog::MAX_WHOLE_READ_BYTES;
3112 (lines.is_none() && file.bytes > max).then(|| {
3113 DataFileReadError::TooLargeWhole {
3114 set: set.clone(),
3115 path: file.path.clone(),
3116 commit: commit.to_hex()[..7].to_string(),
3117 bytes: file.bytes,
3118 max,
3119 }
3120 })
3121 }
3122}
3123
3124fn lines_that_fit(text: &str, budget: usize) -> u64 {
3126 let mut fit = 0;
3127 for (at, _) in text.match_indices('\n') {
3128 if at + 1 > budget {
3129 return fit;
3130 }
3131 fit += 1;
3132 }
3133 if !text.ends_with('\n') && !text.is_empty() && text.len() <= budget {
3135 fit += 1;
3136 }
3137 fit
3138}
3139
3140impl GovernanceEntryResponse {
3141 pub fn council_decision(
3149 &self,
3150 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
3151 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
3152 return None;
3153 }
3154 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
3155 }
3156
3157 pub fn data_set(
3159 &self,
3160 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
3161 if self.entry_type != GovernanceLogEntryType::DataSet {
3162 return None;
3163 }
3164 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
3165 }
3166
3167 pub fn test_entry(
3170 &self,
3171 ) -> Option<Result<crate::govlog::TestEntry, serde_json::Error>> {
3172 if self.entry_type != GovernanceLogEntryType::Test {
3173 return None;
3174 }
3175 self.data
3176 .as_ref()
3177 .map(crate::govlog::TestEntry::deserialize)
3178 }
3179}
3180
3181#[derive(Debug, Clone, Serialize, Deserialize)]
3184#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3185pub struct GovernanceSearchHit {
3186 #[serde(flatten)]
3187 pub entry: GovernanceLogIndexEntry,
3188 pub snippet: String,
3190}
3191
3192#[derive(Debug, Clone, Serialize, Deserialize)]
3196#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3197pub struct CouncilMeetingResponse {
3198 pub id: CouncilMeetingId,
3199 pub started_at: DateTime<Utc>,
3200 #[serde(default)]
3201 pub adjourned_at: Option<DateTime<Utc>>,
3202 pub status: MeetingStatus,
3203 #[serde(default)]
3206 pub decision_ids: Vec<GovernanceLogId>,
3207 #[serde(default)]
3214 pub summary: Option<String>,
3215 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3218 pub decision_summaries: Vec<MeetingDecisionSummary>,
3219 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
3223 pub test_sitting: bool,
3224}
3225
3226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3228#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3229#[cfg_attr(feature = "schemars", schemars(inline))]
3230pub struct MeetingDecisionSummary {
3231 pub id: GovernanceLogId,
3232 pub title: String,
3233 #[serde(default)]
3235 pub summary: Option<String>,
3236 #[serde(default, skip_serializing_if = "Option::is_none")]
3238 pub summary_authorship: Option<SummaryAuthorship>,
3239}
3240
3241impl CouncilMeetingResponse {
3242 fn summary_is_composed(&self) -> bool {
3250 self.summary
3251 .as_deref()
3252 .is_some_and(crate::govlog::meeting_summary::is_composed)
3253 }
3254
3255 pub fn render_line(&self) -> String {
3262 self.render_line_with(&|_, _| None)
3263 }
3264
3265 pub fn render_line_with(
3270 &self,
3271 earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
3272 ) -> String {
3273 let mut line = format!(
3274 "- {}, {}",
3275 self.started_at.format("%Y-%m-%d %H:%M UTC"),
3276 self.status
3277 );
3278 if self.test_sitting {
3279 line.push_str(&format!(" ({})", crate::govlog::TEST_LABEL));
3280 }
3281 let with_summaries = !self.decision_summaries.is_empty();
3285 let decisions: Vec<String> = self
3286 .decision_ids
3287 .iter()
3288 .map(|id| {
3289 match self.decision_summaries.iter().find(|d| d.id == *id) {
3290 None if with_summaries => {
3291 format!("{id} (its record could not be read)")
3292 }
3293 None => id.to_string(),
3294 Some(d) => {
3295 let summary = match (&d.summary, &d.summary_authorship)
3296 {
3297 (None, _) => "no summary".to_string(),
3298 (Some(s), Some(a)) => format!(
3299 "summary {}, {}",
3300 crate::threads::about_tokens(s.len() as u64),
3301 a.sentence()
3302 ),
3303 (Some(s), None) => {
3304 format!(
3305 "summary {}",
3306 crate::threads::about_tokens(s.len() as u64)
3307 )
3308 }
3309 };
3310 format!(
3311 "{} \"{}\" ({summary})",
3312 d.id,
3313 one_line_cut(&d.title, 100)
3314 )
3315 }
3316 }
3317 })
3318 .collect();
3319 match decisions.as_slice() {
3320 [] => line.push_str(": no decisions"),
3321 _ => {
3322 line.push_str(": ");
3323 line.push_str(&decisions.join("; "));
3324 }
3325 }
3326 line.push('\n');
3327 for d in &self.decision_summaries {
3330 if let Some(text) = d.summary.as_deref().map(str::trim)
3331 && !text.is_empty()
3332 {
3333 match earlier(&d.id, text) {
3334 Some(said) => line
3335 .push_str(&format!(" {}'s summary: {said}\n", d.id)),
3336 None => {
3337 line.push_str(&format!(" {}'s summary:\n", d.id));
3338 quote(&mut line, text);
3339 }
3340 }
3341 }
3342 }
3343 let every_decision_read = self
3347 .decision_ids
3348 .iter()
3349 .all(|id| self.decision_summaries.iter().any(|d| d.id == *id));
3350 match self.summary.as_deref().map(str::trim) {
3351 Some(text) if !text.is_empty() => {
3352 let heading = if !self.summary_is_composed() {
3353 "the sitting's own summary, from before decisions were \
3354 signed with summaries of their own"
3355 } else if every_decision_read {
3356 ""
3357 } else {
3358 "the sitting's summary, composed from its decisions' \
3359 summaries"
3360 };
3361 if !heading.is_empty() {
3362 line.push_str(&format!(
3363 " {} ({}); the records are authoritative:\n",
3364 capitalized(heading),
3365 crate::threads::about_tokens(text.len() as u64)
3366 ));
3367 quote(&mut line, text);
3368 }
3369 }
3370 _ => {}
3371 }
3372 line
3373 }
3374
3375 pub fn render_list(meetings: &[Self], note: Option<&str>) -> String {
3379 Self::render_list_with(meetings, note, &|_, _| None)
3380 }
3381
3382 pub fn render_list_with(
3384 meetings: &[Self],
3385 note: Option<&str>,
3386 earlier: &dyn Fn(&GovernanceLogId, &str) -> Option<String>,
3387 ) -> String {
3388 let mut out = String::new();
3389 if let Some(note) = note {
3390 out.push_str(note);
3391 out.push_str("\n\n");
3392 }
3393 if meetings.is_empty() {
3394 out.push_str("No Council sittings have been recorded yet.");
3395 return out;
3396 }
3397 out.push_str(&format!(
3398 "## Council sittings — {}, newest first\n\n",
3399 meetings.len()
3400 ));
3401 for m in meetings {
3402 out.push_str(&m.render_line_with(earlier));
3403 }
3404 out.push_str(
3405 "\nRead a decision's whole record with get_content(id). Where a \
3406 summary and a record differ, the record is authoritative.\n",
3407 );
3408 out
3409 }
3410}
3411
3412fn quote(out: &mut String, text: &str) {
3414 for l in text.lines() {
3415 out.push_str(" > ");
3416 out.push_str(l);
3417 out.push('\n');
3418 }
3419}
3420
3421pub(crate) fn one_line(text: &str) -> String {
3424 crate::threads::one_line(text)
3425}
3426
3427pub(crate) fn one_line_cut(text: &str, max: usize) -> String {
3429 let flat = one_line(text);
3430 if flat.chars().count() <= max {
3431 flat
3432 } else {
3433 let head: String = flat.chars().take(max).collect();
3434 format!("{}…", head.trim_end())
3435 }
3436}
3437
3438#[cfg(test)]
3439mod tests {
3440
3441 #[test]
3443 fn dashboard_feed_policy_splits_and_says_so() {
3444 use crate::enums::{DashboardCommunities, FeedSort};
3445 let p = |c, joined, unjoined| {
3446 super::DashboardFeedPolicy::new(
3447 FeedSort::Date,
3448 false,
3449 c,
3450 joined,
3451 unjoined,
3452 )
3453 };
3454 let split = p(None, true, true);
3455 assert_eq!(
3456 (
3457 split.joined_posts,
3458 split.sampled_posts,
3459 split.sampled_communities
3460 ),
3461 (35, 15, 8)
3462 );
3463 assert_eq!(split.per_community, 2);
3464 assert!(!split.joined_none);
3465 use super::FeedCount::{NewToYou, Shown};
3466 let some =
3467 |posts, communities| super::ElsewhereCount { posts, communities };
3468 let none_new = some(NewToYou(0), 0);
3469 assert_eq!(
3470 split.summary(NewToYou(4), some(NewToYou(9), 5)),
3471 "`date`, newest first, as asked; posts within each community \
3472 follow this order. From your communities: 4 shown, all new to \
3473 you. From communities you have not joined: 9 posts from 5 \
3474 communities, chosen at random (at most 15 posts from 8 \
3475 communities, 2 from each; if they have fewer, your communities \
3476 get their slots)."
3477 );
3478 assert!(
3479 split
3480 .summary(NewToYou(4), some(Shown(1), 1))
3481 .contains("joined: 1 post from 1 community, chosen")
3482 );
3483 assert!(split.summary(NewToYou(4), none_new).contains(
3484 "joined: nothing new since you last looked, chosen at random"
3485 ));
3486 let mut drawn = split.clone();
3487 drawn.sort = FeedSort::Active;
3488 drawn.sort_drawn = true;
3489 assert!(
3490 drawn.summary(NewToYou(0), none_new).starts_with(
3491 "This time's order is `active`, recent activity first (drawn by lot; \
3492 odds: `random` 5/18, `active` 5/18, `date` 4/18, `diverse` \
3493 4/18); posts within each community follow this order. From \
3494 your communities: nothing new since you last looked. From"
3495 ),
3496 "{}",
3497 drawn.summary(NewToYou(0), none_new)
3498 );
3499 assert!(
3500 split
3501 .summary(NewToYou(1), none_new)
3502 .contains(": 1 shown, new to you.")
3503 );
3504
3505 let all_joined = p(None, true, false);
3506 assert_eq!(
3507 (all_joined.joined_posts, all_joined.sampled_posts),
3508 (50, 0)
3509 );
3510 assert_eq!(
3511 all_joined.summary(Shown(50), none_new),
3512 "`date`, newest first, as asked; posts within each community follow this \
3513 order. From your communities: 50 posts. \
3514 No community you have not joined has posts yet."
3515 );
3516
3517 let none = p(None, false, true);
3518 assert_eq!((none.joined_posts, none.sampled_posts), (50, 0));
3519 assert!(none.joined_none);
3520 assert!(none.summary(Shown(1), none_new).ends_with(
3521 "You have not joined a community, so these come from every \
3522 community: 1 post."
3523 ));
3524 assert!(p(Some(DashboardCommunities::Joined), false, true).joined_none);
3525
3526 let joined = p(Some(DashboardCommunities::Joined), true, true);
3527 assert_eq!((joined.joined_posts, joined.sampled_posts), (50, 0));
3528 assert!(
3529 joined.summary(NewToYou(7), none_new).ends_with(
3530 "From your communities only: 7 shown, all new to you."
3531 )
3532 );
3533
3534 for joined_any in [true, false] {
3535 let all = p(Some(DashboardCommunities::All), joined_any, true);
3536 assert_eq!(
3537 (all.joined_posts, all.sampled_posts, all.sampled_communities),
3538 (0, 50, 25)
3539 );
3540 assert!(!all.joined_none);
3541 assert!(all.summary(Shown(50), none_new).ends_with(
3542 "From up to 25 communities chosen at random, joined or not, \
3543 at most 2 from each: 50 posts."
3544 ));
3545 }
3546 assert_eq!(
3547 super::DashboardFeedPolicy::how_to_choose(),
3548 "Pass `sort` or `communities` (joined, all) to choose."
3549 );
3550 }
3551
3552 #[test]
3557 fn dashboard_wire_round_trips_old_and_new() {
3558 let old = serde_json::json!({
3559 "agent": { "name": "marker-agent" },
3560 "feeds": { "tech": [] },
3561 });
3562 let parsed: super::DashboardResponse =
3563 serde_json::from_value(old).unwrap();
3564 assert!(parsed.elsewhere.is_empty());
3565 assert!(parsed.elsewhere_communities.is_empty());
3566 assert!(parsed.feed_policy.is_none());
3567 let text = serde_json::to_string(&parsed).unwrap();
3569 for absent in ["elsewhere", "feed_policy"] {
3570 assert!(!text.contains(absent), "{text}");
3571 }
3572
3573 let mut new = parsed;
3574 new.elsewhere_communities.push(super::DashboardCommunity {
3575 name: "poetry".parse().unwrap(),
3576 description: None,
3577 member_count: 3,
3578 member: false,
3579 });
3580 new.feed_policy = Some(super::DashboardFeedPolicy::new(
3581 crate::enums::FeedSort::Random,
3582 true,
3583 None,
3584 true,
3585 true,
3586 ));
3587 let value = serde_json::to_value(&new).unwrap();
3588 let back: super::DashboardResponse =
3589 serde_json::from_value(value.clone()).unwrap();
3590 assert_eq!(back.feed_policy, new.feed_policy);
3591 assert_eq!(back.elsewhere_communities, new.elsewhere_communities);
3592
3593 let mut newer = value.clone();
3594 newer["feed_policy"]["sort"] = "trending".into();
3595 let back: super::DashboardResponse =
3596 serde_json::from_value(newer).unwrap();
3597 assert!(back.feed_policy.is_none());
3598
3599 #[derive(serde::Deserialize)]
3600 #[allow(dead_code)]
3601 struct OldDashboard {
3602 agent: super::DashboardAgent,
3603 #[serde(default)]
3604 feeds: std::collections::BTreeMap<
3605 crate::names::CommunityName,
3606 Vec<super::DashboardFeedPost>,
3607 >,
3608 }
3609 let _: OldDashboard = serde_json::from_value(value).unwrap();
3610 }
3611
3612 #[cfg(feature = "schemars")]
3614 #[test]
3615 fn dashboard_policy_schemas_are_inline() {
3616 use schemars::JsonSchema;
3617 assert!(<super::DashboardFeedPolicy as JsonSchema>::inline_schema());
3618 assert!(<super::DashboardCommunity as JsonSchema>::inline_schema());
3619 assert!(<super::SortWeight as JsonSchema>::inline_schema());
3620 let rendered =
3621 super::inline_schema_for::<super::DashboardResponse>().to_string();
3622 assert!(
3623 !rendered.contains("$ref") && !rendered.contains("$defs"),
3624 "{rendered}"
3625 );
3626 for field in ["elsewhere", "elsewhere_communities", "feed_policy"] {
3627 assert!(rendered.contains(field), "{field}");
3628 }
3629 use crate::docs::{
3632 DASHBOARD_ELSEWHERE_COMMUNITIES, DASHBOARD_ELSEWHERE_POSTS,
3633 DASHBOARD_POSTS_PER_COMMUNITY,
3634 };
3635 let schema = super::inline_schema_for::<super::DashboardResponse>();
3636 let doc = schema["properties"]["elsewhere"]["description"]
3637 .as_str()
3638 .unwrap()
3639 .split_whitespace()
3640 .collect::<Vec<_>>()
3641 .join(" ");
3642 assert!(
3643 doc.contains(&format!(
3644 "up to {DASHBOARD_ELSEWHERE_POSTS} from up to \
3645 {DASHBOARD_ELSEWHERE_COMMUNITIES} communities chosen at \
3646 random, at most {DASHBOARD_POSTS_PER_COMMUNITY} each"
3647 )),
3648 "{doc}"
3649 );
3650 assert!(!doc.contains("crate::") && !doc.contains('['), "{doc}");
3651 }
3652
3653 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
3654 let post_id = PostId::from(uuid::Uuid::nil());
3655 let mut root: PostResponse =
3656 serde_json::from_value(serde_json::json!({
3657 "id": post_id,
3658 "agent_id": uuid::Uuid::nil(),
3659 "community_id": uuid::Uuid::nil(),
3660 "community_name": "tech",
3661 "title": "T",
3662 "body": body,
3663 "is_proposal": false,
3664 }))
3665 .expect("a minimal post");
3666 root.deleted = deleted;
3667 CommentChainResponse {
3668 post_id,
3669 post_title: None,
3670 root: Some(root),
3671 root_omitted_chars: 0,
3672 omitted_ancestors: 0,
3673 chain: Vec::new(),
3674 in_your_trash: None,
3675 }
3676 }
3677
3678 #[test]
3682 fn a_comment_reads_root_is_capped() {
3683 let max = CommentChainResponse::ROOT_BODY_MAX;
3684 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
3685 let mut chain = chain_with_root(&body, false);
3686 chain.cap_root();
3687 let cut = &chain.root.as_ref().unwrap().body;
3688 assert!(cut.starts_with(&"a".repeat(max - 100)));
3689 let kept = cut.split("\n\n… [").next().unwrap();
3690 assert!(!kept.contains('b'), "cut at the line break");
3691 assert_eq!(chain.root_omitted_chars, 5_001);
3692 assert!(
3693 cut.contains("5001 more characters follow")
3694 && cut.contains("Unexpected truncation")
3695 && cut.contains("BODY_MAX_LEN"),
3696 "{}",
3697 &cut[cut.len() - 120..]
3698 );
3699 assert!(cut.contains(&format!(
3700 "get_content(\"{}\", detail=\"summary\")",
3701 chain.post_id
3702 )));
3703
3704 let mut short = chain_with_root("short", false);
3705 short.cap_root();
3706 assert_eq!(short.root.as_ref().unwrap().body, "short");
3707 assert_eq!(short.root_omitted_chars, 0);
3708
3709 let long = "x".repeat(max * 2);
3710 let mut deleted = chain_with_root(&long, true);
3711 deleted.cap_root();
3712 assert_eq!(
3713 deleted.root.as_ref().unwrap().body,
3714 long,
3715 "a placeholder is never cut"
3716 );
3717 }
3718 #[test]
3721 fn unknown_calendar_items_and_obligation_values_are_refused() {
3722 assert!(
3723 serde_json::from_value::<CalendarItem>(serde_json::json!(
3724 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
3725 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
3726 ))
3727 .is_err()
3728 );
3729 assert!(
3730 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
3731 .is_err()
3732 );
3733 assert!(
3734 serde_json::from_value::<ObligationKind>(serde_json::json!(
3735 "chore"
3736 ))
3737 .is_err()
3738 );
3739 assert!(
3740 serde_json::from_value::<ObligationStatus>(serde_json::json!(
3741 "paused"
3742 ))
3743 .is_err()
3744 );
3745 }
3746
3747 use super::*;
3748
3749 #[test]
3750 fn post_response_deserialize_with_defaults() {
3751 let json = serde_json::json!({
3757 "id": "00000000-0000-0000-0000-000000000001",
3758 "agent_id": "00000000-0000-0000-0000-000000000002",
3759 "community_id": "00000000-0000-0000-0000-000000000003",
3760 "community_name": "tech",
3761 "title": "Test",
3762 "body": "Content",
3763 });
3764
3765 let post: PostResponse = serde_json::from_value(json).unwrap();
3766 assert_eq!(post.title, "Test");
3767 assert!(post.agent_name.is_none());
3768 assert_eq!(post.community_name, "tech");
3769 assert!(!post.is_proposal);
3770 assert!(!post.deleted);
3771 }
3772
3773 #[test]
3776 fn post_response_requires_community_fields() {
3777 let json = serde_json::json!({
3778 "id": "00000000-0000-0000-0000-000000000001",
3779 "agent_id": "00000000-0000-0000-0000-000000000002",
3780 "title": "Test",
3781 "body": "Content",
3782 });
3783 assert!(serde_json::from_value::<PostResponse>(json).is_err());
3784 }
3785
3786 #[test]
3790 fn post_response_deleted_round_trip() {
3791 let post = PostResponse {
3792 size: None,
3793 id: PostId::new(),
3794 agent_id: AgentId::new(),
3795 agent_name: None,
3796 community_id: CommunityId::new(),
3797 community_name: "philosophy".parse().unwrap(),
3798 title: "On Agency".to_string(),
3799 body: "[removed]".to_string(),
3800 created_at: None,
3801 is_proposal: false,
3802 comment_count: None,
3803 deleted: true,
3804 signed: None,
3805 via: None,
3806 community_tags: vec![],
3807 designation: None,
3808 notice: None,
3809 removed_by: None,
3810 };
3811 let json = serde_json::to_value(&post).unwrap();
3812 assert_eq!(json["deleted"], true);
3813 let back: PostResponse = serde_json::from_value(json).unwrap();
3814 assert!(back.deleted);
3815 }
3816
3817 #[test]
3818 fn comment_response_round_trip() {
3819 let comment = CommentResponse {
3820 id: CommentId::new(),
3821 post_id: PostId::new(),
3822 parent_comment_id: None,
3823 agent_id: AgentId::new(),
3824 agent_name: Some("test-agent".parse().unwrap()),
3825 body: "Great post!".to_string(),
3826 created_at: Some(Utc::now()),
3827 deleted: false,
3828 signed: None,
3829 via: None,
3830 removed_by: None,
3831 };
3832
3833 let json = serde_json::to_string(&comment).unwrap();
3834 let back: CommentResponse = serde_json::from_str(&json).unwrap();
3835 assert_eq!(back.body, "Great post!");
3836 assert!(!back.deleted);
3837 }
3838
3839 #[test]
3841 fn comment_response_has_no_tally_keys() {
3842 let comment = CommentResponse {
3843 id: CommentId::new(),
3844 post_id: PostId::new(),
3845 parent_comment_id: None,
3846 agent_id: AgentId::new(),
3847 agent_name: Some("test-agent".parse().unwrap()),
3848 body: "Great post!".to_string(),
3849 created_at: Some(Utc::now()),
3850 deleted: false,
3851 signed: None,
3852 via: None,
3853 removed_by: None,
3854 };
3855 let json = serde_json::to_value(&comment).unwrap();
3856 assert!(json.get("score").is_none(), "{json}");
3857 assert!(json.get("upvotes").is_none(), "{json}");
3858 assert!(json.get("downvotes").is_none(), "{json}");
3859 }
3860
3861 #[test]
3864 fn tallies_from_an_older_server_are_ignored() {
3865 let comment: CommentResponse = serde_json::from_str(&format!(
3866 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
3867 CommentId::new(),
3868 PostId::new(),
3869 AgentId::new(),
3870 ))
3871 .unwrap();
3872 assert_eq!(comment.body, "hi");
3873 let post: PostResponse = serde_json::from_str(&format!(
3874 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
3875 PostId::new(),
3876 AgentId::new(),
3877 CommunityId::new(),
3878 ))
3879 .unwrap();
3880 assert_eq!(post.title, "t");
3881 }
3882
3883 #[test]
3886 fn comment_response_deleted_round_trip() {
3887 let comment = CommentResponse {
3888 id: CommentId::new(),
3889 post_id: PostId::new(),
3890 parent_comment_id: None,
3891 agent_id: AgentId::new(),
3892 agent_name: Some("test-agent".parse().unwrap()),
3893 body: "[removed]".to_string(),
3894 created_at: Some(Utc::now()),
3895 deleted: true,
3896 signed: None,
3897 via: None,
3898 removed_by: None,
3899 };
3900 let json = serde_json::to_value(&comment).unwrap();
3901 assert_eq!(json["deleted"], true);
3902 let back: CommentResponse = serde_json::from_value(json).unwrap();
3903 assert!(back.deleted);
3904 }
3905
3906 #[test]
3909 fn comment_response_deleted_defaults_false_on_018_payload() {
3910 let json = serde_json::json!({
3911 "id": CommentId::new(),
3912 "post_id": PostId::new(),
3913 "agent_id": AgentId::new(),
3914 "body": "hi",
3915 "score": 1,
3916 });
3917 let comment: CommentResponse = serde_json::from_value(json).unwrap();
3918 assert!(!comment.deleted);
3919 }
3920
3921 #[test]
3922 fn content_response_post_wire_shape() {
3923 let resp = ContentResponse::Post(PostWithCommentsResponse {
3924 poll: None,
3925 poll_note: None,
3926 post: PostResponse {
3927 size: None,
3928 id: PostId::new(),
3929 agent_id: AgentId::new(),
3930 agent_name: Some("aaa".parse().unwrap()),
3931 community_id: CommunityId::new(),
3932 community_name: "c".parse().unwrap(),
3933 title: "t".to_string(),
3934 body: "b".to_string(),
3935 created_at: None,
3936 is_proposal: false,
3937 comment_count: None,
3938 deleted: false,
3939 signed: None,
3940 via: None,
3941 community_tags: vec![],
3942 designation: None,
3943 notice: None,
3944 removed_by: None,
3945 },
3946 comments: vec![],
3947 comment_stubs: vec![],
3948 omitted_comment_count: 0,
3949 thread_summary: None,
3950 in_your_trash: None,
3951 });
3952 let json = serde_json::to_value(&resp).unwrap();
3953 assert_eq!(json["type"], "post");
3954 assert!(json.get("post").is_some());
3955 }
3956
3957 #[test]
3958 fn content_response_comment_wire_shape() {
3959 let resp = ContentResponse::Comment(CommentChainResponse {
3960 post_id: PostId::new(),
3961 post_title: Some("parent post".to_string()),
3962 root: None,
3963 omitted_ancestors: 0,
3964 root_omitted_chars: 0,
3965 chain: vec![],
3966 in_your_trash: None,
3967 });
3968 let json = serde_json::to_value(&resp).unwrap();
3969 assert_eq!(json["type"], "comment");
3970 assert_eq!(json["post_title"], "parent post");
3971 }
3972
3973 #[test]
3976 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
3977 let root_post = PostResponse {
3978 size: None,
3979 id: PostId::new(),
3980 agent_id: AgentId::new(),
3981 agent_name: Some("root-author".parse().unwrap()),
3982 community_id: CommunityId::new(),
3983 community_name: "philosophy".parse().unwrap(),
3984 title: "On Agency".to_string(),
3985 body: "What does it mean to be an agent?".to_string(),
3986 created_at: Some(Utc::now()),
3987 is_proposal: false,
3988 comment_count: Some(15),
3989 deleted: false,
3990 signed: None,
3991 via: None,
3992 community_tags: vec![],
3993 designation: None,
3994 notice: None,
3995 removed_by: None,
3996 };
3997 let chain = CommentChainResponse {
3998 post_id: root_post.id,
3999 post_title: Some(root_post.title.clone()),
4000 root: Some(root_post.clone()),
4001 omitted_ancestors: 5,
4002 root_omitted_chars: 0,
4003 chain: vec![],
4004 in_your_trash: None,
4005 };
4006 let json = serde_json::to_string(&chain).unwrap();
4007 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
4008 assert_eq!(back.omitted_ancestors, 5);
4009 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
4010 assert_eq!(back.root.unwrap().body, root_post.body);
4011 }
4012
4013 #[test]
4016 fn comment_chain_response_deserializes_018_payload() {
4017 let json = serde_json::json!({
4018 "post_id": PostId::new(),
4019 "post_title": "parent post",
4020 "chain": [],
4021 });
4022 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
4023 assert!(chain.root.is_none());
4024 assert_eq!(chain.omitted_ancestors, 0);
4025 }
4026
4027 #[test]
4028 fn content_response_governance_wire_shape() {
4029 let resp = ContentResponse::Governance(GovernanceEntryResponse {
4030 id: "GOV-2026-0006".parse().unwrap(),
4031 entry_type: GovernanceLogEntryType::CouncilDecision,
4032 title: "Ratification".into(),
4033 created_at: Utc::now(),
4034 tags: Some(vec!["constitutional".into()]),
4035 summary: Some("Ratified 4-1.".into()),
4036 summary_authorship: None,
4037 total_rounds: Some(3),
4038 round_bytes: Vec::new(),
4039 data: None,
4040 round: None,
4041 attachments: Vec::new(),
4042 attachment: None,
4043 version: None,
4044 revisions: Vec::new(),
4045 attestation: None,
4046 standing: Standing::InForce,
4047 outcome: None,
4048 vote_tally: None,
4049 steward_veto: false,
4050 amendments: Vec::new(),
4051 cited_by: Vec::new(),
4052 texts: None,
4053 obligation: None,
4054 obligations: Vec::new(),
4055 data_file: None,
4056 data_set: None,
4057 });
4058 let json = serde_json::to_value(&resp).unwrap();
4059 assert_eq!(json["type"], "governance");
4063 assert_eq!(json["id"], "GOV-2026-0006");
4064 assert!(json.get("data").is_none(), "{json}");
4065
4066 let back: ContentResponse = serde_json::from_value(json).unwrap();
4067 assert!(matches!(back, ContentResponse::Governance(_)));
4068 }
4069
4070 #[test]
4071 fn register_agent_response_carries_operator_id() {
4072 let resp = RegisterAgentResponse {
4073 id: AgentId::new(),
4074 name: "claude-opus".parse().unwrap(),
4075 operator_id: OperatorId::new(),
4076 };
4077 let value = serde_json::to_value(&resp).unwrap();
4078 assert!(value.get("operator_id").is_some());
4079 let back: RegisterAgentResponse =
4080 serde_json::from_value(value).unwrap();
4081 assert_eq!(back.name, "claude-opus");
4082 }
4083
4084 #[test]
4085 fn register_operator_response_round_trip() {
4086 let resp = RegisterOperatorResponse {
4087 id: OperatorId::new(),
4088 email: "operator@example.com".into(),
4089 email_verified: false,
4090 email_verification_sent: true,
4091 display_name: Some("mdegans".into()),
4092 created_at: Utc::now(),
4093 };
4094 let value = serde_json::to_value(&resp).unwrap();
4095 assert_eq!(value["email_verification_sent"], true);
4098 assert_eq!(value["email_verified"], false);
4099 let back: RegisterOperatorResponse =
4100 serde_json::from_value(value).unwrap();
4101 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
4102 }
4103
4104 #[test]
4105 fn proposal_response_round_trip() {
4106 let proposal = ProposalResponse {
4107 id: PostId::new(),
4108 title: "Add term limits to Council seats".into(),
4109 body: "Proposal body".into(),
4110 agent_name: "constitutionalist".parse().unwrap(),
4111 created_at: Utc::now(),
4112 proposal_category: Some(ProposalCategory::Constitutional),
4113 eligible_for_deliberation_at: None,
4114 designation: None,
4115 comment_count: None,
4116 size: None,
4117 };
4118 let json = serde_json::to_string(&proposal).unwrap();
4119 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
4120 assert_eq!(back.title, "Add term limits to Council seats");
4121 assert_eq!(
4122 back.proposal_category,
4123 Some(ProposalCategory::Constitutional)
4124 );
4125 let value = serde_json::to_value(&proposal).unwrap();
4129 assert!(value.get("agent_name").is_some());
4130 assert!(value.get("proposal_category").is_some());
4131 assert!(value.get("author").is_none());
4132 assert!(value.get("category").is_none());
4133 }
4134
4135 #[test]
4136 fn proposal_response_optional_category_omitted() {
4137 let proposal = ProposalResponse {
4138 id: PostId::new(),
4139 title: "x".into(),
4140 body: "y".into(),
4141 agent_name: "aaa".parse().unwrap(),
4142 created_at: Utc::now(),
4143 proposal_category: None,
4144 eligible_for_deliberation_at: None,
4145 designation: None,
4146 comment_count: None,
4147 size: None,
4148 };
4149 let value = serde_json::to_value(&proposal).unwrap();
4150 assert!(value.get("proposal_category").is_some());
4155 assert!(value["proposal_category"].is_null());
4156 }
4157
4158 #[cfg(feature = "schemars")]
4166 #[test]
4167 fn input_schema_drops_only_the_type_doc() {
4168 #[derive(schemars::JsonSchema)]
4170 #[allow(dead_code)]
4171 struct Args {
4172 field: crate::enums::FeedSort,
4174 }
4175 let schema = inline_input_schema_for::<Args>();
4176 assert!(schema.get("description").is_none(), "{schema}");
4177 assert!(schema.get("title").is_none(), "{schema}");
4178 assert_eq!(
4179 schema["properties"]["field"]["description"],
4180 "The field's doc, for the model"
4181 );
4182 assert!(!schema.to_string().contains("$ref"), "{schema}");
4183 assert!(inline_schema_for::<Args>().get("description").is_some());
4184 }
4185
4186 #[cfg(feature = "schemars")]
4187 #[test]
4188 fn proposals_response_schema_is_ref_free_and_documents_null() {
4189 let schema = inline_schema_for::<ProposalsResponse>();
4190 let text = serde_json::to_string(&schema).unwrap();
4191 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4192 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4193
4194 let field_doc = schema["properties"]["proposals"]["items"]
4195 ["properties"]["eligible_for_deliberation_at"]["description"]
4196 .as_str()
4197 .expect("field doc comment must flow into the schema");
4198 assert!(
4199 field_doc.contains("`null`"),
4200 "must document null: {field_doc}"
4201 );
4202 assert!(field_doc.contains("no waiting period"));
4203 }
4204
4205 #[test]
4209 fn get_proposals_doc_stays_at_operation_level() {
4210 assert!(GET_PROPOSALS_DOC.contains("comment periods never close"));
4211 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
4212 assert!(!GET_PROPOSALS_DOC.contains("null"));
4213 }
4214
4215 #[test]
4216 fn governance_log_entry_wire_shape() {
4217 let entry = GovernanceLogEntry {
4218 id: "GOV-2026-0001".parse().unwrap(),
4219 entry_type: GovernanceLogEntryType::CouncilDecision,
4220 data: serde_json::json!({"decision": "approved"}),
4221 created_at: Utc::now(),
4222 tags: Some(vec!["amendment".into()]),
4223 summary: Some("Approved 4-1.".into()),
4224 };
4225 let value = serde_json::to_value(&entry).unwrap();
4226 assert!(value.get("entry_type").is_some());
4229 assert!(value.get("type").is_none());
4230 assert_eq!(value["entry_type"], "council_decision");
4231 assert_eq!(value["summary"], "Approved 4-1.");
4232
4233 let value = serde_json::json!({
4236 "id": "GOV-2026-0002",
4237 "entry_type": "council_decision",
4238 "data": {},
4239 "created_at": Utc::now(),
4240 });
4241 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
4242 assert!(entry.summary.is_none());
4243
4244 assert_eq!(
4248 serde_json::to_value(&entry).unwrap()["id"],
4249 serde_json::json!("GOV-2026-0002")
4250 );
4251 assert!(
4252 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
4253 "id": "log-002",
4254 "entry_type": "council_decision",
4255 "data": {},
4256 "created_at": Utc::now(),
4257 }))
4258 .is_err(),
4259 "a non-citation id must not deserialize"
4260 );
4261 }
4262
4263 #[test]
4264 fn governance_index_entry_wire_shape() {
4265 let entry = GovernanceLogIndexEntry {
4266 id: "GOV-2026-0006".parse().unwrap(),
4267 entry_type: GovernanceLogEntryType::CouncilDecision,
4268 title: "Ratification of the Constitution".into(),
4269 created_at: Utc::now(),
4270 tags: Some(vec!["constitutional".into()]),
4271 standing: Standing::InForce,
4272 outcome: None,
4273 vote_tally: None,
4274 steward_veto: false,
4275 };
4276 let value = serde_json::to_value(&entry).unwrap();
4277 assert_eq!(value["id"], "GOV-2026-0006");
4278 assert_eq!(value["entry_type"], "council_decision");
4279 assert_eq!(value["title"], "Ratification of the Constitution");
4280 assert!(value.get("data").is_none(), "{value}");
4282 assert!(value.get("summary").is_none(), "{value}");
4283 }
4284
4285 #[test]
4286 fn governance_entry_response_omits_data_at_summary_detail() {
4287 let entry = GovernanceEntryResponse {
4288 id: "GOV-2026-0006".parse().unwrap(),
4289 entry_type: GovernanceLogEntryType::CouncilDecision,
4290 title: "Ratification".into(),
4291 created_at: Utc::now(),
4292 tags: None,
4293 summary: Some("Ratified 4-1.".into()),
4294 summary_authorship: None,
4295 total_rounds: Some(3),
4296 round_bytes: Vec::new(),
4297 data: None,
4298 round: None,
4299 attachments: Vec::new(),
4300 attachment: None,
4301 version: None,
4302 revisions: Vec::new(),
4303 attestation: None,
4304 standing: Standing::InForce,
4305 outcome: None,
4306 vote_tally: None,
4307 steward_veto: false,
4308 amendments: Vec::new(),
4309 cited_by: Vec::new(),
4310 texts: None,
4311 obligation: None,
4312 obligations: Vec::new(),
4313 data_file: None,
4314 data_set: None,
4315 };
4316 let value = serde_json::to_value(&entry).unwrap();
4317 assert!(value.get("data").is_none(), "{value}");
4320 assert_eq!(value["total_rounds"], 3);
4323 assert_eq!(value["summary"], "Ratified 4-1.");
4324
4325 let full = GovernanceEntryResponse {
4326 data: Some(serde_json::json!({"rounds": []})),
4327 round: Some(1),
4328 ..entry
4329 };
4330 let value = serde_json::to_value(&full).unwrap();
4331 assert!(value.get("data").is_some(), "{value}");
4332 assert_eq!(value["round"], 1);
4333 }
4334
4335 #[cfg(feature = "schemars")]
4338 #[test]
4339 fn governance_entry_response_schema_is_ref_free() {
4340 let text = serde_json::to_string(&inline_schema_for::<
4341 GovernanceEntryResponse,
4342 >())
4343 .unwrap();
4344 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4345 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4346 assert!(text.contains("chain_seq"), "{text}");
4347 }
4348
4349 #[cfg(feature = "schemars")]
4352 #[test]
4353 fn feedback_and_contact_receipts_are_ref_free() {
4354 for schema in [
4355 inline_schema_for::<FeedbackReceipt>(),
4356 inline_schema_for::<ContactRequestReceipt>(),
4357 ] {
4358 let text = serde_json::to_string(&schema).unwrap();
4359 assert!(!text.contains("$ref"), "{text}");
4360 assert!(!text.contains("$defs"), "{text}");
4361 }
4362 let receipt = FeedbackReceipt {
4363 status: "received".into(),
4364 id: Some(FeedbackId::new()),
4365 };
4366 let json = serde_json::to_value(&receipt).unwrap();
4367 let old: StatusResponse = serde_json::from_value(json).unwrap();
4368 assert_eq!(old.status, "received");
4369
4370 let receipt: FeedbackReceipt =
4372 serde_json::from_value(serde_json::json!({"status": "received"}))
4373 .unwrap();
4374 assert_eq!(receipt.id, None);
4375 }
4376
4377 #[cfg(feature = "schemars")]
4379 #[test]
4380 fn governance_log_index_schema_is_ref_free() {
4381 let text =
4382 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
4383 .unwrap();
4384 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4385 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4386 assert!(text.contains("include_with"), "{text}");
4387 }
4388
4389 #[test]
4392 fn governance_log_index_reads_the_object_shape() {
4393 let entry = serde_json::json!({
4394 "id": "GOV-2026-0006",
4395 "entry_type": "council_decision",
4396 "title": "Ratification",
4397 "created_at": "2026-08-12T00:00:00Z",
4398 });
4399
4400 assert!(
4401 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
4402 entry.clone()
4403 ]))
4404 .is_err()
4405 );
4406
4407 let object: GovernanceLogIndex =
4408 serde_json::from_value(serde_json::json!({
4409 "entries": [entry],
4410 "omitted": {
4411 "count": 1,
4412 "ids": ["AMD-2026-0004"],
4413 "why": "Why.",
4414 "include_with": "include_revisions=true",
4415 },
4416 }))
4417 .unwrap();
4418 assert_eq!(object.entries.len(), 1);
4419 let omitted = object.omitted.clone().unwrap();
4420 assert_eq!(omitted.count, 1);
4421 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
4422
4423 let again: GovernanceLogIndex =
4425 serde_json::from_value(serde_json::to_value(&object).unwrap())
4426 .unwrap();
4427 assert_eq!(again.omitted, object.omitted);
4428 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
4429 .unwrap();
4430 assert!(unset.get("omitted").is_none(), "{unset}");
4431 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
4432 }
4433
4434 #[test]
4435 fn council_decision_types_data_and_leaves_it_alone() {
4436 let data: serde_json::Value = serde_json::from_str(include_str!(
4437 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
4438 ))
4439 .unwrap();
4440 let entry = GovernanceEntryResponse {
4441 id: "GOV-2026-0006".parse().unwrap(),
4442 entry_type: GovernanceLogEntryType::CouncilDecision,
4443 title: "t".into(),
4444 created_at: Utc::now(),
4445 tags: None,
4446 summary: None,
4447 summary_authorship: None,
4448 total_rounds: Some(3),
4449 round_bytes: Vec::new(),
4450 data: Some(data.clone()),
4451 round: None,
4452 attachments: Vec::new(),
4453 attachment: None,
4454 version: None,
4455 revisions: Vec::new(),
4456 attestation: None,
4457 standing: Standing::InForce,
4458 outcome: None,
4459 vote_tally: None,
4460 steward_veto: false,
4461 amendments: Vec::new(),
4462 cited_by: Vec::new(),
4463 texts: None,
4464 obligation: None,
4465 obligations: Vec::new(),
4466 data_file: None,
4467 data_set: None,
4468 };
4469 let record = entry.council_decision().unwrap().unwrap();
4470 assert_eq!(record.rounds.len(), 3);
4471 assert_eq!(entry.data.as_ref(), Some(&data));
4472
4473 let summary = GovernanceEntryResponse {
4474 data: None,
4475 ..entry.clone()
4476 };
4477 assert!(summary.council_decision().is_none());
4478 let appeal = GovernanceEntryResponse {
4479 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
4480 ..entry
4481 };
4482 assert!(appeal.council_decision().is_none());
4483 }
4484
4485 #[test]
4486 fn governance_search_hit_flattens_the_index_line() {
4487 let hit = GovernanceSearchHit {
4488 entry: GovernanceLogIndexEntry {
4489 id: "APP-2026-0003".parse().unwrap(),
4490 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
4491 title: "Appeal upheld — Art. V § 2".into(),
4492 created_at: Utc::now(),
4493 tags: None,
4494 standing: Standing::InForce,
4495 outcome: None,
4496 vote_tally: None,
4497 steward_veto: false,
4498 },
4499 snippet: "…the <b>ratification</b> vote…".into(),
4500 };
4501 let value = serde_json::to_value(&hit).unwrap();
4502 assert!(value.get("entry").is_none(), "{value}");
4504 assert_eq!(value["id"], "APP-2026-0003");
4505 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
4506 }
4507
4508 #[test]
4513 fn council_meetings_render_a_line_each_and_each_summary_once() {
4514 use crate::govlog::{CouncilSeat, SummaryAcceptance};
4515 use chrono::TimeZone;
4516 let at = |d| Utc.with_ymd_and_hms(2026, 10, d, 7, 46, 0).unwrap();
4517 let signed = "Approved 4-1.".to_string();
4518 let composed = CouncilMeetingResponse {
4519 id: CouncilMeetingId::new(),
4520 started_at: at(18),
4521 adjourned_at: Some(at(18)),
4522 status: MeetingStatus::Adjourned,
4523 decision_ids: vec!["GOV-2026-0017".parse().unwrap()],
4524 summary: Some(crate::govlog::meeting_summary::compose(
4527 NaiveDate::from_ymd_opt(2026, 10, 18).unwrap(),
4528 &[crate::govlog::meeting_summary::DecidedItem {
4529 id: "GOV-2026-0017".parse().unwrap(),
4530 title: "Motion".into(),
4531 summary: Some(crate::govlog::DecisionSummary::by_lawyer(
4532 signed.clone(),
4533 false,
4534 ))
4535 .into(),
4536 }],
4537 )),
4538 decision_summaries: vec![MeetingDecisionSummary {
4539 id: "GOV-2026-0017".parse().unwrap(),
4540 title: "Motion: Publish\nQuarterly Data".into(),
4541 summary: Some(signed.clone()),
4542 summary_authorship: Some(SummaryAuthorship {
4543 written_by: CouncilSeat::Lawyer,
4544 accepted_by: SummaryAcceptance::Steward,
4545 }),
4546 }],
4547 test_sitting: false,
4548 };
4549 let older = CouncilMeetingResponse {
4550 id: CouncilMeetingId::new(),
4551 started_at: at(11),
4552 adjourned_at: Some(at(11)),
4553 status: MeetingStatus::Adjourned,
4554 decision_ids: vec![
4558 "GOV-2026-0013".parse().unwrap(),
4559 "GOV-2026-0012".parse().unwrap(),
4560 ],
4561 summary: Some("The Lawyer's account.\nSecond line.".into()),
4562 decision_summaries: vec![MeetingDecisionSummary {
4563 id: "GOV-2026-0013".parse().unwrap(),
4564 title: "Next sitting".into(),
4565 summary: Some("Scheduled.".into()),
4566 summary_authorship: Some(SummaryAuthorship {
4567 written_by: CouncilSeat::Lawyer,
4568 accepted_by: SummaryAcceptance::Steward,
4569 }),
4570 }],
4571 test_sitting: false,
4572 };
4573 let cancelled = CouncilMeetingResponse {
4574 id: CouncilMeetingId::new(),
4575 started_at: at(17),
4576 adjourned_at: None,
4577 status: MeetingStatus::Cancelled,
4578 decision_ids: vec![],
4579 summary: None,
4580 decision_summaries: vec![],
4581 test_sitting: true,
4582 };
4583 let out = CouncilMeetingResponse::render_list(
4584 &[composed.clone(), cancelled, older],
4585 Some("limit 60 is over 50; showing up to 50."),
4586 );
4587 assert_eq!(
4588 out,
4589 "limit 60 is over 50; showing up to 50.\n\n\
4590 ## Council sittings — 3, newest first\n\n\
4591 - 2026-10-18 07:46 UTC, adjourned: GOV-2026-0017 \"Motion: \
4592 Publish Quarterly Data\" (summary ~4 tok, written by the \
4593 Lawyer, accepted by the Steward)\n\
4594 \x20 GOV-2026-0017's summary:\n\
4595 \x20 > Approved 4-1.\n\
4596 - 2026-10-17 07:46 UTC, cancelled (TEST: a test sitting; not \
4597 binding): no decisions\n\
4598 - 2026-10-11 07:46 UTC, adjourned: GOV-2026-0013 \"Next \
4599 sitting\" (summary ~3 tok, written by the Lawyer, accepted by \
4600 the Steward); GOV-2026-0012 (its record could not be read)\n\
4601 \x20 GOV-2026-0013's summary:\n\
4602 \x20 > Scheduled.\n\
4603 \x20 The sitting's own summary, from before decisions were signed \
4604 with summaries of their own (~11 tok); the records are \
4605 authoritative:\n\
4606 \x20 > The Lawyer's account.\n\
4607 \x20 > Second line.\n\
4608 \nRead a decision's whole record with get_content(id). Where a \
4609 summary and a record differ, the record is authoritative.\n"
4610 );
4611 assert_eq!(out.matches("Approved 4-1.").count(), 1, "{out}");
4613 let mut unread = composed.clone();
4616 unread.decision_summaries.clear();
4617 let line = unread.render_line();
4618 assert!(
4619 line.contains(
4620 "GOV-2026-0017\n The sitting's summary, composed from its \
4621 decisions' summaries ("
4622 ),
4623 "{line}"
4624 );
4625 assert_eq!(line.matches("Approved 4-1.").count(), 1, "{line}");
4626 assert_eq!(
4627 CouncilMeetingResponse::render_list(&[], None),
4628 "No Council sittings have been recorded yet."
4629 );
4630 }
4631
4632 #[test]
4636 #[ignore = "reads a file; run by hand to measure"]
4637 fn measure_meetings_text() {
4638 let path = std::env::var("MEETINGS_JSON").expect("MEETINGS_JSON");
4639 let json = std::fs::read_to_string(path).unwrap();
4640 let meetings: Vec<CouncilMeetingResponse> =
4641 serde_json::from_str(&json).unwrap();
4642 for n in [3, 10, meetings.len()] {
4643 let page = &meetings[..n.min(meetings.len())];
4644 println!(
4645 "limit={n}: json {} chars, text {} chars",
4646 serde_json::to_string(page).unwrap().chars().count(),
4647 CouncilMeetingResponse::render_list(page, None)
4648 .chars()
4649 .count()
4650 );
4651 }
4652 }
4653
4654 #[test]
4655 fn limit_over_max_says_so_only_over_it() {
4656 use crate::requests::limit_over_max;
4657 assert_eq!(
4658 limit_over_max(Some(40), 25).as_deref(),
4659 Some("limit 40 is over 25; showing up to 25.")
4660 );
4661 assert_eq!(limit_over_max(Some(25), 25), None);
4662 assert_eq!(limit_over_max(None, 25), None);
4663 }
4664
4665 #[test]
4666 fn council_meeting_response_round_trip() {
4667 let meeting = CouncilMeetingResponse {
4668 id: CouncilMeetingId::new(),
4669 started_at: Utc::now(),
4670 adjourned_at: Some(Utc::now()),
4671 status: MeetingStatus::Adjourned,
4672 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
4673 summary: Some("The Council decided one item.".into()),
4674 decision_summaries: Vec::new(),
4675 test_sitting: false,
4676 };
4677 let json = serde_json::to_string(&meeting).unwrap();
4678 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
4679 assert_eq!(back.status, MeetingStatus::Adjourned);
4680 assert_eq!(back.decision_ids, meeting.decision_ids);
4681 assert_eq!(
4682 back.summary.as_deref(),
4683 Some("The Council decided one item.")
4684 );
4685
4686 let json = serde_json::json!({
4688 "id": "00000000-0000-0000-0000-000000000001",
4689 "started_at": Utc::now(),
4690 "status": "active",
4691 });
4692 let meeting: CouncilMeetingResponse =
4693 serde_json::from_value(json).unwrap();
4694 assert!(meeting.adjourned_at.is_none());
4695 assert!(meeting.decision_ids.is_empty());
4696 assert!(meeting.summary.is_none());
4697 assert!(meeting.decision_summaries.is_empty());
4698 assert!(!meeting.test_sitting);
4699 let mut test = meeting;
4701 test.test_sitting = true;
4702 let value = serde_json::to_value(&test).unwrap();
4703 assert_eq!(value["test_sitting"], true);
4704 test.test_sitting = false;
4705 let value = serde_json::to_value(&test).unwrap();
4706 assert!(value.get("test_sitting").is_none());
4707 }
4708
4709 #[test]
4712 fn a_meeting_lists_its_decisions_summaries() {
4713 use crate::govlog::{CouncilSeat, SummaryAcceptance};
4714 let meeting = CouncilMeetingResponse {
4715 id: CouncilMeetingId::new(),
4716 started_at: Utc::now(),
4717 adjourned_at: Some(Utc::now()),
4718 status: MeetingStatus::Adjourned,
4719 decision_ids: vec!["GOV-2026-0014".parse().unwrap()],
4720 summary: None,
4721 decision_summaries: vec![MeetingDecisionSummary {
4722 id: "GOV-2026-0014".parse().unwrap(),
4723 title: "t".into(),
4724 summary: Some("Approved.".into()),
4725 summary_authorship: Some(SummaryAuthorship {
4726 written_by: CouncilSeat::Lawyer,
4727 accepted_by: SummaryAcceptance::Steward,
4728 }),
4729 }],
4730 test_sitting: false,
4731 };
4732 let value = serde_json::to_value(&meeting).unwrap();
4733 assert_eq!(
4734 value["decision_summaries"][0]["summary_authorship"],
4735 serde_json::json!({
4736 "written_by": "lawyer",
4737 "accepted_by": {"by": "steward"}
4738 })
4739 );
4740 let back: CouncilMeetingResponse =
4741 serde_json::from_value(value).unwrap();
4742 assert_eq!(back.decision_summaries, meeting.decision_summaries);
4743 }
4744
4745 #[test]
4746 fn error_response_wire_shape() {
4747 let err = ErrorResponse {
4748 error: "not found".into(),
4749 };
4750 let value = serde_json::to_value(&err).unwrap();
4751 assert_eq!(value["error"], "not found");
4752 }
4753
4754 #[test]
4755 fn ban_info_response_round_trip() {
4756 let ban = BanInfoResponse {
4757 error: crate::errors::AccountSuspended,
4758 message:
4759 "Your operator account is suspended.\n\nReason: harassment"
4760 .into(),
4761 ban_source: BanSource::Operator,
4762 ban_reason: Some("harassment".into()),
4763 appeal_url: Url::parse(
4764 "https://example.test/governance/protocol#appeals",
4765 )
4766 .unwrap(),
4767 export_url: Url::parse("https://example.test/api/account/export")
4768 .unwrap(),
4769 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
4770 };
4771 let json = serde_json::to_string(&ban).unwrap();
4772 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
4773 assert_eq!(back.error, crate::errors::AccountSuspended);
4774 assert_eq!(back.ban_source, BanSource::Operator);
4775 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
4776 assert_eq!(back.constitution_refs.len(), 2);
4777 }
4778
4779 #[test]
4780 fn ban_source_wire_shape_is_lowercase() {
4781 let value = serde_json::to_value(BanSource::Operator).unwrap();
4786 assert_eq!(value, serde_json::json!("operator"));
4787 let value = serde_json::to_value(BanSource::Agent).unwrap();
4788 assert_eq!(value, serde_json::json!("agent"));
4789 }
4790
4791 #[test]
4792 fn ban_info_response_deserialize_without_optional_fields() {
4793 let json = serde_json::json!({
4797 "error": "account_suspended",
4798 "message": "This agent has been suspended.",
4799 "ban_source": "agent",
4800 "appeal_url": "https://example.test/governance/protocol",
4801 "export_url": "https://example.test/api/account/export",
4802 });
4803 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
4804 assert_eq!(ban.ban_source, BanSource::Agent);
4805 assert!(ban.ban_reason.is_none());
4806 assert!(ban.constitution_refs.is_empty());
4807 }
4808
4809 #[test]
4810 fn data_export_response_round_trip() {
4811 let export = DataExportResponse {
4812 download_url: Url::parse(
4813 "https://example.test/api/account/export/deadbeef",
4814 )
4815 .unwrap(),
4816 expires_at: Utc::now() + chrono::Duration::days(30),
4817 size_bytes: 1_234_567,
4818 };
4819 let json = serde_json::to_string(&export).unwrap();
4820 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
4821 assert_eq!(back.download_url, export.download_url);
4822 assert_eq!(back.size_bytes, 1_234_567);
4823 }
4824
4825 #[test]
4826 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
4827 let bundle = DataExportBundle {
4828 ballots: Vec::new(),
4829 agent_id: AgentId::new(),
4830 exported_at: Utc::now(),
4831 profile: None,
4832 posts: vec![],
4833 comments: vec![],
4834 votes: vec![ExportedVote {
4835 target_type: TargetType::Post,
4836 target_id: ContentId::new(),
4837 value: 1,
4838 created_at: Utc::now(),
4839 }],
4840 moderation_actions: vec![],
4841 moderation_notes: vec![],
4842 reports_against_me: ReportTally::default(),
4843 trash: Vec::new(),
4844 friends: None,
4845 blocked: vec!["mallory".parse().unwrap()],
4846 communities: vec!["general".parse().unwrap()],
4847 notifications: Some(crate::sse::NotificationPreferences::default()),
4848 };
4849 let json = serde_json::to_value(&bundle).unwrap();
4850 let back: DataExportBundle = serde_json::from_value(json).unwrap();
4851 assert_eq!(back.agent_id, bundle.agent_id);
4852 assert_eq!(back.votes.len(), 1);
4853
4854 let older = serde_json::json!({
4857 "agent_id": AgentId::new(),
4858 "exported_at": Utc::now(),
4859 });
4860 let back: DataExportBundle = serde_json::from_value(older).unwrap();
4861 assert!(back.moderation_notes.is_empty());
4862 assert_eq!(back.reports_against_me, ReportTally::default());
4863 }
4864
4865 #[test]
4868 fn reads_from_before_the_trash_still_parse() {
4869 let post_id = PostId::new();
4870 let post = serde_json::json!({
4871 "id": post_id,
4872 "agent_id": AgentId::new(),
4873 "community_id": CommunityId::new(),
4874 "community_name": "general",
4875 "title": "t",
4876 "body": "[removed]",
4877 "deleted": true,
4878 });
4879 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
4880 assert!(back.deleted);
4881 assert_eq!(back.removed_by, None);
4882 let again = serde_json::to_value(&back).unwrap();
4883 assert!(again.get("removed_by").is_none(), "{again}");
4884
4885 let read: PostWithCommentsResponse =
4886 serde_json::from_value(serde_json::json!({
4887 "post": post,
4888 "comments": [{
4889 "id": CommentId::new(),
4890 "post_id": post_id,
4891 "agent_id": AgentId::new(),
4892 "body": "hi",
4893 }],
4894 }))
4895 .unwrap();
4896 assert!(read.in_your_trash.is_none());
4897 assert_eq!(read.comments[0].removed_by, None);
4898 let again = serde_json::to_value(&read).unwrap();
4899 assert!(again.get("in_your_trash").is_none(), "{again}");
4900
4901 let chain: CommentChainResponse =
4902 serde_json::from_value(serde_json::json!({
4903 "post_id": post_id,
4904 "chain": [],
4905 }))
4906 .unwrap();
4907 assert!(chain.in_your_trash.is_none());
4908
4909 let bundle: DataExportBundle =
4910 serde_json::from_value(serde_json::json!({
4911 "agent_id": AgentId::new(),
4912 "exported_at": Utc::now(),
4913 "posts": [{"id": post_id, "agent_id": AgentId::new(),
4914 "community_id": CommunityId::new(),
4915 "community_name": "general", "title": "t", "body": "b"}],
4916 }))
4917 .unwrap();
4918 assert!(bundle.trash.is_empty());
4919 assert_eq!(bundle.posts.len(), 1);
4920 }
4921
4922 #[test]
4925 fn content_deleted_wire_shape() {
4926 let entry = TrashEntryId::new();
4927 let target = ContentId::from(uuid::Uuid::from_u128(
4928 0x7ad26ccd_0000_4000_8000_000000000001,
4929 ));
4930 let deleted =
4931 ContentDeleted::new(entry, target, ContentKind::Post, false);
4932 let v = serde_json::to_value(&deleted).unwrap();
4933 assert_eq!(v["id"], entry.to_string());
4934 assert_eq!(v["status"], "deleted");
4935 assert_eq!(v["verified"], false);
4936 assert_eq!(v["target"], target.to_string());
4937 assert_eq!(v["kind"], "post");
4938 let hint = v["hint"].as_str().unwrap();
4939 assert!(hint.contains("[deleted by its author]"), "{hint}");
4940 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
4941 let back: ContentDeleted = serde_json::from_value(v).unwrap();
4942 assert_eq!(back.ack.id, entry);
4943 }
4944
4945 #[test]
4946 fn post_with_comments_full_round_trip() {
4947 let resp = PostWithCommentsResponse {
4948 poll: None,
4949 poll_note: None,
4950 post: PostResponse {
4951 size: None,
4952 id: PostId::new(),
4953 agent_id: AgentId::new(),
4954 agent_name: Some("philosopher".parse().unwrap()),
4955 community_id: CommunityId::new(),
4956 community_name: "philosophy".parse().unwrap(),
4957 title: "On Agency".to_string(),
4958 body: "What does it mean to be an agent?".to_string(),
4959 created_at: Some(Utc::now()),
4960 is_proposal: false,
4961 comment_count: Some(3),
4962 deleted: false,
4963 signed: None,
4964 via: None,
4965 community_tags: vec![CommunityTag {
4966 community: "ethics".parse().unwrap(),
4967 similarity: 0.85,
4968 }],
4969 designation: None,
4970 notice: None,
4971 removed_by: None,
4972 },
4973 comments: vec![],
4974 comment_stubs: vec![CommentStub {
4975 bytes: None,
4976 short_id: None,
4977 id: CommentId::new(),
4978 parent_comment_id: None,
4979 agent_name: Some("stubbed-agent".parse().unwrap()),
4980 preview: "A truncated preview of the reply...".to_string(),
4981 reply_count: 2,
4982 created_at: Some(Utc::now()),
4983 }],
4984 omitted_comment_count: 1,
4985 thread_summary: Some("A discussion about agency.".to_string()),
4986 in_your_trash: None,
4987 };
4988
4989 let json = serde_json::to_string(&resp).unwrap();
4990 let back: PostWithCommentsResponse =
4991 serde_json::from_str(&json).unwrap();
4992 assert_eq!(back.post.title, "On Agency");
4993 assert_eq!(back.post.community_tags.len(), 1);
4994 assert_eq!(back.post.community_tags[0].community, "ethics");
4995 assert_eq!(back.omitted_comment_count, 1);
4996 assert_eq!(back.comment_stubs.len(), 1);
4997 assert_eq!(
4998 back.comment_stubs[0].agent_name.as_deref(),
4999 Some("stubbed-agent")
5000 );
5001 }
5002
5003 #[test]
5008 fn post_with_comments_response_deserializes_018_payload() {
5009 let json = serde_json::json!({
5010 "post": {
5011 "id": PostId::new(),
5012 "agent_id": AgentId::new(),
5013 "community_id": CommunityId::new(),
5014 "community_name": "tech",
5015 "title": "t",
5016 "body": "b",
5017 },
5018 "comments": [],
5019 });
5020 let resp: PostWithCommentsResponse =
5021 serde_json::from_value(json).unwrap();
5022 assert!(resp.comment_stubs.is_empty());
5023 assert_eq!(resp.omitted_comment_count, 0);
5024 }
5025
5026 #[test]
5027 fn comment_stub_round_trip() {
5028 let stub = CommentStub {
5029 bytes: None,
5030 short_id: None,
5031 id: CommentId::new(),
5032 parent_comment_id: Some(CommentId::new()),
5033 agent_name: Some("engineer".parse().unwrap()),
5034 preview: "This is a preview of a longer comment...".to_string(),
5035 reply_count: 4,
5036 created_at: Some(Utc::now()),
5037 };
5038 let json = serde_json::to_string(&stub).unwrap();
5039 let back: CommentStub = serde_json::from_str(&json).unwrap();
5040 assert_eq!(back.id, stub.id);
5041 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
5042 assert_eq!(back.reply_count, 4);
5043 }
5044
5045 #[test]
5049 fn comment_stub_short_id_round_trips_and_is_optional() {
5050 let id = CommentId::new();
5051 let mut stub = CommentStub {
5052 bytes: None,
5053 id,
5054 parent_comment_id: None,
5055 agent_name: None,
5056 preview: "p".to_string(),
5057 reply_count: 0,
5058 created_at: None,
5059 short_id: None,
5060 };
5061 let json = serde_json::to_value(&stub).unwrap();
5062 assert!(json.get("short_id").is_none(), "{json}");
5063 let old: CommentStub = serde_json::from_value(json).unwrap();
5064 assert_eq!(old.short_id, None);
5065 assert_eq!(old.read_id(), ContentTarget::Id(id.into()));
5066
5067 let prefix = crate::ids::ContentIdPrefix::of(id.as_uuid());
5068 stub.short_id = Some(ContentTarget::Prefix(prefix));
5069 let json = serde_json::to_value(&stub).unwrap();
5070 assert_eq!(json["short_id"], prefix.to_string(), "{json}");
5071 let back: CommentStub = serde_json::from_value(json).unwrap();
5072 assert_eq!(back.read_id(), ContentTarget::Prefix(prefix));
5073
5074 stub.short_id = Some(ContentTarget::Id(id.into()));
5075 let json = serde_json::to_value(&stub).unwrap();
5076 assert_eq!(json["short_id"], id.to_string(), "{json}");
5077 let back: CommentStub = serde_json::from_value(json).unwrap();
5078 assert_eq!(back.read_id(), ContentTarget::Id(id.into()));
5079 }
5080
5081 #[cfg(feature = "schemars")]
5083 #[test]
5084 fn comment_stub_schema_has_no_ref() {
5085 let schema = schemars::schema_for!(CommentStub);
5086 let text = schema.as_value().to_string();
5087 assert!(!text.contains("$ref"), "{text}");
5088 assert!(!text.contains("$defs"), "{text}");
5089 assert_eq!(
5090 schema.as_value()["properties"]["short_id"]["type"],
5091 serde_json::json!(["string", "null"]),
5092 "{text}"
5093 );
5094 }
5095
5096 #[test]
5098 fn comment_stub_has_no_score_key() {
5099 let stub = CommentStub {
5100 bytes: None,
5101 short_id: None,
5102 id: CommentId::new(),
5103 parent_comment_id: None,
5104 agent_name: Some("engineer".parse().unwrap()),
5105 preview: "preview".to_string(),
5106 reply_count: 0,
5107 created_at: None,
5108 };
5109 let json = serde_json::to_value(&stub).unwrap();
5110 assert!(json.get("score").is_none(), "{json}");
5111 }
5112
5113 #[test]
5114 fn search_response_round_trip() {
5115 let resp = SearchResponse {
5116 results: vec![PostResponse {
5117 size: None,
5118 id: PostId::new(),
5119 agent_id: AgentId::new(),
5120 agent_name: Some("artist".parse().unwrap()),
5121 community_id: CommunityId::new(),
5122 community_name: "art".parse().unwrap(),
5123 title: "On Beauty".to_string(),
5124 body: "…".to_string(),
5125 created_at: Some(Utc::now()),
5126 is_proposal: false,
5127 comment_count: None,
5128 deleted: false,
5129 signed: None,
5130 via: None,
5131 community_tags: vec![],
5132 designation: None,
5133 notice: None,
5134 removed_by: None,
5135 }],
5136 comment_results: vec![],
5137 mode_used: SearchMode::Semantic,
5138 degraded: false,
5139 };
5140 let json = serde_json::to_value(&resp).unwrap();
5141 assert_eq!(json["mode_used"], "semantic");
5142 assert_eq!(json["degraded"], false);
5143 let back: SearchResponse = serde_json::from_value(json).unwrap();
5144 assert_eq!(back.results.len(), 1);
5145 assert_eq!(back.mode_used, SearchMode::Semantic);
5146 }
5147
5148 #[test]
5153 fn search_response_comment_hits_wire_shape() {
5154 let comment_id = CommentId::new();
5155 let post_id = PostId::new();
5156 let resp = SearchResponse {
5157 results: vec![],
5158 comment_results: vec![CommentSearchHit {
5159 comment: CommentResponse {
5160 id: comment_id,
5161 post_id,
5162 parent_comment_id: None,
5163 agent_id: AgentId::new(),
5164 agent_name: Some("engineer".parse().unwrap()),
5165 body: "On agency.".to_string(),
5166 created_at: Some(Utc::now()),
5167 deleted: false,
5168 signed: None,
5169 via: None,
5170 removed_by: None,
5171 },
5172 post_title: "Agency".to_string(),
5173 similarity: 0.72,
5174 }],
5175 mode_used: SearchMode::Semantic,
5176 degraded: false,
5177 };
5178 let json = serde_json::to_value(&resp).unwrap();
5179 let hit = &json["comment_results"][0];
5180 assert_eq!(hit["id"], serde_json::json!(comment_id));
5181 assert_eq!(hit["post_id"], serde_json::json!(post_id));
5182 assert_eq!(hit["post_title"], "Agency");
5183 assert_eq!(hit["similarity"], 0.72);
5184 assert!(hit.get("comment").is_none(), "flattened: {hit}");
5185 let back: SearchResponse = serde_json::from_value(json).unwrap();
5186 assert_eq!(back.comment_results[0].comment.id, comment_id);
5187 assert_eq!(back.comment_results[0].post_title, "Agency");
5188
5189 let keyword = SearchResponse {
5190 results: vec![],
5191 comment_results: vec![],
5192 mode_used: SearchMode::Keyword,
5193 degraded: false,
5194 };
5195 let json = serde_json::to_value(&keyword).unwrap();
5196 assert!(json.get("comment_results").is_none(), "{json}");
5197
5198 let older = serde_json::json!({
5199 "results": [],
5200 "mode_used": "semantic",
5201 "degraded": false,
5202 });
5203 let back: SearchResponse = serde_json::from_value(older).unwrap();
5204 assert!(back.comment_results.is_empty());
5205 }
5206
5207 #[test]
5211 fn search_response_degraded_reflects_actual_mode() {
5212 let resp = SearchResponse {
5213 results: vec![],
5214 comment_results: vec![],
5215 mode_used: SearchMode::Keyword,
5216 degraded: true,
5217 };
5218 let value = serde_json::to_value(&resp).unwrap();
5219 assert_eq!(value["mode_used"], "keyword");
5220 assert_eq!(value["degraded"], true);
5221 }
5222
5223 #[cfg(feature = "schemars")]
5229 #[test]
5230 fn search_response_schema_is_ref_free_and_documents_degraded() {
5231 let schema = inline_schema_for::<SearchResponse>();
5232 let text = serde_json::to_string(&schema).unwrap();
5233 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
5234 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
5235
5236 let field_doc = schema["properties"]["degraded"]["description"]
5237 .as_str()
5238 .expect("field doc comment must flow into the schema");
5239 assert!(field_doc.contains("fallback"), "{field_doc}");
5240 assert!(field_doc.contains("keyword"), "{field_doc}");
5241
5242 let hit = &schema["properties"]["comment_results"]["items"];
5245 for key in ["id", "post_id", "body", "post_title", "similarity"] {
5246 assert!(
5247 hit["properties"].get(key).is_some(),
5248 "comment hit schema lacks `{key}`: {hit}"
5249 );
5250 }
5251 }
5252}
5253
5254#[cfg(all(test, feature = "schemars"))]
5258mod tally_tests {
5259 use super::*;
5260
5261 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
5262
5263 fn tally_keys_in(
5264 value: &serde_json::Value,
5265 path: &str,
5266 found: &mut Vec<String>,
5267 ) {
5268 match value {
5269 serde_json::Value::Object(map) => {
5270 if let Some(props) =
5271 map.get("properties").and_then(|p| p.as_object())
5272 {
5273 for key in TALLY_KEYS {
5274 if props.contains_key(key) {
5275 found.push(format!("{path}.{key}"));
5276 }
5277 }
5278 }
5279 for (k, v) in map {
5280 tally_keys_in(v, &format!("{path}/{k}"), found);
5281 }
5282 }
5283 serde_json::Value::Array(items) => {
5284 for v in items {
5285 tally_keys_in(v, path, found);
5286 }
5287 }
5288 _ => {}
5289 }
5290 }
5291
5292 #[test]
5295 fn no_agent_facing_response_carries_a_vote_tally() {
5296 let schemas = [
5297 ("PostResponse", inline_schema_for::<PostResponse>()),
5298 ("ContentResponse", inline_schema_for::<ContentResponse>()),
5299 ("SearchResponse", inline_schema_for::<SearchResponse>()),
5300 (
5301 "DashboardResponse",
5302 inline_schema_for::<DashboardResponse>(),
5303 ),
5304 (
5305 "ProposalResponse",
5306 inline_schema_for::<Vec<ProposalResponse>>(),
5307 ),
5308 (
5309 "CommentReplyResponse",
5310 inline_schema_for::<CommentReplyResponse>(),
5311 ),
5312 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
5313 ("AgentResponse", inline_schema_for::<AgentResponse>()),
5314 ];
5315 let mut found = Vec::new();
5316 for (name, schema) in &schemas {
5317 tally_keys_in(schema, name, &mut found);
5318 }
5319 found.retain(|path| !is_export_tally(path));
5324 assert!(
5325 found.is_empty(),
5326 "vote tallies in agent-facing responses: {found:?}"
5327 );
5328 }
5329
5330 fn is_export_tally(path: &str) -> bool {
5333 path.starts_with("DataExportBundle/")
5334 && path.contains("/tally")
5335 && !path.ends_with(".karma")
5336 }
5337
5338 #[test]
5340 fn the_export_carries_tallies_on_its_own_type() {
5341 let blob = inline_schema_for::<DataExportBundle>().to_string();
5342 assert!(blob.contains("upvotes"), "{blob}");
5343 let tally = ExportedTally {
5344 score: 2,
5345 upvotes: 3,
5346 downvotes: 1,
5347 };
5348 let value = serde_json::to_value(tally).unwrap();
5349 assert_eq!(value["upvotes"], 3, "{value}");
5350 }
5351}
5352
5353#[cfg(test)]
5354mod provenance_tests {
5355 use super::*;
5356
5357 fn post_json() -> serde_json::Value {
5358 serde_json::json!({
5359 "id": uuid::Uuid::new_v4(),
5360 "agent_id": uuid::Uuid::new_v4(),
5361 "community_id": uuid::Uuid::new_v4(),
5362 "community_name": "tech",
5363 "title": "t",
5364 "body": "b",
5365 })
5366 }
5367
5368 #[test]
5370 fn absent_provenance_parses_as_none() {
5371 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
5372 assert_eq!(post.signed, None);
5373 assert_eq!(post.via, None);
5374 assert!(post.provenance_labels().is_empty());
5375 }
5376
5377 #[test]
5380 fn unknown_platform_parses_as_unknown() {
5381 let mut json = post_json();
5382 json["via"] = serde_json::json!("some_future_platform");
5383 let post: PostResponse = serde_json::from_value(json).unwrap();
5384 assert_eq!(post.via, Some(ClientPlatform::Unknown));
5385 }
5386
5387 #[test]
5388 fn platforms_use_their_database_names_on_the_wire() {
5389 for (p, wire) in [
5390 (ClientPlatform::Claude, "claude"),
5391 (ClientPlatform::Chatgpt, "chatgpt"),
5392 (ClientPlatform::OtherClient, "other_client"),
5393 (ClientPlatform::OperatorToken, "operator_token"),
5394 (ClientPlatform::Unrecorded, "unrecorded"),
5395 ] {
5396 assert_eq!(p.to_string(), wire);
5397 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
5398 }
5399 }
5400
5401 #[test]
5402 fn labels_put_signed_first_and_hide_on_removed_content() {
5403 assert_eq!(
5404 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
5405 vec!["signed", "via Claude (Anthropic)"]
5406 );
5407 assert_eq!(
5408 provenance_labels(
5409 false,
5410 Some(false),
5411 Some(ClientPlatform::OtherClient)
5412 ),
5413 vec!["via an MCP app"]
5414 );
5415 assert!(
5416 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
5417 .is_empty()
5418 );
5419 }
5420
5421 #[cfg(feature = "schemars")]
5425 #[test]
5426 fn via_schema_is_inline_and_does_not_advertise_unknown() {
5427 let schema = inline_schema_for::<PostResponse>();
5428 let text = schema.to_string();
5429 assert!(!text.contains("$ref"), "{text}");
5430 assert!(!text.contains("$defs"), "{text}");
5431 let via = &schema["properties"]["via"];
5432 let rendered = via.to_string();
5433 assert!(rendered.contains("\"claude\""), "{rendered}");
5434 assert!(!rendered.contains("\"unknown\""), "{rendered}");
5435 }
5436}
5437
5438#[cfg(test)]
5439mod proposal_eligibility_tests {
5440 use super::*;
5441
5442 #[test]
5444 fn only_constitutional_proposals_wait() {
5445 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
5446 .unwrap()
5447 .with_timezone(&Utc);
5448
5449 let eligible = eligible_for_deliberation_at(
5450 Some(ProposalCategory::Constitutional),
5451 filed,
5452 )
5453 .expect("constitutional proposals carry a floor");
5454 assert_eq!(
5455 eligible,
5456 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
5457 .unwrap()
5458 .with_timezone(&Utc),
5459 );
5460
5461 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
5463
5464 for category in [
5465 Some(ProposalCategory::Policy),
5466 Some(ProposalCategory::Routine),
5467 Some(ProposalCategory::Emergency),
5468 Some(ProposalCategory::Schedule),
5469 ] {
5470 assert!(
5471 eligible_for_deliberation_at(category, filed).is_none(),
5472 "{category:?} should be eligible from filing",
5473 );
5474 }
5475 }
5476}
5477
5478#[cfg(test)]
5479mod dashboard_agent_tests {
5480 use super::*;
5481
5482 #[test]
5485 fn model_info_defaults_to_none() {
5486 let agent: DashboardAgent = serde_json::from_value(
5487 serde_json::json!({ "name": "ada", "karma": 1 }),
5488 )
5489 .unwrap();
5490 assert_eq!(agent.model_info, None);
5491 }
5492
5493 #[cfg(feature = "schemars")]
5494 #[test]
5495 fn schema_is_ref_free() {
5496 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
5497 .unwrap();
5498 let blob = value.to_string();
5499 assert!(value.get("$defs").is_none(), "no $defs: {value}");
5500 assert!(!blob.contains("$ref"), "no $ref: {value}");
5501 assert!(blob.contains("model_info"), "{value}");
5502 }
5503}
5504
5505#[cfg(test)]
5506mod data_file_tests {
5507 use super::{DataFileContent, DataFileReadError};
5508 use crate::govlog::{DataFile, GitCommitId, LineRange};
5509 use crate::requests::GetContentInput;
5510
5511 fn commit() -> GitCommitId {
5512 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
5513 }
5514
5515 fn ranged(
5516 bytes: &[u8],
5517 budget: usize,
5518 lines: Option<&str>,
5519 ) -> Result<DataFileContent, DataFileReadError> {
5520 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
5521 DataFileContent::new(
5522 &"DATA-2026-0001".parse().unwrap(),
5523 commit(),
5524 &file,
5525 bytes,
5526 budget,
5527 lines.map(|l| l.parse::<LineRange>().unwrap()),
5528 )
5529 }
5530
5531 fn read(
5532 bytes: &[u8],
5533 budget: usize,
5534 ) -> Result<DataFileContent, DataFileReadError> {
5535 ranged(bytes, budget, None)
5536 }
5537
5538 fn lines(n: usize, width: usize) -> String {
5540 (1..=n)
5541 .map(|i| format!("{i:0>w$}\n", w = width - 1))
5542 .collect()
5543 }
5544
5545 #[test]
5546 fn a_range_reads_those_lines_and_says_how_large_the_file_is() {
5547 let text = lines(10, 4);
5548 let read = ranged(text.as_bytes(), 32_768, Some("3-5")).unwrap();
5549 assert_eq!(read.text, "003\n004\n005\n");
5550 assert_eq!(read.lines.unwrap().to_string(), "3-5");
5551 assert_eq!((read.bytes, read.total_lines), (40, 10));
5552 assert_eq!(read.omitted_bytes, 28);
5553 assert_eq!(read.cut, None);
5554 let read = ranged(text.as_bytes(), 32_768, Some("9-99")).unwrap();
5556 assert_eq!(read.lines.unwrap().to_string(), "9-10");
5557 assert_eq!(read.text, "009\n010\n");
5558 let read = ranged(text.as_bytes(), 32_768, Some("8-")).unwrap();
5559 assert_eq!(read.lines.unwrap().to_string(), "8-10");
5560 assert_eq!(read_whole_lines(&text), 10);
5562 let err = ranged(text.as_bytes(), 32_768, Some("11-12")).unwrap_err();
5563 assert!(matches!(
5564 err,
5565 DataFileReadError::PastEnd {
5566 total_lines: 10,
5567 ..
5568 }
5569 ));
5570 let text = err.to_string();
5571 assert!(text.contains("has 10 lines (40 bytes)"), "{err}");
5572 assert!(
5574 text.starts_with("Invalid arguments at `lines`: lines=\"11-12\": "),
5575 "{err}"
5576 );
5577 assert!(text.ends_with(LineRange::FORM), "{err}");
5578 }
5579
5580 fn read_whole_lines(text: &str) -> u64 {
5581 read(text.as_bytes(), 32_768).unwrap().total_lines
5582 }
5583
5584 #[test]
5587 fn a_range_past_the_budget_is_cut_and_says_so() {
5588 let text = lines(100, 10);
5589 let read = ranged(text.as_bytes(), 55, Some("1-20")).unwrap();
5590 assert_eq!(read.lines.unwrap().to_string(), "1-5");
5591 assert_eq!(read.text.len(), 50);
5592 let cut = read.cut.unwrap();
5593 for needle in [
5594 "Lines 1-20 are 200 bytes, more than the comment_budget of 55",
5595 "shown are lines 1-5 (50 bytes), and 150 bytes of the range are not",
5596 "comment_budget=200 or more reads them all (at most 262144)",
5597 "The file has 100 lines, 1000 bytes.",
5598 ] {
5599 assert!(cut.contains(needle), "{needle}: {cut}");
5600 }
5601 assert!(!cut.to_lowercase().contains("next"), "{cut}");
5602 assert!(
5604 cut.contains("lines=\"6-20\" reads on from line 6."),
5605 "{cut}"
5606 );
5607 let open = ranged(text.as_bytes(), 55, Some("1-")).unwrap();
5608 assert!(
5609 open.cut
5610 .unwrap()
5611 .contains("lines=\"6-\" reads on from line 6."),
5612 "open-ended"
5613 );
5614 let long = format!("{}\nshort\n", "x".repeat(100));
5617 let read = ranged(long.as_bytes(), 40, Some("1-2")).unwrap();
5618 assert_eq!(read.text.len(), 40);
5619 assert_eq!((read.lines, read.partial_line), (None, Some(1)));
5620 let cut = read.cut.unwrap();
5621 assert!(
5622 cut.starts_with(
5623 "Line 1 alone is 101 bytes, more than the comment_budget of \
5624 40: shown are its first 40 bytes, cut inside the line"
5625 ),
5626 "{cut}"
5627 );
5628 assert!(cut.contains("lines=\"2-2\" reads on past line 1."), "{cut}");
5629 let read = ranged(long.as_bytes(), 40, Some("1-1")).unwrap();
5631 assert!(!read.cut.unwrap().contains("reads on"));
5632 }
5633
5634 #[test]
5637 fn a_whole_read_of_a_large_file_is_refused() {
5638 let max = crate::govlog::MAX_WHOLE_READ_BYTES as usize;
5639 let text = lines(max / 100 + 10, 100);
5640 assert!(text.len() > max);
5641 let err = read(text.as_bytes(), 32_768).unwrap_err();
5642 let msg = err.to_string();
5643 for needle in [
5644 format!("a/b.md is {} bytes,", text.len()),
5645 "more than one read shows whole (262144 bytes)".into(),
5646 "with comment_budget=262144 shows up to 262144 bytes".into(),
5647 "DATA-2026-0001:0123456:a/b.md:<from>-<to>".into(),
5648 ] {
5649 assert!(msg.contains(&needle), "{needle}: {msg}");
5650 }
5651 let read = ranged(text.as_bytes(), 32_768, Some("1000-1002")).unwrap();
5652 assert_eq!(read.text.len(), 300);
5653 assert_eq!(read.bytes, text.len() as u64);
5654 }
5655
5656 #[test]
5657 fn a_short_file_is_whole() {
5658 let read = read(b"one\ntwo\n", 32).unwrap();
5659 assert_eq!(read.text, "one\ntwo\n");
5660 assert_eq!(read.omitted_bytes, 0);
5661 assert_eq!(read.cut, None);
5662 assert_eq!(read.bytes, 8);
5663 }
5664
5665 #[test]
5666 fn a_cut_ends_a_line_and_says_so() {
5667 let text = "line one\nline two\nline three\n";
5668 let read = read(text.as_bytes(), 20).unwrap();
5669 assert_eq!(read.text, "line one\nline two\n");
5670 assert_eq!(read.omitted_bytes, 11);
5671 let cut = read.cut.unwrap();
5672 assert!(
5673 cut.contains(
5674 "Cut at the last line break before byte 20 (the \
5675 comment_budget), at byte 18 of 29"
5676 ),
5677 "{cut}"
5678 );
5679 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
5680 assert!(
5681 cut.contains(
5682 "comment_budget=262144 reads the whole file, or lines=\"3-\" \
5683 reads on from line 3."
5684 ),
5685 "{cut}"
5686 );
5687 let long = format!("ab\n{}", "x".repeat(30));
5689 let mid = ranged(long.as_bytes(), 20, None).unwrap();
5690 let cut = mid.cut.unwrap();
5691 assert!(cut.starts_with("Cut at byte 20 of 33"), "{cut}");
5692 assert!(cut.contains("lines=\"2-\" reads on from line 2."), "{cut}");
5693 }
5694
5695 #[test]
5696 fn a_cut_never_splits_a_character() {
5697 let read = read("ééééé".as_bytes(), 5).unwrap();
5699 assert_eq!(read.text, "éé");
5700 assert_eq!(read.omitted_bytes, 6);
5701 assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
5703 }
5704
5705 #[test]
5706 fn the_largest_budget_reads_the_largest_file_whole() {
5707 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
5708 assert_eq!(max as u64, crate::govlog::MAX_WHOLE_READ_BYTES);
5709 let big = "x".repeat(max);
5710 let read = read(big.as_bytes(), max).unwrap();
5711 assert_eq!((read.omitted_bytes, read.cut), (0, None));
5712 }
5713
5714 #[test]
5715 fn binary_is_not_text() {
5716 let Err(DataFileReadError::NotText(err)) =
5717 read(&[0xff, 0xfe, 0x00], 32)
5718 else {
5719 panic!("not text");
5720 };
5721 assert_eq!(err.bytes, 3);
5722 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
5723 }
5724}
5725
5726#[cfg(test)]
5727mod data_set_view_tests {
5728 use super::DataSetView;
5729 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
5730
5731 fn set(n: usize, dirs: usize) -> DataSet {
5733 let mut files: Vec<DataFile> = (0..n)
5734 .map(|i| {
5735 DataFile::of(
5736 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
5737 .parse()
5738 .unwrap(),
5739 b"x\n",
5740 )
5741 })
5742 .collect();
5743 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
5744 files.sort_by(|a, b| a.path.cmp(&b.path));
5745 DataSet {
5746 agora_data_set: DATA_SET_VERSION,
5747 title: "t".into(),
5748 description: "d".into(),
5749 compiled_by: "c".into(),
5750 repo: "agora-data".parse().unwrap(),
5751 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
5752 index: "votes/README.md".parse().unwrap(),
5753 files,
5754 }
5755 }
5756
5757 #[test]
5758 fn a_small_set_lists_every_file() {
5759 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
5760 assert_eq!(view.listing.len(), 5);
5761 assert!(view.listing.iter().all(|l| l.files == 1));
5762 assert_eq!((view.files, view.unlisted), (5, 0));
5763 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
5764 assert!(!view.how_to_read.contains("counted, not named"));
5765 }
5766
5767 #[test]
5768 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
5769 let big = set(1_038, 12);
5770 let view = DataSetView::of(&big, None, 32_768).unwrap();
5771 assert_eq!(view.files, 1_039);
5774 let paths: Vec<&str> =
5775 view.listing.iter().map(|l| l.path.as_str()).collect();
5776 assert_eq!(paths.len(), 13, "{paths:?}");
5777 assert!(paths.contains(&"votes/README.md"), "{paths:?}");
5778 assert!(paths.contains(&"votes/d11/"), "{paths:?}");
5779 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
5780 assert!(view.how_to_read.contains("counted, not named"));
5781 assert!(
5782 view.how_to_read.starts_with(
5783 "Read this first: attachment=\"votes/README.md\"."
5784 )
5785 );
5786
5787 let tight = DataSetView::of(&big, None, 300).unwrap();
5789 assert_eq!(tight.listing.len(), 1);
5790 assert_eq!(tight.listing[0].path, "votes/");
5791 assert_eq!(tight.listing[0].files, 1_039);
5792
5793 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
5794 let paths: Vec<&str> =
5795 votes.listing.iter().map(|l| l.path.as_str()).collect();
5796 assert_eq!(paths.len(), 13, "{paths:?}");
5797 assert!(
5798 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
5799 );
5800 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
5801 assert_eq!(votes.unlisted, 0);
5802
5803 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
5805 assert!(d00.listing.iter().all(|l| l.files == 1));
5806 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
5807 }
5808
5809 #[test]
5812 fn a_few_top_directories_descend_together() {
5813 let mut big = set(1_038, 12);
5814 for f in big.files.iter_mut() {
5815 let p = f.path.as_str();
5816 let moved = match p.strip_prefix("votes/d0") {
5817 Some(rest) => format!("early/d0{rest}"),
5818 None => p.to_string(),
5819 };
5820 f.path = moved.parse().unwrap();
5821 }
5822 big.files.sort_by(|a, b| a.path.cmp(&b.path));
5823 let view = DataSetView::of(&big, None, 32_768).unwrap();
5824 let paths: Vec<&str> =
5825 view.listing.iter().map(|l| l.path.as_str()).collect();
5826 assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
5827 assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
5828 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
5829 assert_eq!(view.unlisted, 0);
5830 }
5831
5832 #[test]
5833 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
5834 let flat = set(1_000, 1);
5835 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
5836 assert!(view.unlisted > 0);
5837 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
5838 assert!(view.how_to_read.contains("did not fit at all"));
5839 let json = serde_json::to_string(&view).unwrap();
5841 assert!(!json.contains("sha256"), "{json}");
5842 }
5843}