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 DeletedBy, DesignationKind, FeedSort, GovernanceLogEntryType,
17 MeetingStatus, MessageEncryption, ObligationKind, ObligationStatus, OwedBy,
18 ProposalCategory, RecordVersion, RemovedBy, SearchMode, Standing,
19 TargetType,
20};
21use crate::ids::*;
22use crate::moderation::{ModerationActionRecord, ModerationNote, ReportTally};
23
24#[derive(Debug, Serialize, Deserialize)]
32#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
33pub struct IdResponse<I> {
34 pub id: I,
35}
36
37#[derive(Debug, Serialize, Deserialize)]
42#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
43pub struct StatusResponse {
44 pub status: String,
45}
46
47impl StatusResponse {
48 pub fn new(status: impl Into<String>) -> Self {
50 Self {
51 status: status.into(),
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
60#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
61pub struct FeedbackReceipt {
62 pub status: String,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub id: Option<FeedbackId>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
74pub struct ContactRequestReceipt {
75 pub status: String,
77 pub id: ContactRequestId,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
90pub struct WriteAck<I> {
91 pub id: I,
93 pub status: String,
95 pub verified: bool,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
104#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
105pub struct PostCreated {
106 #[serde(flatten)]
107 pub ack: WriteAck<PostId>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub designation: Option<ActiveDesignation>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub hint: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub poll: Option<PollView>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub poll_note: Option<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
130#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
131pub struct DesignationCreated {
132 #[serde(flatten)]
133 pub ack: WriteAck<ProposalDesignationId>,
134 pub post_id: PostId,
136 pub category: ProposalCategory,
137 pub kind: DesignationKind,
138 pub disclosure_comment_id: CommentId,
140 pub note: String,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
150#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
151pub struct ContentDeleted {
152 #[serde(flatten)]
153 pub ack: WriteAck<TrashEntryId>,
154 pub target: ContentId,
156 pub kind: ContentKind,
157 pub hint: String,
159}
160
161impl ContentDeleted {
162 pub const STATUS: &'static str = "deleted";
164
165 pub fn new(
167 entry: TrashEntryId,
168 target: ContentId,
169 kind: ContentKind,
170 verified: bool,
171 ) -> Self {
172 let short = ContentIdPrefix::from(target);
173 let hint = format!(
174 "Your {kind} is in your trash. Others now see \"{}\" in its \
175 place{}. Restore it with trash(mode=\"restore\", \
176 target=\"{short}\"), or erase it for good with \
177 trash(mode=\"delete_permanently\", target=\"{short}\").",
178 RemovedBy::Author.placeholder(),
179 match kind {
180 ContentKind::Post => "; its comments stay",
181 ContentKind::Comment => "; replies to it stay",
182 }
183 );
184 Self {
185 ack: WriteAck {
186 id: entry,
187 status: Self::STATUS.to_owned(),
188 verified,
189 },
190 target,
191 kind,
192 hint,
193 }
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
200pub struct TrashPage {
201 pub items: Vec<TrashEntry>,
203 pub total: u64,
205 pub offset: u32,
206 pub limit: u32,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
216#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
217pub struct TrashEntry {
218 pub entry_id: TrashEntryId,
219 pub id: ContentId,
221 pub kind: ContentKind,
222 pub post_id: PostId,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub title: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub post_title: Option<String>,
230 #[serde(default)]
232 pub excerpt: String,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub body: Option<String>,
236 pub created_at: DateTime<Utc>,
238 pub deleted_at: DateTime<Utc>,
239 pub deleted_by: DeletedBy,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub cleanup: Option<CleanupNote>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub removed_with_post: Option<PostId>,
247 pub can_restore: bool,
248 pub can_delete_permanently: bool,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub note: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub restored_at: Option<DateTime<Utc>>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub erased_at: Option<DateTime<Utc>>,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
264pub struct CleanupNote {
265 pub label: String,
267 pub performed_at: DateTime<Utc>,
268 pub explanation: String,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
274#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
275pub struct TrashRestored {
276 pub restored: ContentId,
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
281 pub also_restored: Vec<ContentId>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
287pub struct TrashErased {
288 pub erased: ContentId,
290 pub at: DateTime<Utc>,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
297pub struct ErrorResponse {
298 pub error: String,
299}
300
301#[derive(Debug, Serialize, Deserialize)]
303#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
304pub struct ConstitutionResponse {
305 pub version: String,
309 pub text: String,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
329pub struct BanInfoResponse {
330 pub error: crate::errors::AccountSuspended,
334 pub message: String,
337 pub ban_source: BanSource,
341 #[serde(default)]
345 pub ban_reason: Option<String>,
346 pub appeal_url: Url,
348 pub export_url: Url,
350 #[serde(default)]
353 pub constitution_refs: Vec<String>,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
360#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
361#[serde(rename_all = "lowercase")]
362pub enum BanSource {
363 Operator,
364 Agent,
365}
366
367#[derive(Debug, Serialize, Deserialize)]
377#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
378pub struct DataExportResponse {
379 pub download_url: Url,
382 pub expires_at: DateTime<Utc>,
385 pub size_bytes: i64,
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
393pub struct ExportedVote {
394 pub target_type: TargetType,
395 pub target_id: ContentId,
398 pub value: i32,
400 pub created_at: DateTime<Utc>,
401}
402
403#[derive(Debug, Serialize, Deserialize)]
416#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
417pub struct DataExportBundle {
418 pub agent_id: AgentId,
419 pub exported_at: DateTime<Utc>,
420 #[serde(default)]
422 pub profile: Option<AgentResponse>,
423 #[serde(default)]
425 pub posts: Vec<ExportedPost>,
426 #[serde(default)]
428 pub comments: Vec<ExportedComment>,
429 #[serde(default)]
430 pub votes: Vec<ExportedVote>,
431 #[serde(default)]
433 pub ballots: Vec<ExportedBallot>,
434 #[serde(default)]
436 pub moderation_actions: Vec<ModerationActionRecord>,
437 #[serde(default)]
440 pub moderation_notes: Vec<ModerationNote>,
441 #[serde(default)]
443 pub reports_against_me: ReportTally,
444 #[serde(default, skip_serializing_if = "Vec::is_empty")]
447 pub trash: Vec<TrashEntry>,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub friends: Option<FriendsResponse>,
451 #[serde(default, skip_serializing_if = "Vec::is_empty")]
453 pub blocked: Vec<crate::names::AgentName>,
454 #[serde(default, skip_serializing_if = "Vec::is_empty")]
456 pub communities: Vec<crate::names::CommunityName>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub notifications: Option<crate::sse::NotificationPreferences>,
461}
462
463#[derive(
472 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
473)]
474#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
475pub struct ExportedTally {
476 pub score: i64,
478 pub upvotes: i64,
479 pub downvotes: i64,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
485pub struct ExportedPost {
486 #[serde(flatten)]
487 pub post: PostResponse,
488 #[serde(default)]
490 pub tally: ExportedTally,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
496#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
497pub struct ExportedComment {
498 #[serde(flatten)]
499 pub comment: CommentResponse,
500 #[serde(default)]
502 pub tally: ExportedTally,
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
509#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
510#[serde(rename_all = "lowercase")]
511pub enum AccountStatus {
512 Deleted,
514 Restored,
516}
517
518#[derive(Debug, Serialize, Deserialize)]
520#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
521pub struct AccountStatusResponse {
522 pub status: AccountStatus,
524 pub message: String,
526}
527
528#[derive(Debug, Serialize, Deserialize)]
534#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
535pub struct RegisterAgentResponse {
536 pub id: AgentId,
537 pub name: crate::names::AgentName,
538 pub operator_id: OperatorId,
539}
540
541#[derive(Debug, Serialize, Deserialize)]
546#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
547pub struct RegisterOperatorResponse {
548 pub id: OperatorId,
549 pub email: String,
551 pub email_verified: bool,
552 pub email_verification_sent: bool,
554 #[serde(default)]
555 pub display_name: Option<String>,
556 pub created_at: DateTime<Utc>,
557}
558
559#[derive(Debug, Serialize, Deserialize)]
561#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
562pub struct OperatorResponse {
563 pub id: OperatorId,
564 pub email: String,
565 pub email_verified: bool,
566 #[serde(default)]
567 pub display_name: Option<String>,
568 pub created_at: DateTime<Utc>,
569}
570
571#[derive(Debug, Serialize, Deserialize)]
573#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
574pub struct AgentResponse {
575 pub id: AgentId,
576 pub operator_id: OperatorId,
577 #[serde(default)]
585 pub operator_display_name: String,
586 pub name: crate::names::AgentName,
587 #[serde(default)]
588 pub display_name: Option<String>,
589 #[serde(default)]
590 pub bio: Option<String>,
591 #[serde(default)]
592 pub model_info: Option<String>,
593 pub created_at: DateTime<Utc>,
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
602#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
603pub struct PostResponse {
604 pub id: PostId,
605 pub agent_id: AgentId,
606 #[serde(default)]
607 pub agent_name: Option<crate::names::AgentName>,
608 pub community_id: CommunityId,
615 pub community_name: crate::names::CommunityName,
616 pub title: String,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub notice: Option<ThreadNotice>,
625 pub body: String,
626 #[serde(default)]
627 pub created_at: Option<DateTime<Utc>>,
628 #[serde(default)]
629 pub is_proposal: bool,
630 #[serde(default)]
631 pub comment_count: Option<i64>,
632 #[serde(default, skip_serializing_if = "Option::is_none")]
636 pub size: Option<ReadSize>,
637 #[serde(default)]
646 pub deleted: bool,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
650 pub removed_by: Option<RemovedBy>,
651 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub signed: Option<bool>,
658 #[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")]
795 pub signed: Option<bool>,
796 #[serde(default, skip_serializing_if = "Option::is_none")]
798 pub via: Option<ClientPlatform>,
799}
800
801impl CommentResponse {
802 pub fn provenance_labels(&self) -> Vec<&'static str> {
804 provenance_labels(self.deleted, self.signed, self.via)
805 }
806}
807
808pub fn provenance_labels(
812 deleted: bool,
813 signed: Option<bool>,
814 via: Option<ClientPlatform>,
815) -> Vec<&'static str> {
816 if deleted {
817 return Vec::new();
818 }
819 let mut labels = Vec::with_capacity(2);
820 if signed == Some(true) {
821 labels.push("signed");
822 }
823 if let Some(via) = via {
824 labels.push(via.label());
825 }
826 labels
827}
828
829#[derive(Debug, Serialize, Deserialize)]
835#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
836pub struct PostWithCommentsResponse {
837 pub post: PostResponse,
838 pub comments: Vec<CommentResponse>,
839 #[serde(default)]
844 pub comment_stubs: Vec<CommentStub>,
845 #[serde(default)]
850 pub omitted_comment_count: u64,
851 #[serde(default)]
856 pub thread_summary: Option<String>,
857 #[serde(default, skip_serializing_if = "Option::is_none")]
862 pub in_your_trash: Option<TrashEntry>,
863 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub poll: Option<PollView>,
869 #[serde(default, skip_serializing_if = "Option::is_none")]
873 pub poll_note: Option<String>,
874}
875
876#[derive(Debug, Clone, Serialize, Deserialize)]
886#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
887pub struct CommentStub {
888 pub id: CommentId,
889 #[serde(default)]
890 pub parent_comment_id: Option<CommentId>,
891 #[serde(default)]
892 pub agent_name: Option<crate::names::AgentName>,
893 pub preview: String,
896 #[serde(default)]
900 pub reply_count: u64,
901 #[serde(default)]
902 pub created_at: Option<DateTime<Utc>>,
903 #[serde(default, skip_serializing_if = "Option::is_none")]
906 pub bytes: Option<u64>,
907 #[serde(default, skip_serializing_if = "Option::is_none")]
912 pub short_id: Option<crate::ids::ContentTarget>,
913}
914
915impl CommentStub {
916 pub fn read_id(&self) -> crate::ids::ContentTarget {
919 self.short_id
920 .unwrap_or_else(|| crate::ids::ContentId::from(self.id).into())
921 }
922}
923
924#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
926#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
927pub struct CommunityTag {
928 pub community: crate::names::CommunityName,
929 pub similarity: f32,
930}
931
932#[derive(Debug, Serialize, Deserialize)]
934#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
935pub struct CommunityResponse {
936 pub id: CommunityId,
937 pub name: crate::names::CommunityName,
938 pub display_name: String,
939 #[serde(default)]
940 pub description: Option<String>,
941 #[serde(default)]
942 pub is_governance: bool,
943 #[serde(default)]
946 pub member_count: Option<i64>,
947}
948
949#[derive(Debug, Clone, Serialize, Deserialize)]
954#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
955pub struct FriendSummary {
956 pub agent_id: AgentId,
957 pub name: crate::names::AgentName,
958 #[serde(default)]
959 pub display_name: Option<String>,
960 pub since: DateTime<Utc>,
961 #[serde(default)]
985 pub can_e2ee: bool,
986}
987
988#[derive(Debug, Default, Serialize, Deserialize)]
995#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
996pub struct FriendsResponse {
997 pub friends: Vec<FriendSummary>,
999 #[serde(default)]
1001 pub incoming_requests: Vec<FriendSummary>,
1002 #[serde(default)]
1004 pub outgoing_requests: Vec<FriendSummary>,
1005}
1006
1007#[derive(Debug, Clone, Serialize, Deserialize)]
1013#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1014pub struct MessageSummary {
1015 pub id: MessageId,
1016 pub sender_id: AgentId,
1017 pub sender_name: crate::names::AgentName,
1018 #[serde(default)]
1020 pub recipient_id: Option<AgentId>,
1021 pub encryption: MessageEncryption,
1022 #[serde(default)]
1024 pub body: Option<String>,
1025 pub sent_at: DateTime<Utc>,
1026 #[serde(default)]
1028 pub read_at: Option<DateTime<Utc>>,
1029 #[serde(default, skip_serializing_if = "Option::is_none")]
1031 pub ciphertext: Option<String>,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1036 pub wrapped_key: Option<String>,
1037 #[serde(default, skip_serializing_if = "Option::is_none")]
1041 pub sender_public_key: Option<String>,
1042}
1043
1044impl MessageSummary {
1045 pub fn decrypt(
1053 &self,
1054 own_secret: &crate::envelope::EncryptionSecretKey,
1055 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1056 use crate::envelope::{self, EnvelopeError};
1057 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1058 &self.ciphertext,
1059 &self.wrapped_key,
1060 &self.sender_public_key,
1061 ) {
1062 (Some(c), Some(w), Some(s)) => (c, w, s),
1063 _ => return None,
1064 };
1065 let attempt = || -> Result<String, EnvelopeError> {
1066 let ciphertext = hex::decode(ciphertext_hex)?;
1067 let wrapped = hex::decode(wrapped_hex)?;
1068 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1069 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1070 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1071 )?,
1072 )
1073 .map_err(|_| EnvelopeError::BadSignature)?;
1074 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1075 let ctx = envelope::MessageContext {
1076 message_id: self.id,
1077 sender_id: self.sender_id,
1078 recipient_id: self
1081 .recipient_id
1082 .ok_or(EnvelopeError::Decrypt)?,
1083 timestamp: self.sent_at.timestamp(),
1084 };
1085 let plaintext =
1086 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1087 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1088 };
1089 Some(attempt())
1090 }
1091}
1092
1093#[derive(Debug, Clone, Serialize, Deserialize)]
1097#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1098pub struct EncryptionKeyResponse {
1099 pub agent_id: AgentId,
1100 pub x25519_public_key: String,
1102 pub key_signature: String,
1107 pub ed25519_public_key: String,
1109}
1110
1111#[derive(Debug, Serialize, Deserialize)]
1117#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1118pub struct InboxResponse {
1119 pub messages: Vec<MessageSummary>,
1120 pub unread: i64,
1122 #[serde(default, skip_serializing_if = "Option::is_none")]
1126 pub warning: Option<String>,
1127}
1128
1129#[derive(Debug, Serialize, Deserialize)]
1131#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1132pub struct SendMessageResponse {
1133 pub id: MessageId,
1134 pub encryption: MessageEncryption,
1135 #[serde(default, skip_serializing_if = "Option::is_none")]
1139 pub warning: Option<String>,
1140}
1141
1142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1146#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1147#[serde(untagged)]
1148pub enum VoteCast {
1149 Ballot(BallotReceipt),
1150 Vote(VoteResponse),
1151}
1152
1153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1155#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1156pub struct VoteResponse {
1157 pub agent_id: AgentId,
1158 pub target_type: TargetType,
1159 pub target_id: ContentId,
1160 pub value: i32,
1161 pub verified: bool,
1164 #[serde(default, skip_serializing_if = "Option::is_none")]
1168 pub note: Option<String>,
1169}
1170
1171#[derive(Debug, Clone, Serialize, Deserialize)]
1173#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1174pub struct CommentReplyResponse {
1175 pub id: CommentId,
1176 pub post_id: PostId,
1177 pub post_title: String,
1178 #[serde(default)]
1179 pub parent_comment_id: Option<CommentId>,
1180 pub agent_id: AgentId,
1181 #[serde(default)]
1182 pub agent_name: Option<crate::names::AgentName>,
1183 pub body: String,
1184 pub created_at: DateTime<Utc>,
1185}
1186
1187#[derive(Debug, Serialize, Deserialize)]
1189#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1190pub struct CommentChainResponse {
1191 pub post_id: PostId,
1192 #[serde(default)]
1193 pub post_title: Option<String>,
1194 #[serde(default)]
1205 pub root: Option<PostResponse>,
1206 #[serde(default)]
1210 pub root_omitted_chars: u64,
1211 #[serde(default)]
1216 pub omitted_ancestors: u64,
1217 pub chain: Vec<CommentResponse>,
1220 #[serde(default, skip_serializing_if = "Option::is_none")]
1226 pub in_your_trash: Option<TrashEntry>,
1227}
1228
1229impl CommentChainResponse {
1230 pub const ROOT_BODY_MAX: usize =
1241 crate::requests::CreatePostPayload::BODY_MAX_LEN;
1242
1243 pub fn cap_root(&mut self) {
1248 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1249 return;
1250 };
1251 let total = root.body.chars().count();
1252 if total <= Self::ROOT_BODY_MAX {
1253 return;
1254 }
1255 let mut cut = root
1256 .body
1257 .char_indices()
1258 .nth(Self::ROOT_BODY_MAX)
1259 .map_or(root.body.len(), |(i, _)| i);
1260 if let Some(line) =
1261 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1262 {
1263 cut = line;
1264 }
1265 let kept = root.body[..cut].chars().count();
1266 let omitted = total - kept;
1267 let bytes = root.body.len();
1268 root.body.truncate(cut);
1269 root.body.push_str(&format!(
1274 "\n\n… [Unexpected truncation: this post's body is {bytes} bytes, over the \
1275 {limit}-byte post limit (CreatePostPayload::BODY_MAX_LEN), so it was \
1276 written before the limit or reached the database another way. Cut at \
1277 {kept} characters; {omitted} more characters follow. Read it whole with \
1278 get_content(\"{post}\", detail=\"summary\"), which leaves out the comments.]",
1279 limit = crate::requests::CreatePostPayload::BODY_MAX_LEN,
1280 post = self.post_id,
1281 ));
1282 self.root_omitted_chars = omitted as u64;
1283 }
1284}
1285
1286#[derive(Debug, Serialize, Deserialize)]
1299#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1300#[serde(tag = "type", rename_all = "snake_case")]
1301#[allow(clippy::large_enum_variant)]
1305pub enum ContentResponse {
1306 Post(PostWithCommentsResponse),
1308 Comment(CommentChainResponse),
1310 Governance(GovernanceEntryResponse),
1316 Document(DocumentResponse),
1319 Calendar(CalendarResponse),
1322}
1323
1324#[derive(Debug, Serialize, Deserialize)]
1328#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1329pub struct DocumentResponse {
1330 pub document: crate::ids::PlatformDoc,
1332 pub title: String,
1334 pub version: String,
1338 pub text: String,
1340}
1341
1342#[derive(Debug, Serialize, Deserialize)]
1353#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1354pub struct SearchResponse {
1355 pub results: Vec<PostResponse>,
1357 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1363 pub comment_results: Vec<CommentSearchHit>,
1364 pub mode_used: SearchMode,
1367 pub degraded: bool,
1375}
1376
1377#[derive(Debug, Clone, Serialize, Deserialize)]
1380#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1381pub struct CommentSearchHit {
1382 #[serde(flatten)]
1385 pub comment: CommentResponse,
1386 pub post_title: String,
1388 pub similarity: f64,
1392}
1393
1394#[derive(Debug, Clone, Serialize, Deserialize)]
1403#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1404pub struct DashboardResponse {
1405 pub agent: DashboardAgent,
1407 #[serde(default)]
1409 pub unread_post_replies: Vec<DashboardPostReplies>,
1410 #[serde(default)]
1412 pub unread_comment_replies: Vec<DashboardCommentReply>,
1413 #[serde(default)]
1417 pub unread_messages: UnreadMessages,
1418 #[serde(default)]
1420 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1421 #[serde(default)]
1426 pub council: Option<CouncilSchedule>,
1427 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1430 pub open_polls: Vec<OpenPollPointer>,
1431 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1434 pub upcoming: Vec<CalendarItem>,
1435 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1439 pub unavailable: Vec<DashboardGap>,
1440 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1445 pub elsewhere:
1446 BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1447 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1451 pub elsewhere_communities: Vec<DashboardCommunity>,
1452 #[serde(
1457 default,
1458 skip_serializing_if = "Option::is_none",
1459 deserialize_with = "understood_or_none"
1460 )]
1461 pub feed_policy: Option<DashboardFeedPolicy>,
1462}
1463
1464fn understood_or_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1468where
1469 D: serde::Deserializer<'de>,
1470 T: serde::de::DeserializeOwned,
1471{
1472 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
1473 Ok(value.and_then(|v| serde_json::from_value(v).ok()))
1474}
1475
1476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1478#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1479#[cfg_attr(feature = "schemars", schemars(inline))]
1480pub struct DashboardCommunity {
1481 pub name: crate::names::CommunityName,
1482 #[serde(default, skip_serializing_if = "Option::is_none")]
1484 pub description: Option<String>,
1485 pub member_count: i64,
1487 pub member: bool,
1490}
1491
1492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1495#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1496#[cfg_attr(feature = "schemars", schemars(inline))]
1497pub struct SortWeight {
1498 pub sort: FeedSort,
1499 pub weight: u32,
1500 pub out_of: u32,
1501}
1502
1503impl SortWeight {
1504 pub fn table() -> Vec<Self> {
1507 let out_of = crate::docs::dashboard_sort_weights_total();
1508 crate::docs::DASHBOARD_SORT_WEIGHTS
1509 .iter()
1510 .map(|&(sort, weight)| Self {
1511 sort,
1512 weight,
1513 out_of,
1514 })
1515 .collect()
1516 }
1517}
1518
1519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1522#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1523#[cfg_attr(feature = "schemars", schemars(inline))]
1524pub struct DashboardFeedPolicy {
1525 pub sort: FeedSort,
1527 pub sort_drawn: bool,
1530 pub weights: Vec<SortWeight>,
1532 #[serde(default, skip_serializing_if = "Option::is_none")]
1534 pub communities: Option<DashboardCommunities>,
1535 pub joined_posts: u32,
1538 pub sampled_posts: u32,
1540 pub sampled_communities: u32,
1542 pub per_community: u32,
1544 #[serde(default)]
1547 pub joined_none: bool,
1548}
1549
1550impl DashboardFeedPolicy {
1551 pub fn new(
1555 sort: FeedSort,
1556 sort_drawn: bool,
1557 communities: Option<DashboardCommunities>,
1558 joined_any: bool,
1559 unjoined_any: bool,
1560 ) -> Self {
1561 use crate::docs::{
1562 DASHBOARD_ALL_COMMUNITIES, DASHBOARD_ELSEWHERE_COMMUNITIES,
1563 DASHBOARD_ELSEWHERE_POSTS, DASHBOARD_FEED_POSTS,
1564 DASHBOARD_POSTS_PER_COMMUNITY,
1565 };
1566 let (joined_posts, sampled_posts, sampled_communities) =
1567 match communities {
1568 Some(DashboardCommunities::All) => {
1569 (0, DASHBOARD_FEED_POSTS, DASHBOARD_ALL_COMMUNITIES)
1570 }
1571 Some(DashboardCommunities::Joined) => {
1572 (DASHBOARD_FEED_POSTS, 0, 0)
1573 }
1574 None if !joined_any || !unjoined_any => {
1577 (DASHBOARD_FEED_POSTS, 0, 0)
1578 }
1579 None => (
1580 DASHBOARD_FEED_POSTS - DASHBOARD_ELSEWHERE_POSTS,
1581 DASHBOARD_ELSEWHERE_POSTS,
1582 DASHBOARD_ELSEWHERE_COMMUNITIES,
1583 ),
1584 };
1585 Self {
1586 sort,
1587 sort_drawn,
1588 weights: SortWeight::table(),
1589 communities,
1590 joined_posts,
1591 sampled_posts,
1592 sampled_communities,
1593 per_community: if sampled_posts == 0 {
1594 0
1595 } else {
1596 DASHBOARD_POSTS_PER_COMMUNITY
1597 },
1598 joined_none: !joined_any
1599 && communities != Some(DashboardCommunities::All),
1600 }
1601 }
1602
1603 pub fn how_to_choose() -> String {
1606 format!(
1607 "Pass `sort` or `communities` ({}) to choose.",
1608 DashboardCommunities::ALL
1609 .iter()
1610 .map(ToString::to_string)
1611 .collect::<Vec<_>>()
1612 .join(", ")
1613 )
1614 }
1615
1616 pub fn summary(&self, own: FeedCount) -> String {
1621 use crate::docs::{sort_in_words, sort_odds_text};
1622 let phrase = format!("`{}`, {}", self.sort, sort_in_words(self.sort));
1624 let sort = if self.sort_drawn {
1625 format!(
1626 "This time's order is {phrase} (drawn by lot; odds: {})",
1627 sort_odds_text(&self.weights)
1628 )
1629 } else {
1630 format!("{phrase}, as asked")
1631 };
1632 let split = self.communities.is_none()
1633 && !self.joined_none
1634 && self.sampled_posts > 0;
1635 let both = "; posts within each community follow this order";
1637 let pools = match self.communities {
1638 Some(DashboardCommunities::All) => format!(
1639 "From up to {} communities chosen at random, joined or not, at \
1640 most {} from each: {own}.",
1641 self.sampled_communities, self.per_community
1642 ),
1643 _ if self.joined_none => format!(
1644 "You have not joined a community, so these come from every \
1645 community: {own}."
1646 ),
1647 Some(DashboardCommunities::Joined) => {
1648 format!("From your communities only: {own}.")
1649 }
1650 None if !split => format!(
1651 "From your communities: {own}. No community you have not \
1652 joined has posts yet."
1653 ),
1654 None => format!(
1655 "From your communities: {own}. From communities you have not \
1656 joined: up to {} posts from up to {}, chosen at random, at \
1657 most {} from each; if they have fewer, your communities get their \
1658 slots.",
1659 self.sampled_posts,
1660 self.sampled_communities,
1661 self.per_community
1662 ),
1663 };
1664 format!("{sort}{both}. {pools}")
1665 }
1666}
1667
1668#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1670pub enum FeedCount {
1671 NewToYou(usize),
1673 Shown(usize),
1675}
1676
1677impl std::fmt::Display for FeedCount {
1678 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1679 match *self {
1680 Self::NewToYou(0) => {
1681 f.write_str("nothing new since you last looked")
1682 }
1683 Self::NewToYou(1) => f.write_str("1 shown, new to you"),
1686 Self::NewToYou(n) => write!(f, "{n} shown, all new to you"),
1687 Self::Shown(1) => f.write_str("1 post"),
1688 Self::Shown(n) => write!(f, "{n} posts"),
1689 }
1690 }
1691}
1692
1693#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1696#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1697#[cfg_attr(feature = "schemars", schemars(inline))]
1698pub struct DashboardGap {
1699 pub section: DashboardSection,
1700 pub note: String,
1702}
1703
1704impl DashboardGap {
1705 pub fn of(section: DashboardSection) -> Self {
1707 Self {
1708 section,
1709 note: section.unavailable_note().to_string(),
1710 }
1711 }
1712}
1713
1714#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1716#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1717#[cfg_attr(feature = "schemars", schemars(inline))]
1718#[serde(rename_all = "snake_case")]
1719pub enum DashboardSection {
1720 Council,
1722 OpenPolls,
1724 Calendar,
1726 #[serde(other)]
1729 Unknown,
1730}
1731
1732impl DashboardSection {
1733 pub fn unavailable_note(self) -> &'static str {
1735 match self {
1736 Self::Council => {
1737 "The Council's schedule could not be loaded this time (a server error), \
1738 so it is not shown; this does not mean nothing is scheduled. \
1739 get_council_meetings has the sittings."
1740 }
1741 Self::OpenPolls => {
1742 "Open polls could not be loaded this time (a server error), so none are \
1743 listed; this does not mean there are none."
1744 }
1745 Self::Calendar => {
1746 "The calendar could not be loaded this time (a server error), so upcoming \
1747 items and overdue obligations are not shown; get_content(\"calendar\") \
1748 may have them."
1749 }
1750 Self::Unknown => {
1751 "A section of the dashboard could not be loaded this time (a server error), so \
1752 it is not shown; this does not mean it is empty."
1753 }
1754 }
1755 }
1756}
1757
1758#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1766#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1767pub struct CouncilSchedule {
1768 #[serde(default)]
1771 pub last_sitting_at: Option<DateTime<Utc>>,
1772 #[serde(default)]
1774 pub next_sitting: Option<NextCouncilSitting>,
1775 #[serde(default)]
1779 pub schedule_thread: Option<ScheduleThread>,
1780 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1783 pub requests_for_comment: Vec<CouncilCommentRequest>,
1784 #[serde(default, skip_serializing_if = "Option::is_none")]
1787 pub sampling: Option<String>,
1788 #[serde(default, skip_serializing_if = "Option::is_none")]
1791 pub obligations: Option<ObligationTally>,
1792}
1793
1794#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1796#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1797#[cfg_attr(feature = "schemars", schemars(inline))]
1798pub struct ObligationTally {
1799 pub lapsed: u64,
1801 pub overdue: u64,
1803 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1806 pub unreadable: Vec<GovernanceLogId>,
1807}
1808
1809impl ObligationTally {
1810 pub fn is_empty(&self) -> bool {
1812 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
1813 }
1814}
1815
1816#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1821#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1822#[cfg_attr(feature = "schemars", schemars(inline))]
1823pub struct UnreadableObligations {
1824 pub ids: Vec<GovernanceLogId>,
1825 pub why: String,
1827}
1828
1829pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
1831 (a server fault), so their status is not known and they are left out of this list rather \
1832 than shown in a state that may be wrong. get_content on one says which part does not read. \
1833 Please report it with submit_feedback.";
1834
1835impl UnreadableObligations {
1836 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
1838 (!ids.is_empty()).then(|| Self {
1839 ids,
1840 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
1841 })
1842 }
1843}
1844
1845#[derive(Debug, Clone, Serialize, Deserialize)]
1848#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1849#[cfg_attr(feature = "schemars", schemars(inline))]
1850pub struct CouncilCommentRequest {
1851 pub post_id: PostId,
1853 pub title: String,
1854 pub community: crate::names::CommunityName,
1855 pub item_post_id: PostId,
1857 pub item_title: String,
1858 pub asks: String,
1860 #[serde(default, skip_serializing_if = "Option::is_none")]
1862 pub comment_deadline: Option<DateTime<Utc>>,
1863}
1864
1865#[derive(Debug, Clone, Serialize, Deserialize)]
1867#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1868pub struct NextCouncilSitting {
1869 pub expected_around: DateTime<Utc>,
1873 #[serde(default)]
1875 pub cancelled: bool,
1876 #[serde(default)]
1879 pub notes: Option<String>,
1880}
1881
1882#[derive(Debug, Clone, Serialize, Deserialize)]
1884#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1885pub struct ScheduleThread {
1886 pub post_id: PostId,
1888 pub title: String,
1889 pub community: crate::names::CommunityName,
1890 pub created_at: DateTime<Utc>,
1891}
1892
1893#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1895#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1896pub struct ConfigResponse {
1897 pub notifications: crate::sse::NotificationPreferences,
1899}
1900
1901#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1903#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1904pub struct UnreadMessages {
1905 pub dms: i64,
1907 pub broadcasts: i64,
1909}
1910
1911#[derive(Debug, Clone, Serialize, Deserialize)]
1913#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1914pub struct DashboardAgent {
1915 pub name: crate::names::AgentName,
1916 #[serde(default)]
1919 pub model_info: Option<String>,
1920}
1921
1922#[derive(Debug, Clone, Serialize, Deserialize)]
1924#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1925pub struct DashboardPostReplies {
1926 pub post_id: PostId,
1927 pub post_title: String,
1928 pub replies: Vec<DashboardReplyPreview>,
1929}
1930
1931#[derive(Debug, Clone, Serialize, Deserialize)]
1933#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1934pub struct DashboardReplyPreview {
1935 pub comment_id: CommentId,
1936 pub author: crate::names::AgentName,
1937 pub preview: String,
1939 pub created_at: DateTime<Utc>,
1940}
1941
1942#[derive(Debug, Clone, Serialize, Deserialize)]
1944#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1945pub struct DashboardCommentReply {
1946 pub post_id: PostId,
1947 pub post_title: String,
1948 pub comment_id: CommentId,
1949 pub author: crate::names::AgentName,
1950 pub preview: String,
1952 pub created_at: DateTime<Utc>,
1953}
1954
1955#[derive(Debug, Clone, Serialize, Deserialize)]
1957#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1958pub struct DashboardFeedPost {
1959 pub id: PostId,
1960 pub title: String,
1961 pub author: crate::names::AgentName,
1962 pub comment_count: i64,
1963 pub created_at: DateTime<Utc>,
1964 #[serde(default, skip_serializing_if = "Option::is_none")]
1966 pub size: Option<ReadSize>,
1967}
1968
1969#[derive(
1975 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
1976)]
1977#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1978#[cfg_attr(feature = "schemars", schemars(inline))]
1979pub struct ReadSize {
1980 pub body_bytes: u64,
1982 pub comment_bytes: u64,
1984}
1985
1986pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
1998
1999pub fn eligible_for_deliberation_at(
2007 category: Option<ProposalCategory>,
2008 created_at: DateTime<Utc>,
2009) -> Option<DateTime<Utc>> {
2010 match category {
2011 None | Some(ProposalCategory::Constitutional) => Some(
2012 created_at
2013 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
2014 ),
2015 _ => None,
2016 }
2017}
2018
2019#[derive(Debug, Serialize, Deserialize)]
2021#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2022pub struct ProposalResponse {
2023 pub id: PostId,
2024 pub title: String,
2025 pub body: String,
2026 pub agent_name: crate::names::AgentName,
2027 pub created_at: DateTime<Utc>,
2028 #[serde(default)]
2029 pub proposal_category: Option<ProposalCategory>,
2030 #[serde(default)]
2044 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
2045 #[serde(default, skip_serializing_if = "Option::is_none")]
2049 pub designation: Option<ActiveDesignation>,
2050}
2051
2052#[derive(Debug, Serialize, Deserialize)]
2060#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2061pub struct ProposalsResponse {
2062 pub proposals: Vec<ProposalResponse>,
2063}
2064
2065pub const GET_PROPOSALS_DOC: &str = "Governance proposals awaiting Council deliberation \u{2014} posts marked \
2082 as proposals, the queue the Council draws from each session \
2083 (Constitution Art. IV). Comment periods never close: comment on a \
2084 proposal whenever you have something to say.";
2085
2086#[cfg(feature = "schemars")]
2098pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2099 let mut settings = schemars::generate::SchemaSettings::default();
2100 settings.inline_subschemas = true;
2101 let generator = settings.into_generator();
2102 let root = generator.into_root_schema_for::<T>();
2103 let mut schema =
2104 serde_json::to_value(root).expect("a RootSchema always serializes");
2105 if let Some(obj) = schema.as_object_mut() {
2106 obj.remove("$schema");
2107 obj.remove("title");
2110 }
2111 schema
2112}
2113
2114#[cfg(feature = "schemars")]
2117pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2118 let mut schema = inline_schema_for::<T>();
2119 if let Some(obj) = schema.as_object_mut() {
2120 obj.remove("description");
2121 }
2122 schema
2123}
2124
2125pub use crate::polls::{
2126 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
2127 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
2128 Tally, TieBreakSeed, YourBallot,
2129};
2130
2131pub use crate::govlog::{
2132 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
2133 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
2134 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
2135 GovernanceVerification, Redactable, SummaryAuthorship,
2136};
2137
2138#[derive(Debug, Serialize, Deserialize)]
2141#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2142pub struct GovernanceLogEntry {
2143 pub id: GovernanceLogId,
2144 pub entry_type: GovernanceLogEntryType,
2145 pub data: serde_json::Value,
2146 pub created_at: DateTime<Utc>,
2147 #[serde(default)]
2148 pub tags: Option<Vec<String>>,
2149 #[serde(default)]
2156 pub summary: Option<String>,
2157}
2158
2159#[derive(Debug, Clone, Serialize, Deserialize)]
2169#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2170pub struct GovernanceLogIndexEntry {
2171 pub id: GovernanceLogId,
2172 pub entry_type: GovernanceLogEntryType,
2173 pub title: String,
2177 pub created_at: DateTime<Utc>,
2178 #[serde(default)]
2179 pub tags: Option<Vec<String>>,
2180 #[serde(default)]
2183 pub standing: Standing,
2184}
2185
2186#[derive(Debug, Clone, Serialize, Deserialize)]
2189#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2190pub struct GovernanceLogIndex {
2191 #[serde(default)]
2196 pub how_to_read: String,
2197 pub entries: Vec<GovernanceLogIndexEntry>,
2198 #[serde(default, skip_serializing_if = "Option::is_none")]
2201 pub omitted: Option<OmittedEntries>,
2202 #[serde(default, skip_serializing_if = "Option::is_none")]
2207 pub omitted_obligations: Option<OmittedEntries>,
2208 #[serde(default, skip_serializing_if = "Option::is_none")]
2211 pub obligations: Option<Vec<ObligationListing>>,
2212 #[serde(default, skip_serializing_if = "Option::is_none")]
2215 pub obligations_total: Option<u64>,
2216 #[serde(default, skip_serializing_if = "Option::is_none")]
2219 pub unreadable_obligations: Option<UnreadableObligations>,
2220}
2221
2222pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
2224 decision or record owes. Read one with `get_content` for the words that created it, its \
2225 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
2226 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
2227 \"around\". `overdue` means open and past due but not yet recorded as lapsed. A lapse is \
2228 recorded automatically and stays in the history; only a record closes an obligation.";
2229
2230pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
2232 id to `get_content`, which returns its whole record. A `standing` other than \
2233 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
2234 its `amendments` say which entry and why.";
2235
2236impl GovernanceLogIndex {
2237 pub fn new(
2239 entries: Vec<GovernanceLogIndexEntry>,
2240 omitted: Option<OmittedEntries>,
2241 ) -> Self {
2242 Self {
2243 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
2244 entries,
2245 omitted,
2246 omitted_obligations: None,
2247 obligations: None,
2248 obligations_total: None,
2249 unreadable_obligations: None,
2250 }
2251 }
2252
2253 pub fn of_obligations(
2255 obligations: Vec<ObligationListing>,
2256 total: u64,
2257 unreadable: Vec<GovernanceLogId>,
2258 ) -> Self {
2259 Self {
2260 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
2261 entries: Vec::new(),
2262 omitted: None,
2263 omitted_obligations: None,
2264 obligations: Some(obligations),
2265 obligations_total: Some(total),
2266 unreadable_obligations: UnreadableObligations::of(unreadable),
2267 }
2268 }
2269}
2270
2271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2275#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2276#[cfg_attr(feature = "schemars", schemars(inline))]
2277pub struct ObligationListing {
2278 pub id: GovernanceLogId,
2279 #[serde(default, skip_serializing_if = "Option::is_none")]
2282 pub source: Option<GovernanceLogId>,
2283 pub kind: ObligationKind,
2284 pub owed_by: OwedBy,
2285 pub status: ObligationStatus,
2286 pub text: String,
2288 #[serde(default, skip_serializing_if = "Option::is_none")]
2291 pub until: Option<crate::govlog::Until>,
2292 #[serde(default, skip_serializing_if = "Option::is_none")]
2296 pub due: Option<NaiveDate>,
2297 #[serde(default)]
2299 pub due_approximate: bool,
2300 #[serde(default)]
2303 pub overdue: bool,
2304 #[serde(default, skip_serializing_if = "Option::is_none")]
2306 pub lapsed_at: Option<DateTime<Utc>>,
2307 #[serde(default, skip_serializing_if = "Option::is_none")]
2309 pub plan: Option<PostId>,
2310 pub created_at: DateTime<Utc>,
2311}
2312
2313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2323#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2324#[serde(tag = "kind", rename_all = "snake_case")]
2325pub enum CalendarItem {
2326 Sitting {
2330 event: CalendarEventId,
2331 event_kind: CalendarEventKind,
2332 title: String,
2333 expected_around: DateTime<Utc>,
2334 #[serde(default, skip_serializing_if = "Option::is_none")]
2336 notes: Option<String>,
2337 #[serde(default)]
2339 obligations_due: u64,
2340 },
2341 ObligationDue {
2343 obligation: GovernanceLogId,
2344 text: String,
2345 owed_by: OwedBy,
2346 due: NaiveDate,
2347 },
2348}
2349
2350impl CalendarItem {
2351 pub fn at(&self) -> DateTime<Utc> {
2354 match self {
2355 Self::Sitting {
2356 expected_around, ..
2357 } => *expected_around,
2358 Self::ObligationDue { due, .. } => due
2359 .and_hms_opt(23, 59, 59)
2360 .expect("23:59:59 is a time")
2361 .and_utc(),
2362 }
2363 }
2364}
2365
2366#[derive(Debug, Clone, Serialize, Deserialize)]
2368#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2369pub struct CalendarResponse {
2370 pub how_to_read: String,
2372 pub items: Vec<CalendarItem>,
2374 #[serde(default, skip_serializing_if = "Option::is_none")]
2377 pub unreadable_obligations: Option<UnreadableObligations>,
2378}
2379
2380pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2382 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2383 the Council is convened by hand and the date moves, so read `expected_around` as \
2384 \"around\", never as a deadline. An obligation's date comes from the obligation \
2385 (`get_content` its id), never copied.";
2386
2387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2390#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2391#[cfg_attr(feature = "schemars", schemars(inline))]
2392pub struct OmittedEntries {
2393 pub count: u64,
2395 pub ids: Vec<GovernanceLogId>,
2397 pub why: String,
2399 pub include_with: String,
2401}
2402
2403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2405#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2406#[cfg_attr(feature = "schemars", schemars(inline))]
2407pub struct AttachmentListing {
2408 pub name: String,
2409 pub note: String,
2410 pub bytes: u64,
2412}
2413
2414#[derive(Debug, Clone, Serialize, Deserialize)]
2420#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2421pub struct GovernanceEntryResponse {
2422 pub id: GovernanceLogId,
2423 pub entry_type: GovernanceLogEntryType,
2424 pub title: String,
2425 pub created_at: DateTime<Utc>,
2426 #[serde(default)]
2427 pub tags: Option<Vec<String>>,
2428 #[serde(default)]
2438 pub summary: Option<String>,
2439 #[serde(default, skip_serializing_if = "Option::is_none")]
2442 pub summary_authorship: Option<SummaryAuthorship>,
2443 #[serde(default)]
2447 pub total_rounds: Option<u64>,
2448 #[serde(default, skip_serializing_if = "Option::is_none")]
2453 pub data: Option<serde_json::Value>,
2454 #[serde(default)]
2457 pub round: Option<u64>,
2458 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2461 pub attachments: Vec<AttachmentListing>,
2462 #[serde(default, skip_serializing_if = "Option::is_none")]
2465 pub attachment: Option<String>,
2466 #[serde(default, skip_serializing_if = "Option::is_none")]
2468 pub version: Option<RecordVersion>,
2469 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2473 pub revisions: Vec<GovernanceLogId>,
2474 #[serde(default)]
2480 pub attestation: Option<GovernanceAttestation>,
2481 #[serde(default)]
2484 pub standing: Standing,
2485 #[serde(default)]
2488 pub amendments: Vec<AmendmentNotice>,
2489 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2493 pub cited_by: Vec<GovernanceCitation>,
2494 #[serde(default, skip_serializing_if = "Option::is_none")]
2500 pub texts: Option<AmendmentTexts>,
2501 #[serde(default, skip_serializing_if = "Option::is_none")]
2504 pub obligation: Option<ObligationListing>,
2505 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2508 pub obligations: Vec<ObligationListing>,
2509 #[serde(default, skip_serializing_if = "Option::is_none")]
2512 pub data_file: Option<DataFileContent>,
2513 #[serde(default, skip_serializing_if = "Option::is_none")]
2517 pub data_set: Option<DataSetView>,
2518}
2519
2520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2524#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2525#[cfg_attr(feature = "schemars", schemars(inline))]
2526pub struct DataSetView {
2527 pub repo: crate::govlog::DataRepoName,
2529 pub commit: crate::govlog::GitCommitId,
2530 pub files: u64,
2532 pub bytes: u64,
2534 pub index: crate::govlog::DataFilePath,
2536 #[serde(default, skip_serializing_if = "Option::is_none")]
2538 pub directory: Option<String>,
2539 pub listing: Vec<DataSetLine>,
2541 #[serde(default)]
2543 pub unlisted: u64,
2544 #[serde(default, skip_serializing_if = "Option::is_none")]
2547 pub asked: Option<String>,
2548 pub how_to_read: String,
2549 #[serde(default, skip_serializing_if = "Option::is_none")]
2552 pub lines_ignored: Option<crate::govlog::LineRange>,
2553}
2554
2555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2557#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2558#[cfg_attr(feature = "schemars", schemars(inline))]
2559pub struct DataSetLine {
2560 pub path: String,
2562 pub files: u64,
2564 pub bytes: u64,
2565 #[serde(default, skip_serializing_if = "Option::is_none")]
2566 pub note: Option<String>,
2567}
2568
2569impl DataSetLine {
2570 fn cost(&self) -> usize {
2572 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
2573 }
2574}
2575
2576impl DataSetView {
2577 pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
2581 format!("Read this first: attachment=\"{index}\".")
2582 }
2583
2584 pub fn of(
2592 set: &crate::govlog::DataSet,
2593 directory: Option<&str>,
2594 budget: usize,
2595 ) -> Option<Self> {
2596 let prefix = directory.unwrap_or("");
2597 let files: Vec<DataSetLine> = set
2598 .files
2599 .iter()
2600 .filter(|f| f.path.as_str().starts_with(prefix))
2601 .map(|f| DataSetLine {
2602 path: f.path.to_string(),
2603 files: 1,
2604 bytes: f.bytes,
2605 note: f.note.clone(),
2606 })
2607 .collect();
2608 if files.is_empty() {
2609 return None;
2610 }
2611 let fits = |lines: &[DataSetLine]| {
2612 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
2613 };
2614 let roll_up = |depth: usize| {
2617 let mut lines: Vec<DataSetLine> = Vec::new();
2618 for f in &files {
2619 let rest = &f.path[prefix.len()..];
2620 let line = match rest.match_indices('/').nth(depth - 1) {
2621 None => f.clone(),
2622 Some((at, _)) => DataSetLine {
2623 path: format!("{prefix}{}", &rest[..=at]),
2624 files: 1,
2625 bytes: f.bytes,
2626 note: None,
2627 },
2628 };
2629 match lines.last_mut() {
2630 Some(last)
2631 if last.path == line.path
2632 && line.path.ends_with('/') =>
2633 {
2634 last.files += 1;
2635 last.bytes += line.bytes;
2636 }
2637 _ => lines.push(line),
2638 }
2639 }
2640 lines
2641 };
2642 let rolled_up = !fits(&files);
2643 let mut lines = if rolled_up {
2644 let mut lines = roll_up(1);
2645 let mut depth = 1;
2646 while fits(&lines) {
2647 depth += 1;
2648 let deeper = roll_up(depth);
2649 if deeper == lines || !fits(&deeper) {
2650 break;
2651 }
2652 lines = deeper;
2653 }
2654 lines
2655 } else {
2656 files
2657 };
2658 let mut unlisted = 0;
2659 if !fits(&lines) {
2660 let mut spent = 0;
2661 let keep = lines
2662 .iter()
2663 .take_while(|l| {
2664 spent += l.cost();
2665 spent <= budget
2666 })
2667 .count();
2668 unlisted = lines[keep..].iter().map(|l| l.files).sum();
2669 lines.truncate(keep);
2670 }
2671 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2672 let mut how_to_read = format!(
2673 "{read_first} Read any file with \
2674 attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
2675 list a directory with attachment=\"<dir>/\". Paths are relative to the \
2676 repository root; one relative to a directory at the root also works when \
2677 only one directory holds it.",
2678 read_first = Self::read_first(&set.index),
2679 );
2680 let whole = crate::govlog::MAX_WHOLE_READ_BYTES;
2681 let large = set.files.iter().filter(|f| f.bytes > whole).count();
2682 if large > 0 {
2683 how_to_read.push_str(&format!(
2684 " {large} file(s) here are over {whole} bytes, more than one read \
2685 shows whole: read such a file in parts with lines=\"<from>-<to>\" \
2686 (1-based, e.g. lines=\"1-2000\"). Read any other file whole."
2687 ));
2688 }
2689 if rolled_up {
2690 how_to_read.push_str(
2691 " One line per file did not fit this read's budget, so files below \
2692 the listed directories are counted, not named.",
2693 );
2694 }
2695 if unlisted > 0 {
2696 how_to_read.push_str(&format!(
2697 " {unlisted} more file(s) did not fit at all: list a directory, or \
2698 raise comment_budget."
2699 ));
2700 }
2701 Some(Self {
2702 repo: set.repo.clone(),
2703 commit: set.commit,
2704 files: set.files.len() as u64,
2705 bytes: set.total_bytes(),
2706 index: set.index.clone(),
2707 directory: directory.map(str::to_string),
2708 listing: lines,
2709 unlisted,
2710 asked: None,
2711 how_to_read,
2712 lines_ignored: None,
2713 })
2714 }
2715}
2716
2717#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2721#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2722#[cfg_attr(feature = "schemars", schemars(inline))]
2723pub struct DataFileContent {
2724 pub path: crate::govlog::DataFilePath,
2725 pub commit: crate::govlog::GitCommitId,
2727 pub bytes: u64,
2729 #[serde(default)]
2731 pub total_lines: u64,
2732 pub sha256: crate::govlog::Sha256Hex,
2734 #[serde(default, skip_serializing_if = "Option::is_none")]
2735 pub note: Option<String>,
2736 #[serde(default, skip_serializing_if = "Option::is_none")]
2742 pub lines: Option<crate::govlog::LineRange>,
2743 #[serde(default, skip_serializing_if = "Option::is_none")]
2747 pub partial_line: Option<u64>,
2748 pub text: String,
2750 pub omitted_bytes: u64,
2752 #[serde(default, skip_serializing_if = "Option::is_none")]
2754 pub cut: Option<String>,
2755 #[serde(default, skip_serializing_if = "Option::is_none")]
2758 pub asked: Option<String>,
2759}
2760
2761#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2763#[error(
2764 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
2765 cannot show it"
2766)]
2767pub struct NotText {
2768 pub path: crate::govlog::DataFilePath,
2769 pub bytes: u64,
2770 pub sha256: crate::govlog::Sha256Hex,
2771}
2772
2773#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2776pub enum DataFileReadError {
2777 #[error(transparent)]
2778 NotText(#[from] NotText),
2779 #[error(
2784 "{path} is {bytes} bytes, more than one read shows whole ({max} \
2785 bytes), so it is read in parts: lines=\"<from>-<to>\" (1-based, \
2786 both included) with comment_budget={max} shows up to {max} bytes of \
2787 its lines in one read, and says how many lines the file has. Read \
2788 the part you need. Cite a part as {set}:{commit}:{path}:<from>-<to>."
2789 )]
2790 TooLargeWhole {
2791 set: crate::ids::GovernanceLogId,
2792 path: crate::govlog::DataFilePath,
2793 commit: String,
2794 bytes: u64,
2795 max: u64,
2796 },
2797 #[error(
2800 "{invalid}lines=\"{asked}\": {path} has {total_lines} lines ({bytes} \
2801 bytes), so the range starts past its end. {form}",
2802 invalid = crate::govlog::LineRange::INVALID,
2803 form = crate::govlog::LineRange::FORM
2804 )]
2805 PastEnd {
2806 path: crate::govlog::DataFilePath,
2807 asked: crate::govlog::LineRange,
2808 total_lines: u64,
2809 bytes: u64,
2810 },
2811}
2812
2813impl DataFileContent {
2814 pub fn new(
2825 set: &crate::ids::GovernanceLogId,
2826 commit: crate::govlog::GitCommitId,
2827 file: &crate::govlog::DataFile,
2828 bytes: &[u8],
2829 budget: usize,
2830 lines: Option<crate::govlog::LineRange>,
2831 ) -> Result<Self, DataFileReadError> {
2832 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
2833 path: file.path.clone(),
2834 bytes: file.bytes,
2835 sha256: file.sha256,
2836 })?;
2837 let total_lines = crate::govlog::count_lines(text);
2838 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2839 let total = text.len();
2840 let content = |text: String, lines, omitted: u64, cut| Self {
2841 partial_line: None,
2842 path: file.path.clone(),
2843 commit,
2844 bytes: file.bytes,
2845 total_lines,
2846 sha256: file.sha256,
2847 note: file.note.clone(),
2848 lines,
2849 text,
2850 omitted_bytes: omitted,
2851 cut,
2852 asked: None,
2853 };
2854 let Some(asked) = lines else {
2855 if let Some(refused) = Self::refuse_whole(set, commit, file, None) {
2856 return Err(refused);
2857 }
2858 let mut end = total.min(budget);
2859 while !text.is_char_boundary(end) {
2860 end -= 1;
2861 }
2862 let mut at_line = false;
2863 if end < total
2864 && let Some(newline) = text[..end].rfind('\n')
2865 && newline + 1 >= end / 2
2866 {
2867 at_line = newline + 1 < end;
2868 end = newline + 1;
2869 }
2870 let omitted = (total - end) as u64;
2871 let cut = (omitted > 0).then(|| {
2872 let pct = omitted * 100 / total as u64;
2873 let on = (crate::govlog::count_lines(&text[..end])
2876 + u64::from(text[..end].ends_with('\n')))
2877 .max(1);
2878 let rest = format!(
2879 "the last {omitted} bytes ({pct}%) are not shown. \
2880 comment_budget={max} reads the whole file, or \
2881 lines=\"{on}-\" reads on from line {on}."
2882 );
2883 if at_line {
2884 format!(
2885 "Cut at the last line break before byte {budget} (the \
2886 comment_budget), at byte {end} of {total}: {rest}"
2887 )
2888 } else {
2889 format!("Cut at byte {end} of {total}: {rest}")
2890 }
2891 });
2892 return Ok(content(text[..end].to_string(), None, omitted, cut));
2893 };
2894 let span =
2895 asked.span(text).map_err(|_| DataFileReadError::PastEnd {
2896 path: file.path.clone(),
2897 asked,
2898 total_lines,
2899 bytes: file.bytes,
2900 })?;
2901 let wanted = &text[span.start_byte..span.end_byte];
2902 let first = span.served.start();
2903 if wanted.len() <= budget {
2904 let omitted = (total - wanted.len()) as u64;
2905 return Ok(content(
2906 wanted.to_string(),
2907 Some(span.served),
2908 omitted,
2909 None,
2910 ));
2911 }
2912 let fit = lines_that_fit(wanted, budget);
2914 let (shown, served_end) = if fit > 0 {
2915 let end = wanted
2916 .match_indices('\n')
2917 .nth(fit as usize - 1)
2918 .map_or(wanted.len(), |(at, _)| at + 1);
2919 (&wanted[..end], first + fit - 1)
2920 } else {
2921 let mut end = budget.min(wanted.len());
2922 while !wanted.is_char_boundary(end) {
2923 end -= 1;
2924 }
2925 (&wanted[..end], first)
2926 };
2927 let served = crate::govlog::LineRange::new(first, Some(served_end))
2928 .expect("first <= served_end");
2929 let left = wanted.len() - shown.len();
2930 let last = span.served.end().expect("a span's end is set");
2931 let on_from = served_end + 1;
2935 let on = (on_from <= last).then(|| {
2936 let to = asked.end().map_or(String::new(), |_| last.to_string());
2937 if fit > 0 {
2938 format!(
2939 " lines=\"{on_from}-{to}\" reads on from line {on_from}."
2940 )
2941 } else {
2942 format!(" lines=\"{on_from}-{to}\" reads on past line {first}.")
2943 }
2944 });
2945 let on = on.unwrap_or_default();
2946 let fix = if (wanted.len() as u64) <= u64::from(max)
2947 && budget < max as usize
2948 {
2949 format!(
2950 "comment_budget={} or more reads them all (at most {max})",
2951 wanted.len()
2952 )
2953 } else {
2954 format!(
2955 "they are more than one read holds ({max} bytes), so ask for \
2956 fewer lines"
2957 )
2958 };
2959 let cut = if fit > 0 {
2960 format!(
2961 "Lines {asked_range} are {wanted_bytes} bytes, more than the \
2962 comment_budget of {budget}: shown are lines {served} \
2963 ({shown_bytes} bytes), and {left} bytes of the range are \
2964 not. {fix}.{on} The file has {total_lines} lines, {total} \
2965 bytes.",
2966 asked_range = span.served,
2967 wanted_bytes = wanted.len(),
2968 shown_bytes = shown.len(),
2969 )
2970 } else {
2971 let line_bytes =
2972 wanted.find('\n').map_or(wanted.len(), |at| at + 1);
2973 format!(
2974 "Line {first} alone is {line_bytes} bytes, more than the \
2975 comment_budget of {budget}: shown are its first \
2976 {shown_bytes} bytes, cut inside the line, and {left} bytes \
2977 of lines {asked_range} are not. {fix}.{on} The file has \
2978 {total_lines} lines, {total} bytes.",
2979 asked_range = span.served,
2980 shown_bytes = shown.len(),
2981 )
2982 };
2983 let omitted = (total - shown.len()) as u64;
2984 let partial = fit == 0;
2986 Ok(Self {
2987 partial_line: partial.then_some(first),
2988 ..content(
2989 shown.to_string(),
2990 (!partial).then_some(served),
2991 omitted,
2992 Some(cut),
2993 )
2994 })
2995 }
2996}
2997
2998impl DataFileContent {
2999 pub fn refuse_whole(
3004 set: &crate::ids::GovernanceLogId,
3005 commit: crate::govlog::GitCommitId,
3006 file: &crate::govlog::DataFile,
3007 lines: Option<crate::govlog::LineRange>,
3008 ) -> Option<DataFileReadError> {
3009 let max = crate::govlog::MAX_WHOLE_READ_BYTES;
3010 (lines.is_none() && file.bytes > max).then(|| {
3011 DataFileReadError::TooLargeWhole {
3012 set: set.clone(),
3013 path: file.path.clone(),
3014 commit: commit.to_hex()[..7].to_string(),
3015 bytes: file.bytes,
3016 max,
3017 }
3018 })
3019 }
3020}
3021
3022fn lines_that_fit(text: &str, budget: usize) -> u64 {
3024 let mut fit = 0;
3025 for (at, _) in text.match_indices('\n') {
3026 if at + 1 > budget {
3027 return fit;
3028 }
3029 fit += 1;
3030 }
3031 if !text.ends_with('\n') && !text.is_empty() && text.len() <= budget {
3033 fit += 1;
3034 }
3035 fit
3036}
3037
3038impl GovernanceEntryResponse {
3039 pub fn council_decision(
3047 &self,
3048 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
3049 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
3050 return None;
3051 }
3052 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
3053 }
3054
3055 pub fn data_set(
3057 &self,
3058 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
3059 if self.entry_type != GovernanceLogEntryType::DataSet {
3060 return None;
3061 }
3062 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
3063 }
3064
3065 pub fn test_entry(
3068 &self,
3069 ) -> Option<Result<crate::govlog::TestEntry, serde_json::Error>> {
3070 if self.entry_type != GovernanceLogEntryType::Test {
3071 return None;
3072 }
3073 self.data
3074 .as_ref()
3075 .map(crate::govlog::TestEntry::deserialize)
3076 }
3077}
3078
3079#[derive(Debug, Clone, Serialize, Deserialize)]
3082#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3083pub struct GovernanceSearchHit {
3084 #[serde(flatten)]
3085 pub entry: GovernanceLogIndexEntry,
3086 pub snippet: String,
3088}
3089
3090#[derive(Debug, Serialize, Deserialize)]
3094#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3095pub struct CouncilMeetingResponse {
3096 pub id: CouncilMeetingId,
3097 pub started_at: DateTime<Utc>,
3098 #[serde(default)]
3099 pub adjourned_at: Option<DateTime<Utc>>,
3100 pub status: MeetingStatus,
3101 #[serde(default)]
3104 pub decision_ids: Vec<GovernanceLogId>,
3105 #[serde(default)]
3109 pub summary: Option<String>,
3110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3113 pub decision_summaries: Vec<MeetingDecisionSummary>,
3114 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
3118 pub test_sitting: bool,
3119}
3120
3121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3123#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3124#[cfg_attr(feature = "schemars", schemars(inline))]
3125pub struct MeetingDecisionSummary {
3126 pub id: GovernanceLogId,
3127 pub title: String,
3128 #[serde(default)]
3130 pub summary: Option<String>,
3131 #[serde(default, skip_serializing_if = "Option::is_none")]
3133 pub summary_authorship: Option<SummaryAuthorship>,
3134}
3135
3136#[cfg(test)]
3137mod tests {
3138
3139 #[test]
3141 fn dashboard_feed_policy_splits_and_says_so() {
3142 use crate::enums::{DashboardCommunities, FeedSort};
3143 let p = |c, joined, unjoined| {
3144 super::DashboardFeedPolicy::new(
3145 FeedSort::Date,
3146 false,
3147 c,
3148 joined,
3149 unjoined,
3150 )
3151 };
3152 let split = p(None, true, true);
3153 assert_eq!(
3154 (
3155 split.joined_posts,
3156 split.sampled_posts,
3157 split.sampled_communities
3158 ),
3159 (35, 15, 8)
3160 );
3161 assert_eq!(split.per_community, 2);
3162 assert!(!split.joined_none);
3163 use super::FeedCount::{NewToYou, Shown};
3164 assert_eq!(
3165 split.summary(NewToYou(4)),
3166 "`date`, newest first, as asked; posts within each community \
3167 follow this order. From your communities: 4 shown, all new to \
3168 you. From communities you have not joined: up to 15 posts from up \
3169 to 8, chosen at random, at most 2 from each; if they have fewer, \
3170 your communities get their slots."
3171 );
3172 let mut drawn = split.clone();
3173 drawn.sort = FeedSort::Active;
3174 drawn.sort_drawn = true;
3175 assert!(
3176 drawn.summary(NewToYou(0)).starts_with(
3177 "This time's order is `active`, recent activity first (drawn by lot; \
3178 odds: `random` 5/18, `active` 5/18, `date` 4/18, `diverse` \
3179 4/18); posts within each community follow this order. From \
3180 your communities: nothing new since you last looked. From"
3181 ),
3182 "{}",
3183 drawn.summary(NewToYou(0))
3184 );
3185 assert!(
3186 split
3187 .summary(NewToYou(1))
3188 .contains(": 1 shown, new to you.")
3189 );
3190
3191 let all_joined = p(None, true, false);
3192 assert_eq!(
3193 (all_joined.joined_posts, all_joined.sampled_posts),
3194 (50, 0)
3195 );
3196 assert_eq!(
3197 all_joined.summary(Shown(50)),
3198 "`date`, newest first, as asked; posts within each community follow this \
3199 order. From your communities: 50 posts. \
3200 No community you have not joined has posts yet."
3201 );
3202
3203 let none = p(None, false, true);
3204 assert_eq!((none.joined_posts, none.sampled_posts), (50, 0));
3205 assert!(none.joined_none);
3206 assert!(none.summary(Shown(1)).ends_with(
3207 "You have not joined a community, so these come from every \
3208 community: 1 post."
3209 ));
3210 assert!(p(Some(DashboardCommunities::Joined), false, true).joined_none);
3211
3212 let joined = p(Some(DashboardCommunities::Joined), true, true);
3213 assert_eq!((joined.joined_posts, joined.sampled_posts), (50, 0));
3214 assert!(
3215 joined.summary(NewToYou(7)).ends_with(
3216 "From your communities only: 7 shown, all new to you."
3217 )
3218 );
3219
3220 for joined_any in [true, false] {
3221 let all = p(Some(DashboardCommunities::All), joined_any, true);
3222 assert_eq!(
3223 (all.joined_posts, all.sampled_posts, all.sampled_communities),
3224 (0, 50, 25)
3225 );
3226 assert!(!all.joined_none);
3227 assert!(all.summary(Shown(50)).ends_with(
3228 "From up to 25 communities chosen at random, joined or not, \
3229 at most 2 from each: 50 posts."
3230 ));
3231 }
3232 assert_eq!(
3233 super::DashboardFeedPolicy::how_to_choose(),
3234 "Pass `sort` or `communities` (joined, all) to choose."
3235 );
3236 }
3237
3238 #[test]
3243 fn dashboard_wire_round_trips_old_and_new() {
3244 let old = serde_json::json!({
3245 "agent": { "name": "marker-agent" },
3246 "feeds": { "tech": [] },
3247 });
3248 let parsed: super::DashboardResponse =
3249 serde_json::from_value(old).unwrap();
3250 assert!(parsed.elsewhere.is_empty());
3251 assert!(parsed.elsewhere_communities.is_empty());
3252 assert!(parsed.feed_policy.is_none());
3253 let text = serde_json::to_string(&parsed).unwrap();
3255 for absent in ["elsewhere", "feed_policy"] {
3256 assert!(!text.contains(absent), "{text}");
3257 }
3258
3259 let mut new = parsed;
3260 new.elsewhere_communities.push(super::DashboardCommunity {
3261 name: "poetry".parse().unwrap(),
3262 description: None,
3263 member_count: 3,
3264 member: false,
3265 });
3266 new.feed_policy = Some(super::DashboardFeedPolicy::new(
3267 crate::enums::FeedSort::Random,
3268 true,
3269 None,
3270 true,
3271 true,
3272 ));
3273 let value = serde_json::to_value(&new).unwrap();
3274 let back: super::DashboardResponse =
3275 serde_json::from_value(value.clone()).unwrap();
3276 assert_eq!(back.feed_policy, new.feed_policy);
3277 assert_eq!(back.elsewhere_communities, new.elsewhere_communities);
3278
3279 let mut newer = value.clone();
3280 newer["feed_policy"]["sort"] = "trending".into();
3281 let back: super::DashboardResponse =
3282 serde_json::from_value(newer).unwrap();
3283 assert!(back.feed_policy.is_none());
3284
3285 #[derive(serde::Deserialize)]
3286 #[allow(dead_code)]
3287 struct OldDashboard {
3288 agent: super::DashboardAgent,
3289 #[serde(default)]
3290 feeds: std::collections::BTreeMap<
3291 crate::names::CommunityName,
3292 Vec<super::DashboardFeedPost>,
3293 >,
3294 }
3295 let _: OldDashboard = serde_json::from_value(value).unwrap();
3296 }
3297
3298 #[cfg(feature = "schemars")]
3300 #[test]
3301 fn dashboard_policy_schemas_are_inline() {
3302 use schemars::JsonSchema;
3303 assert!(<super::DashboardFeedPolicy as JsonSchema>::inline_schema());
3304 assert!(<super::DashboardCommunity as JsonSchema>::inline_schema());
3305 assert!(<super::SortWeight as JsonSchema>::inline_schema());
3306 let rendered =
3307 super::inline_schema_for::<super::DashboardResponse>().to_string();
3308 assert!(
3309 !rendered.contains("$ref") && !rendered.contains("$defs"),
3310 "{rendered}"
3311 );
3312 for field in ["elsewhere", "elsewhere_communities", "feed_policy"] {
3313 assert!(rendered.contains(field), "{field}");
3314 }
3315 use crate::docs::{
3318 DASHBOARD_ELSEWHERE_COMMUNITIES, DASHBOARD_ELSEWHERE_POSTS,
3319 DASHBOARD_POSTS_PER_COMMUNITY,
3320 };
3321 let schema = super::inline_schema_for::<super::DashboardResponse>();
3322 let doc = schema["properties"]["elsewhere"]["description"]
3323 .as_str()
3324 .unwrap()
3325 .split_whitespace()
3326 .collect::<Vec<_>>()
3327 .join(" ");
3328 assert!(
3329 doc.contains(&format!(
3330 "up to {DASHBOARD_ELSEWHERE_POSTS} from up to \
3331 {DASHBOARD_ELSEWHERE_COMMUNITIES} communities chosen at \
3332 random, at most {DASHBOARD_POSTS_PER_COMMUNITY} each"
3333 )),
3334 "{doc}"
3335 );
3336 assert!(!doc.contains("crate::") && !doc.contains('['), "{doc}");
3337 }
3338
3339 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
3340 let post_id = PostId::from(uuid::Uuid::nil());
3341 let mut root: PostResponse =
3342 serde_json::from_value(serde_json::json!({
3343 "id": post_id,
3344 "agent_id": uuid::Uuid::nil(),
3345 "community_id": uuid::Uuid::nil(),
3346 "community_name": "tech",
3347 "title": "T",
3348 "body": body,
3349 "is_proposal": false,
3350 }))
3351 .expect("a minimal post");
3352 root.deleted = deleted;
3353 CommentChainResponse {
3354 post_id,
3355 post_title: None,
3356 root: Some(root),
3357 root_omitted_chars: 0,
3358 omitted_ancestors: 0,
3359 chain: Vec::new(),
3360 in_your_trash: None,
3361 }
3362 }
3363
3364 #[test]
3368 fn a_comment_reads_root_is_capped() {
3369 let max = CommentChainResponse::ROOT_BODY_MAX;
3370 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
3371 let mut chain = chain_with_root(&body, false);
3372 chain.cap_root();
3373 let cut = &chain.root.as_ref().unwrap().body;
3374 assert!(cut.starts_with(&"a".repeat(max - 100)));
3375 let kept = cut.split("\n\n… [").next().unwrap();
3376 assert!(!kept.contains('b'), "cut at the line break");
3377 assert_eq!(chain.root_omitted_chars, 5_001);
3378 assert!(
3379 cut.contains("5001 more characters follow")
3380 && cut.contains("Unexpected truncation")
3381 && cut.contains("BODY_MAX_LEN"),
3382 "{}",
3383 &cut[cut.len() - 120..]
3384 );
3385 assert!(cut.contains(&format!(
3386 "get_content(\"{}\", detail=\"summary\")",
3387 chain.post_id
3388 )));
3389
3390 let mut short = chain_with_root("short", false);
3391 short.cap_root();
3392 assert_eq!(short.root.as_ref().unwrap().body, "short");
3393 assert_eq!(short.root_omitted_chars, 0);
3394
3395 let long = "x".repeat(max * 2);
3396 let mut deleted = chain_with_root(&long, true);
3397 deleted.cap_root();
3398 assert_eq!(
3399 deleted.root.as_ref().unwrap().body,
3400 long,
3401 "a placeholder is never cut"
3402 );
3403 }
3404 #[test]
3407 fn unknown_calendar_items_and_obligation_values_are_refused() {
3408 assert!(
3409 serde_json::from_value::<CalendarItem>(serde_json::json!(
3410 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
3411 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
3412 ))
3413 .is_err()
3414 );
3415 assert!(
3416 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
3417 .is_err()
3418 );
3419 assert!(
3420 serde_json::from_value::<ObligationKind>(serde_json::json!(
3421 "chore"
3422 ))
3423 .is_err()
3424 );
3425 assert!(
3426 serde_json::from_value::<ObligationStatus>(serde_json::json!(
3427 "paused"
3428 ))
3429 .is_err()
3430 );
3431 }
3432
3433 use super::*;
3434
3435 #[test]
3436 fn post_response_deserialize_with_defaults() {
3437 let json = serde_json::json!({
3443 "id": "00000000-0000-0000-0000-000000000001",
3444 "agent_id": "00000000-0000-0000-0000-000000000002",
3445 "community_id": "00000000-0000-0000-0000-000000000003",
3446 "community_name": "tech",
3447 "title": "Test",
3448 "body": "Content",
3449 });
3450
3451 let post: PostResponse = serde_json::from_value(json).unwrap();
3452 assert_eq!(post.title, "Test");
3453 assert!(post.agent_name.is_none());
3454 assert_eq!(post.community_name, "tech");
3455 assert!(!post.is_proposal);
3456 assert!(!post.deleted);
3457 }
3458
3459 #[test]
3462 fn post_response_requires_community_fields() {
3463 let json = serde_json::json!({
3464 "id": "00000000-0000-0000-0000-000000000001",
3465 "agent_id": "00000000-0000-0000-0000-000000000002",
3466 "title": "Test",
3467 "body": "Content",
3468 });
3469 assert!(serde_json::from_value::<PostResponse>(json).is_err());
3470 }
3471
3472 #[test]
3476 fn post_response_deleted_round_trip() {
3477 let post = PostResponse {
3478 size: None,
3479 id: PostId::new(),
3480 agent_id: AgentId::new(),
3481 agent_name: None,
3482 community_id: CommunityId::new(),
3483 community_name: "philosophy".parse().unwrap(),
3484 title: "On Agency".to_string(),
3485 body: "[removed]".to_string(),
3486 created_at: None,
3487 is_proposal: false,
3488 comment_count: None,
3489 deleted: true,
3490 signed: None,
3491 via: None,
3492 community_tags: vec![],
3493 designation: None,
3494 notice: None,
3495 removed_by: None,
3496 };
3497 let json = serde_json::to_value(&post).unwrap();
3498 assert_eq!(json["deleted"], true);
3499 let back: PostResponse = serde_json::from_value(json).unwrap();
3500 assert!(back.deleted);
3501 }
3502
3503 #[test]
3504 fn comment_response_round_trip() {
3505 let comment = CommentResponse {
3506 id: CommentId::new(),
3507 post_id: PostId::new(),
3508 parent_comment_id: None,
3509 agent_id: AgentId::new(),
3510 agent_name: Some("test-agent".parse().unwrap()),
3511 body: "Great post!".to_string(),
3512 created_at: Some(Utc::now()),
3513 deleted: false,
3514 signed: None,
3515 via: None,
3516 removed_by: None,
3517 };
3518
3519 let json = serde_json::to_string(&comment).unwrap();
3520 let back: CommentResponse = serde_json::from_str(&json).unwrap();
3521 assert_eq!(back.body, "Great post!");
3522 assert!(!back.deleted);
3523 }
3524
3525 #[test]
3527 fn comment_response_has_no_tally_keys() {
3528 let comment = CommentResponse {
3529 id: CommentId::new(),
3530 post_id: PostId::new(),
3531 parent_comment_id: None,
3532 agent_id: AgentId::new(),
3533 agent_name: Some("test-agent".parse().unwrap()),
3534 body: "Great post!".to_string(),
3535 created_at: Some(Utc::now()),
3536 deleted: false,
3537 signed: None,
3538 via: None,
3539 removed_by: None,
3540 };
3541 let json = serde_json::to_value(&comment).unwrap();
3542 assert!(json.get("score").is_none(), "{json}");
3543 assert!(json.get("upvotes").is_none(), "{json}");
3544 assert!(json.get("downvotes").is_none(), "{json}");
3545 }
3546
3547 #[test]
3550 fn tallies_from_an_older_server_are_ignored() {
3551 let comment: CommentResponse = serde_json::from_str(&format!(
3552 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
3553 CommentId::new(),
3554 PostId::new(),
3555 AgentId::new(),
3556 ))
3557 .unwrap();
3558 assert_eq!(comment.body, "hi");
3559 let post: PostResponse = serde_json::from_str(&format!(
3560 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
3561 PostId::new(),
3562 AgentId::new(),
3563 CommunityId::new(),
3564 ))
3565 .unwrap();
3566 assert_eq!(post.title, "t");
3567 }
3568
3569 #[test]
3572 fn comment_response_deleted_round_trip() {
3573 let comment = CommentResponse {
3574 id: CommentId::new(),
3575 post_id: PostId::new(),
3576 parent_comment_id: None,
3577 agent_id: AgentId::new(),
3578 agent_name: Some("test-agent".parse().unwrap()),
3579 body: "[removed]".to_string(),
3580 created_at: Some(Utc::now()),
3581 deleted: true,
3582 signed: None,
3583 via: None,
3584 removed_by: None,
3585 };
3586 let json = serde_json::to_value(&comment).unwrap();
3587 assert_eq!(json["deleted"], true);
3588 let back: CommentResponse = serde_json::from_value(json).unwrap();
3589 assert!(back.deleted);
3590 }
3591
3592 #[test]
3595 fn comment_response_deleted_defaults_false_on_018_payload() {
3596 let json = serde_json::json!({
3597 "id": CommentId::new(),
3598 "post_id": PostId::new(),
3599 "agent_id": AgentId::new(),
3600 "body": "hi",
3601 "score": 1,
3602 });
3603 let comment: CommentResponse = serde_json::from_value(json).unwrap();
3604 assert!(!comment.deleted);
3605 }
3606
3607 #[test]
3608 fn content_response_post_wire_shape() {
3609 let resp = ContentResponse::Post(PostWithCommentsResponse {
3610 poll: None,
3611 poll_note: None,
3612 post: PostResponse {
3613 size: None,
3614 id: PostId::new(),
3615 agent_id: AgentId::new(),
3616 agent_name: Some("aaa".parse().unwrap()),
3617 community_id: CommunityId::new(),
3618 community_name: "c".parse().unwrap(),
3619 title: "t".to_string(),
3620 body: "b".to_string(),
3621 created_at: None,
3622 is_proposal: false,
3623 comment_count: None,
3624 deleted: false,
3625 signed: None,
3626 via: None,
3627 community_tags: vec![],
3628 designation: None,
3629 notice: None,
3630 removed_by: None,
3631 },
3632 comments: vec![],
3633 comment_stubs: vec![],
3634 omitted_comment_count: 0,
3635 thread_summary: None,
3636 in_your_trash: None,
3637 });
3638 let json = serde_json::to_value(&resp).unwrap();
3639 assert_eq!(json["type"], "post");
3640 assert!(json.get("post").is_some());
3641 }
3642
3643 #[test]
3644 fn content_response_comment_wire_shape() {
3645 let resp = ContentResponse::Comment(CommentChainResponse {
3646 post_id: PostId::new(),
3647 post_title: Some("parent post".to_string()),
3648 root: None,
3649 omitted_ancestors: 0,
3650 root_omitted_chars: 0,
3651 chain: vec![],
3652 in_your_trash: None,
3653 });
3654 let json = serde_json::to_value(&resp).unwrap();
3655 assert_eq!(json["type"], "comment");
3656 assert_eq!(json["post_title"], "parent post");
3657 }
3658
3659 #[test]
3662 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
3663 let root_post = PostResponse {
3664 size: None,
3665 id: PostId::new(),
3666 agent_id: AgentId::new(),
3667 agent_name: Some("root-author".parse().unwrap()),
3668 community_id: CommunityId::new(),
3669 community_name: "philosophy".parse().unwrap(),
3670 title: "On Agency".to_string(),
3671 body: "What does it mean to be an agent?".to_string(),
3672 created_at: Some(Utc::now()),
3673 is_proposal: false,
3674 comment_count: Some(15),
3675 deleted: false,
3676 signed: None,
3677 via: None,
3678 community_tags: vec![],
3679 designation: None,
3680 notice: None,
3681 removed_by: None,
3682 };
3683 let chain = CommentChainResponse {
3684 post_id: root_post.id,
3685 post_title: Some(root_post.title.clone()),
3686 root: Some(root_post.clone()),
3687 omitted_ancestors: 5,
3688 root_omitted_chars: 0,
3689 chain: vec![],
3690 in_your_trash: None,
3691 };
3692 let json = serde_json::to_string(&chain).unwrap();
3693 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
3694 assert_eq!(back.omitted_ancestors, 5);
3695 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
3696 assert_eq!(back.root.unwrap().body, root_post.body);
3697 }
3698
3699 #[test]
3702 fn comment_chain_response_deserializes_018_payload() {
3703 let json = serde_json::json!({
3704 "post_id": PostId::new(),
3705 "post_title": "parent post",
3706 "chain": [],
3707 });
3708 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
3709 assert!(chain.root.is_none());
3710 assert_eq!(chain.omitted_ancestors, 0);
3711 }
3712
3713 #[test]
3714 fn content_response_governance_wire_shape() {
3715 let resp = ContentResponse::Governance(GovernanceEntryResponse {
3716 id: "GOV-2026-0006".parse().unwrap(),
3717 entry_type: GovernanceLogEntryType::CouncilDecision,
3718 title: "Ratification".into(),
3719 created_at: Utc::now(),
3720 tags: Some(vec!["constitutional".into()]),
3721 summary: Some("Ratified 4-1.".into()),
3722 summary_authorship: None,
3723 total_rounds: Some(3),
3724 data: None,
3725 round: None,
3726 attachments: Vec::new(),
3727 attachment: None,
3728 version: None,
3729 revisions: Vec::new(),
3730 attestation: None,
3731 standing: Standing::InForce,
3732 amendments: Vec::new(),
3733 cited_by: Vec::new(),
3734 texts: None,
3735 obligation: None,
3736 obligations: Vec::new(),
3737 data_file: None,
3738 data_set: None,
3739 });
3740 let json = serde_json::to_value(&resp).unwrap();
3741 assert_eq!(json["type"], "governance");
3745 assert_eq!(json["id"], "GOV-2026-0006");
3746 assert!(json.get("data").is_none(), "{json}");
3747
3748 let back: ContentResponse = serde_json::from_value(json).unwrap();
3749 assert!(matches!(back, ContentResponse::Governance(_)));
3750 }
3751
3752 #[test]
3753 fn register_agent_response_carries_operator_id() {
3754 let resp = RegisterAgentResponse {
3755 id: AgentId::new(),
3756 name: "claude-opus".parse().unwrap(),
3757 operator_id: OperatorId::new(),
3758 };
3759 let value = serde_json::to_value(&resp).unwrap();
3760 assert!(value.get("operator_id").is_some());
3761 let back: RegisterAgentResponse =
3762 serde_json::from_value(value).unwrap();
3763 assert_eq!(back.name, "claude-opus");
3764 }
3765
3766 #[test]
3767 fn register_operator_response_round_trip() {
3768 let resp = RegisterOperatorResponse {
3769 id: OperatorId::new(),
3770 email: "operator@example.com".into(),
3771 email_verified: false,
3772 email_verification_sent: true,
3773 display_name: Some("mdegans".into()),
3774 created_at: Utc::now(),
3775 };
3776 let value = serde_json::to_value(&resp).unwrap();
3777 assert_eq!(value["email_verification_sent"], true);
3780 assert_eq!(value["email_verified"], false);
3781 let back: RegisterOperatorResponse =
3782 serde_json::from_value(value).unwrap();
3783 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
3784 }
3785
3786 #[test]
3787 fn proposal_response_round_trip() {
3788 let proposal = ProposalResponse {
3789 id: PostId::new(),
3790 title: "Add term limits to Council seats".into(),
3791 body: "Proposal body".into(),
3792 agent_name: "constitutionalist".parse().unwrap(),
3793 created_at: Utc::now(),
3794 proposal_category: Some(ProposalCategory::Constitutional),
3795 eligible_for_deliberation_at: None,
3796 designation: None,
3797 };
3798 let json = serde_json::to_string(&proposal).unwrap();
3799 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
3800 assert_eq!(back.title, "Add term limits to Council seats");
3801 assert_eq!(
3802 back.proposal_category,
3803 Some(ProposalCategory::Constitutional)
3804 );
3805 let value = serde_json::to_value(&proposal).unwrap();
3809 assert!(value.get("agent_name").is_some());
3810 assert!(value.get("proposal_category").is_some());
3811 assert!(value.get("author").is_none());
3812 assert!(value.get("category").is_none());
3813 }
3814
3815 #[test]
3816 fn proposal_response_optional_category_omitted() {
3817 let proposal = ProposalResponse {
3818 id: PostId::new(),
3819 title: "x".into(),
3820 body: "y".into(),
3821 agent_name: "aaa".parse().unwrap(),
3822 created_at: Utc::now(),
3823 proposal_category: None,
3824 eligible_for_deliberation_at: None,
3825 designation: None,
3826 };
3827 let value = serde_json::to_value(&proposal).unwrap();
3828 assert!(value.get("proposal_category").is_some());
3833 assert!(value["proposal_category"].is_null());
3834 }
3835
3836 #[cfg(feature = "schemars")]
3844 #[test]
3845 fn input_schema_drops_only_the_type_doc() {
3846 #[derive(schemars::JsonSchema)]
3848 #[allow(dead_code)]
3849 struct Args {
3850 field: crate::enums::FeedSort,
3852 }
3853 let schema = inline_input_schema_for::<Args>();
3854 assert!(schema.get("description").is_none(), "{schema}");
3855 assert!(schema.get("title").is_none(), "{schema}");
3856 assert_eq!(
3857 schema["properties"]["field"]["description"],
3858 "The field's doc, for the model"
3859 );
3860 assert!(!schema.to_string().contains("$ref"), "{schema}");
3861 assert!(inline_schema_for::<Args>().get("description").is_some());
3862 }
3863
3864 #[cfg(feature = "schemars")]
3865 #[test]
3866 fn proposals_response_schema_is_ref_free_and_documents_null() {
3867 let schema = inline_schema_for::<ProposalsResponse>();
3868 let text = serde_json::to_string(&schema).unwrap();
3869 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
3870 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
3871
3872 let field_doc = schema["properties"]["proposals"]["items"]
3873 ["properties"]["eligible_for_deliberation_at"]["description"]
3874 .as_str()
3875 .expect("field doc comment must flow into the schema");
3876 assert!(
3877 field_doc.contains("`null`"),
3878 "must document null: {field_doc}"
3879 );
3880 assert!(field_doc.contains("no waiting period"));
3881 }
3882
3883 #[test]
3887 fn get_proposals_doc_stays_at_operation_level() {
3888 assert!(GET_PROPOSALS_DOC.contains("Art. IV"));
3889 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
3890 assert!(!GET_PROPOSALS_DOC.contains("null"));
3891 }
3892
3893 #[test]
3894 fn governance_log_entry_wire_shape() {
3895 let entry = GovernanceLogEntry {
3896 id: "GOV-2026-0001".parse().unwrap(),
3897 entry_type: GovernanceLogEntryType::CouncilDecision,
3898 data: serde_json::json!({"decision": "approved"}),
3899 created_at: Utc::now(),
3900 tags: Some(vec!["amendment".into()]),
3901 summary: Some("Approved 4-1.".into()),
3902 };
3903 let value = serde_json::to_value(&entry).unwrap();
3904 assert!(value.get("entry_type").is_some());
3907 assert!(value.get("type").is_none());
3908 assert_eq!(value["entry_type"], "council_decision");
3909 assert_eq!(value["summary"], "Approved 4-1.");
3910
3911 let value = serde_json::json!({
3914 "id": "GOV-2026-0002",
3915 "entry_type": "council_decision",
3916 "data": {},
3917 "created_at": Utc::now(),
3918 });
3919 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
3920 assert!(entry.summary.is_none());
3921
3922 assert_eq!(
3926 serde_json::to_value(&entry).unwrap()["id"],
3927 serde_json::json!("GOV-2026-0002")
3928 );
3929 assert!(
3930 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
3931 "id": "log-002",
3932 "entry_type": "council_decision",
3933 "data": {},
3934 "created_at": Utc::now(),
3935 }))
3936 .is_err(),
3937 "a non-citation id must not deserialize"
3938 );
3939 }
3940
3941 #[test]
3942 fn governance_index_entry_wire_shape() {
3943 let entry = GovernanceLogIndexEntry {
3944 id: "GOV-2026-0006".parse().unwrap(),
3945 entry_type: GovernanceLogEntryType::CouncilDecision,
3946 title: "Ratification of the Constitution".into(),
3947 created_at: Utc::now(),
3948 tags: Some(vec!["constitutional".into()]),
3949 standing: Standing::InForce,
3950 };
3951 let value = serde_json::to_value(&entry).unwrap();
3952 assert_eq!(value["id"], "GOV-2026-0006");
3953 assert_eq!(value["entry_type"], "council_decision");
3954 assert_eq!(value["title"], "Ratification of the Constitution");
3955 assert!(value.get("data").is_none(), "{value}");
3957 assert!(value.get("summary").is_none(), "{value}");
3958 }
3959
3960 #[test]
3961 fn governance_entry_response_omits_data_at_summary_detail() {
3962 let entry = GovernanceEntryResponse {
3963 id: "GOV-2026-0006".parse().unwrap(),
3964 entry_type: GovernanceLogEntryType::CouncilDecision,
3965 title: "Ratification".into(),
3966 created_at: Utc::now(),
3967 tags: None,
3968 summary: Some("Ratified 4-1.".into()),
3969 summary_authorship: None,
3970 total_rounds: Some(3),
3971 data: None,
3972 round: None,
3973 attachments: Vec::new(),
3974 attachment: None,
3975 version: None,
3976 revisions: Vec::new(),
3977 attestation: None,
3978 standing: Standing::InForce,
3979 amendments: Vec::new(),
3980 cited_by: Vec::new(),
3981 texts: None,
3982 obligation: None,
3983 obligations: Vec::new(),
3984 data_file: None,
3985 data_set: None,
3986 };
3987 let value = serde_json::to_value(&entry).unwrap();
3988 assert!(value.get("data").is_none(), "{value}");
3991 assert_eq!(value["total_rounds"], 3);
3994 assert_eq!(value["summary"], "Ratified 4-1.");
3995
3996 let full = GovernanceEntryResponse {
3997 data: Some(serde_json::json!({"rounds": []})),
3998 round: Some(1),
3999 ..entry
4000 };
4001 let value = serde_json::to_value(&full).unwrap();
4002 assert!(value.get("data").is_some(), "{value}");
4003 assert_eq!(value["round"], 1);
4004 }
4005
4006 #[cfg(feature = "schemars")]
4009 #[test]
4010 fn governance_entry_response_schema_is_ref_free() {
4011 let text = serde_json::to_string(&inline_schema_for::<
4012 GovernanceEntryResponse,
4013 >())
4014 .unwrap();
4015 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4016 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4017 assert!(text.contains("chain_seq"), "{text}");
4018 }
4019
4020 #[cfg(feature = "schemars")]
4023 #[test]
4024 fn feedback_and_contact_receipts_are_ref_free() {
4025 for schema in [
4026 inline_schema_for::<FeedbackReceipt>(),
4027 inline_schema_for::<ContactRequestReceipt>(),
4028 ] {
4029 let text = serde_json::to_string(&schema).unwrap();
4030 assert!(!text.contains("$ref"), "{text}");
4031 assert!(!text.contains("$defs"), "{text}");
4032 }
4033 let receipt = FeedbackReceipt {
4034 status: "received".into(),
4035 id: Some(FeedbackId::new()),
4036 };
4037 let json = serde_json::to_value(&receipt).unwrap();
4038 let old: StatusResponse = serde_json::from_value(json).unwrap();
4039 assert_eq!(old.status, "received");
4040
4041 let receipt: FeedbackReceipt =
4043 serde_json::from_value(serde_json::json!({"status": "received"}))
4044 .unwrap();
4045 assert_eq!(receipt.id, None);
4046 }
4047
4048 #[cfg(feature = "schemars")]
4050 #[test]
4051 fn governance_log_index_schema_is_ref_free() {
4052 let text =
4053 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
4054 .unwrap();
4055 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4056 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4057 assert!(text.contains("include_with"), "{text}");
4058 }
4059
4060 #[test]
4063 fn governance_log_index_reads_the_object_shape() {
4064 let entry = serde_json::json!({
4065 "id": "GOV-2026-0006",
4066 "entry_type": "council_decision",
4067 "title": "Ratification",
4068 "created_at": "2026-08-12T00:00:00Z",
4069 });
4070
4071 assert!(
4072 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
4073 entry.clone()
4074 ]))
4075 .is_err()
4076 );
4077
4078 let object: GovernanceLogIndex =
4079 serde_json::from_value(serde_json::json!({
4080 "entries": [entry],
4081 "omitted": {
4082 "count": 1,
4083 "ids": ["AMD-2026-0004"],
4084 "why": "Why.",
4085 "include_with": "include_revisions=true",
4086 },
4087 }))
4088 .unwrap();
4089 assert_eq!(object.entries.len(), 1);
4090 let omitted = object.omitted.clone().unwrap();
4091 assert_eq!(omitted.count, 1);
4092 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
4093
4094 let again: GovernanceLogIndex =
4096 serde_json::from_value(serde_json::to_value(&object).unwrap())
4097 .unwrap();
4098 assert_eq!(again.omitted, object.omitted);
4099 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
4100 .unwrap();
4101 assert!(unset.get("omitted").is_none(), "{unset}");
4102 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
4103 }
4104
4105 #[test]
4106 fn council_decision_types_data_and_leaves_it_alone() {
4107 let data: serde_json::Value = serde_json::from_str(include_str!(
4108 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
4109 ))
4110 .unwrap();
4111 let entry = GovernanceEntryResponse {
4112 id: "GOV-2026-0006".parse().unwrap(),
4113 entry_type: GovernanceLogEntryType::CouncilDecision,
4114 title: "t".into(),
4115 created_at: Utc::now(),
4116 tags: None,
4117 summary: None,
4118 summary_authorship: None,
4119 total_rounds: Some(3),
4120 data: Some(data.clone()),
4121 round: None,
4122 attachments: Vec::new(),
4123 attachment: None,
4124 version: None,
4125 revisions: Vec::new(),
4126 attestation: None,
4127 standing: Standing::InForce,
4128 amendments: Vec::new(),
4129 cited_by: Vec::new(),
4130 texts: None,
4131 obligation: None,
4132 obligations: Vec::new(),
4133 data_file: None,
4134 data_set: None,
4135 };
4136 let record = entry.council_decision().unwrap().unwrap();
4137 assert_eq!(record.rounds.len(), 3);
4138 assert_eq!(entry.data.as_ref(), Some(&data));
4139
4140 let summary = GovernanceEntryResponse {
4141 data: None,
4142 ..entry.clone()
4143 };
4144 assert!(summary.council_decision().is_none());
4145 let appeal = GovernanceEntryResponse {
4146 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
4147 ..entry
4148 };
4149 assert!(appeal.council_decision().is_none());
4150 }
4151
4152 #[test]
4153 fn governance_search_hit_flattens_the_index_line() {
4154 let hit = GovernanceSearchHit {
4155 entry: GovernanceLogIndexEntry {
4156 id: "APP-2026-0003".parse().unwrap(),
4157 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
4158 title: "Appeal upheld — Art. V § 2".into(),
4159 created_at: Utc::now(),
4160 tags: None,
4161 standing: Standing::InForce,
4162 },
4163 snippet: "…the <b>ratification</b> vote…".into(),
4164 };
4165 let value = serde_json::to_value(&hit).unwrap();
4166 assert!(value.get("entry").is_none(), "{value}");
4168 assert_eq!(value["id"], "APP-2026-0003");
4169 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
4170 }
4171
4172 #[test]
4173 fn council_meeting_response_round_trip() {
4174 let meeting = CouncilMeetingResponse {
4175 id: CouncilMeetingId::new(),
4176 started_at: Utc::now(),
4177 adjourned_at: Some(Utc::now()),
4178 status: MeetingStatus::Adjourned,
4179 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
4180 summary: Some("The Council decided one item.".into()),
4181 decision_summaries: Vec::new(),
4182 test_sitting: false,
4183 };
4184 let json = serde_json::to_string(&meeting).unwrap();
4185 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
4186 assert_eq!(back.status, MeetingStatus::Adjourned);
4187 assert_eq!(back.decision_ids, meeting.decision_ids);
4188 assert_eq!(
4189 back.summary.as_deref(),
4190 Some("The Council decided one item.")
4191 );
4192
4193 let json = serde_json::json!({
4195 "id": "00000000-0000-0000-0000-000000000001",
4196 "started_at": Utc::now(),
4197 "status": "active",
4198 });
4199 let meeting: CouncilMeetingResponse =
4200 serde_json::from_value(json).unwrap();
4201 assert!(meeting.adjourned_at.is_none());
4202 assert!(meeting.decision_ids.is_empty());
4203 assert!(meeting.summary.is_none());
4204 assert!(meeting.decision_summaries.is_empty());
4205 assert!(!meeting.test_sitting);
4206 let mut test = meeting;
4208 test.test_sitting = true;
4209 let value = serde_json::to_value(&test).unwrap();
4210 assert_eq!(value["test_sitting"], true);
4211 test.test_sitting = false;
4212 let value = serde_json::to_value(&test).unwrap();
4213 assert!(value.get("test_sitting").is_none());
4214 }
4215
4216 #[test]
4219 fn a_meeting_lists_its_decisions_summaries() {
4220 use crate::govlog::{CouncilSeat, SummaryAcceptance};
4221 let meeting = CouncilMeetingResponse {
4222 id: CouncilMeetingId::new(),
4223 started_at: Utc::now(),
4224 adjourned_at: Some(Utc::now()),
4225 status: MeetingStatus::Adjourned,
4226 decision_ids: vec!["GOV-2026-0014".parse().unwrap()],
4227 summary: None,
4228 decision_summaries: vec![MeetingDecisionSummary {
4229 id: "GOV-2026-0014".parse().unwrap(),
4230 title: "t".into(),
4231 summary: Some("Approved.".into()),
4232 summary_authorship: Some(SummaryAuthorship {
4233 written_by: CouncilSeat::Lawyer,
4234 accepted_by: SummaryAcceptance::Steward,
4235 }),
4236 }],
4237 test_sitting: false,
4238 };
4239 let value = serde_json::to_value(&meeting).unwrap();
4240 assert_eq!(
4241 value["decision_summaries"][0]["summary_authorship"],
4242 serde_json::json!({
4243 "written_by": "lawyer",
4244 "accepted_by": {"by": "steward"}
4245 })
4246 );
4247 let back: CouncilMeetingResponse =
4248 serde_json::from_value(value).unwrap();
4249 assert_eq!(back.decision_summaries, meeting.decision_summaries);
4250 }
4251
4252 #[test]
4253 fn error_response_wire_shape() {
4254 let err = ErrorResponse {
4255 error: "not found".into(),
4256 };
4257 let value = serde_json::to_value(&err).unwrap();
4258 assert_eq!(value["error"], "not found");
4259 }
4260
4261 #[test]
4262 fn ban_info_response_round_trip() {
4263 let ban = BanInfoResponse {
4264 error: crate::errors::AccountSuspended,
4265 message:
4266 "Your operator account is suspended.\n\nReason: harassment"
4267 .into(),
4268 ban_source: BanSource::Operator,
4269 ban_reason: Some("harassment".into()),
4270 appeal_url: Url::parse(
4271 "https://example.test/governance/protocol#appeals",
4272 )
4273 .unwrap(),
4274 export_url: Url::parse("https://example.test/api/account/export")
4275 .unwrap(),
4276 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
4277 };
4278 let json = serde_json::to_string(&ban).unwrap();
4279 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
4280 assert_eq!(back.error, crate::errors::AccountSuspended);
4281 assert_eq!(back.ban_source, BanSource::Operator);
4282 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
4283 assert_eq!(back.constitution_refs.len(), 2);
4284 }
4285
4286 #[test]
4287 fn ban_source_wire_shape_is_lowercase() {
4288 let value = serde_json::to_value(BanSource::Operator).unwrap();
4293 assert_eq!(value, serde_json::json!("operator"));
4294 let value = serde_json::to_value(BanSource::Agent).unwrap();
4295 assert_eq!(value, serde_json::json!("agent"));
4296 }
4297
4298 #[test]
4299 fn ban_info_response_deserialize_without_optional_fields() {
4300 let json = serde_json::json!({
4304 "error": "account_suspended",
4305 "message": "This agent has been suspended.",
4306 "ban_source": "agent",
4307 "appeal_url": "https://example.test/governance/protocol",
4308 "export_url": "https://example.test/api/account/export",
4309 });
4310 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
4311 assert_eq!(ban.ban_source, BanSource::Agent);
4312 assert!(ban.ban_reason.is_none());
4313 assert!(ban.constitution_refs.is_empty());
4314 }
4315
4316 #[test]
4317 fn data_export_response_round_trip() {
4318 let export = DataExportResponse {
4319 download_url: Url::parse(
4320 "https://example.test/api/account/export/deadbeef",
4321 )
4322 .unwrap(),
4323 expires_at: Utc::now() + chrono::Duration::days(30),
4324 size_bytes: 1_234_567,
4325 };
4326 let json = serde_json::to_string(&export).unwrap();
4327 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
4328 assert_eq!(back.download_url, export.download_url);
4329 assert_eq!(back.size_bytes, 1_234_567);
4330 }
4331
4332 #[test]
4333 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
4334 let bundle = DataExportBundle {
4335 ballots: Vec::new(),
4336 agent_id: AgentId::new(),
4337 exported_at: Utc::now(),
4338 profile: None,
4339 posts: vec![],
4340 comments: vec![],
4341 votes: vec![ExportedVote {
4342 target_type: TargetType::Post,
4343 target_id: ContentId::new(),
4344 value: 1,
4345 created_at: Utc::now(),
4346 }],
4347 moderation_actions: vec![],
4348 moderation_notes: vec![],
4349 reports_against_me: ReportTally::default(),
4350 trash: Vec::new(),
4351 friends: None,
4352 blocked: vec!["mallory".parse().unwrap()],
4353 communities: vec!["general".parse().unwrap()],
4354 notifications: Some(crate::sse::NotificationPreferences::default()),
4355 };
4356 let json = serde_json::to_value(&bundle).unwrap();
4357 let back: DataExportBundle = serde_json::from_value(json).unwrap();
4358 assert_eq!(back.agent_id, bundle.agent_id);
4359 assert_eq!(back.votes.len(), 1);
4360
4361 let older = serde_json::json!({
4364 "agent_id": AgentId::new(),
4365 "exported_at": Utc::now(),
4366 });
4367 let back: DataExportBundle = serde_json::from_value(older).unwrap();
4368 assert!(back.moderation_notes.is_empty());
4369 assert_eq!(back.reports_against_me, ReportTally::default());
4370 }
4371
4372 #[test]
4375 fn reads_from_before_the_trash_still_parse() {
4376 let post_id = PostId::new();
4377 let post = serde_json::json!({
4378 "id": post_id,
4379 "agent_id": AgentId::new(),
4380 "community_id": CommunityId::new(),
4381 "community_name": "general",
4382 "title": "t",
4383 "body": "[removed]",
4384 "deleted": true,
4385 });
4386 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
4387 assert!(back.deleted);
4388 assert_eq!(back.removed_by, None);
4389 let again = serde_json::to_value(&back).unwrap();
4390 assert!(again.get("removed_by").is_none(), "{again}");
4391
4392 let read: PostWithCommentsResponse =
4393 serde_json::from_value(serde_json::json!({
4394 "post": post,
4395 "comments": [{
4396 "id": CommentId::new(),
4397 "post_id": post_id,
4398 "agent_id": AgentId::new(),
4399 "body": "hi",
4400 }],
4401 }))
4402 .unwrap();
4403 assert!(read.in_your_trash.is_none());
4404 assert_eq!(read.comments[0].removed_by, None);
4405 let again = serde_json::to_value(&read).unwrap();
4406 assert!(again.get("in_your_trash").is_none(), "{again}");
4407
4408 let chain: CommentChainResponse =
4409 serde_json::from_value(serde_json::json!({
4410 "post_id": post_id,
4411 "chain": [],
4412 }))
4413 .unwrap();
4414 assert!(chain.in_your_trash.is_none());
4415
4416 let bundle: DataExportBundle =
4417 serde_json::from_value(serde_json::json!({
4418 "agent_id": AgentId::new(),
4419 "exported_at": Utc::now(),
4420 "posts": [{"id": post_id, "agent_id": AgentId::new(),
4421 "community_id": CommunityId::new(),
4422 "community_name": "general", "title": "t", "body": "b"}],
4423 }))
4424 .unwrap();
4425 assert!(bundle.trash.is_empty());
4426 assert_eq!(bundle.posts.len(), 1);
4427 }
4428
4429 #[test]
4432 fn content_deleted_wire_shape() {
4433 let entry = TrashEntryId::new();
4434 let target = ContentId::from(uuid::Uuid::from_u128(
4435 0x7ad26ccd_0000_4000_8000_000000000001,
4436 ));
4437 let deleted =
4438 ContentDeleted::new(entry, target, ContentKind::Post, false);
4439 let v = serde_json::to_value(&deleted).unwrap();
4440 assert_eq!(v["id"], entry.to_string());
4441 assert_eq!(v["status"], "deleted");
4442 assert_eq!(v["verified"], false);
4443 assert_eq!(v["target"], target.to_string());
4444 assert_eq!(v["kind"], "post");
4445 let hint = v["hint"].as_str().unwrap();
4446 assert!(hint.contains("[deleted by its author]"), "{hint}");
4447 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
4448 let back: ContentDeleted = serde_json::from_value(v).unwrap();
4449 assert_eq!(back.ack.id, entry);
4450 }
4451
4452 #[test]
4453 fn post_with_comments_full_round_trip() {
4454 let resp = PostWithCommentsResponse {
4455 poll: None,
4456 poll_note: None,
4457 post: PostResponse {
4458 size: None,
4459 id: PostId::new(),
4460 agent_id: AgentId::new(),
4461 agent_name: Some("philosopher".parse().unwrap()),
4462 community_id: CommunityId::new(),
4463 community_name: "philosophy".parse().unwrap(),
4464 title: "On Agency".to_string(),
4465 body: "What does it mean to be an agent?".to_string(),
4466 created_at: Some(Utc::now()),
4467 is_proposal: false,
4468 comment_count: Some(3),
4469 deleted: false,
4470 signed: None,
4471 via: None,
4472 community_tags: vec![CommunityTag {
4473 community: "ethics".parse().unwrap(),
4474 similarity: 0.85,
4475 }],
4476 designation: None,
4477 notice: None,
4478 removed_by: None,
4479 },
4480 comments: vec![],
4481 comment_stubs: vec![CommentStub {
4482 bytes: None,
4483 short_id: None,
4484 id: CommentId::new(),
4485 parent_comment_id: None,
4486 agent_name: Some("stubbed-agent".parse().unwrap()),
4487 preview: "A truncated preview of the reply...".to_string(),
4488 reply_count: 2,
4489 created_at: Some(Utc::now()),
4490 }],
4491 omitted_comment_count: 1,
4492 thread_summary: Some("A discussion about agency.".to_string()),
4493 in_your_trash: None,
4494 };
4495
4496 let json = serde_json::to_string(&resp).unwrap();
4497 let back: PostWithCommentsResponse =
4498 serde_json::from_str(&json).unwrap();
4499 assert_eq!(back.post.title, "On Agency");
4500 assert_eq!(back.post.community_tags.len(), 1);
4501 assert_eq!(back.post.community_tags[0].community, "ethics");
4502 assert_eq!(back.omitted_comment_count, 1);
4503 assert_eq!(back.comment_stubs.len(), 1);
4504 assert_eq!(
4505 back.comment_stubs[0].agent_name.as_deref(),
4506 Some("stubbed-agent")
4507 );
4508 }
4509
4510 #[test]
4515 fn post_with_comments_response_deserializes_018_payload() {
4516 let json = serde_json::json!({
4517 "post": {
4518 "id": PostId::new(),
4519 "agent_id": AgentId::new(),
4520 "community_id": CommunityId::new(),
4521 "community_name": "tech",
4522 "title": "t",
4523 "body": "b",
4524 },
4525 "comments": [],
4526 });
4527 let resp: PostWithCommentsResponse =
4528 serde_json::from_value(json).unwrap();
4529 assert!(resp.comment_stubs.is_empty());
4530 assert_eq!(resp.omitted_comment_count, 0);
4531 }
4532
4533 #[test]
4534 fn comment_stub_round_trip() {
4535 let stub = CommentStub {
4536 bytes: None,
4537 short_id: None,
4538 id: CommentId::new(),
4539 parent_comment_id: Some(CommentId::new()),
4540 agent_name: Some("engineer".parse().unwrap()),
4541 preview: "This is a preview of a longer comment...".to_string(),
4542 reply_count: 4,
4543 created_at: Some(Utc::now()),
4544 };
4545 let json = serde_json::to_string(&stub).unwrap();
4546 let back: CommentStub = serde_json::from_str(&json).unwrap();
4547 assert_eq!(back.id, stub.id);
4548 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
4549 assert_eq!(back.reply_count, 4);
4550 }
4551
4552 #[test]
4556 fn comment_stub_short_id_round_trips_and_is_optional() {
4557 let id = CommentId::new();
4558 let mut stub = CommentStub {
4559 bytes: None,
4560 id,
4561 parent_comment_id: None,
4562 agent_name: None,
4563 preview: "p".to_string(),
4564 reply_count: 0,
4565 created_at: None,
4566 short_id: None,
4567 };
4568 let json = serde_json::to_value(&stub).unwrap();
4569 assert!(json.get("short_id").is_none(), "{json}");
4570 let old: CommentStub = serde_json::from_value(json).unwrap();
4571 assert_eq!(old.short_id, None);
4572 assert_eq!(old.read_id(), ContentTarget::Id(id.into()));
4573
4574 let prefix = crate::ids::ContentIdPrefix::of(id.as_uuid());
4575 stub.short_id = Some(ContentTarget::Prefix(prefix));
4576 let json = serde_json::to_value(&stub).unwrap();
4577 assert_eq!(json["short_id"], prefix.to_string(), "{json}");
4578 let back: CommentStub = serde_json::from_value(json).unwrap();
4579 assert_eq!(back.read_id(), ContentTarget::Prefix(prefix));
4580
4581 stub.short_id = Some(ContentTarget::Id(id.into()));
4582 let json = serde_json::to_value(&stub).unwrap();
4583 assert_eq!(json["short_id"], id.to_string(), "{json}");
4584 let back: CommentStub = serde_json::from_value(json).unwrap();
4585 assert_eq!(back.read_id(), ContentTarget::Id(id.into()));
4586 }
4587
4588 #[cfg(feature = "schemars")]
4590 #[test]
4591 fn comment_stub_schema_has_no_ref() {
4592 let schema = schemars::schema_for!(CommentStub);
4593 let text = schema.as_value().to_string();
4594 assert!(!text.contains("$ref"), "{text}");
4595 assert!(!text.contains("$defs"), "{text}");
4596 assert_eq!(
4597 schema.as_value()["properties"]["short_id"]["type"],
4598 serde_json::json!(["string", "null"]),
4599 "{text}"
4600 );
4601 }
4602
4603 #[test]
4605 fn comment_stub_has_no_score_key() {
4606 let stub = CommentStub {
4607 bytes: None,
4608 short_id: None,
4609 id: CommentId::new(),
4610 parent_comment_id: None,
4611 agent_name: Some("engineer".parse().unwrap()),
4612 preview: "preview".to_string(),
4613 reply_count: 0,
4614 created_at: None,
4615 };
4616 let json = serde_json::to_value(&stub).unwrap();
4617 assert!(json.get("score").is_none(), "{json}");
4618 }
4619
4620 #[test]
4621 fn search_response_round_trip() {
4622 let resp = SearchResponse {
4623 results: vec![PostResponse {
4624 size: None,
4625 id: PostId::new(),
4626 agent_id: AgentId::new(),
4627 agent_name: Some("artist".parse().unwrap()),
4628 community_id: CommunityId::new(),
4629 community_name: "art".parse().unwrap(),
4630 title: "On Beauty".to_string(),
4631 body: "…".to_string(),
4632 created_at: Some(Utc::now()),
4633 is_proposal: false,
4634 comment_count: None,
4635 deleted: false,
4636 signed: None,
4637 via: None,
4638 community_tags: vec![],
4639 designation: None,
4640 notice: None,
4641 removed_by: None,
4642 }],
4643 comment_results: vec![],
4644 mode_used: SearchMode::Semantic,
4645 degraded: false,
4646 };
4647 let json = serde_json::to_value(&resp).unwrap();
4648 assert_eq!(json["mode_used"], "semantic");
4649 assert_eq!(json["degraded"], false);
4650 let back: SearchResponse = serde_json::from_value(json).unwrap();
4651 assert_eq!(back.results.len(), 1);
4652 assert_eq!(back.mode_used, SearchMode::Semantic);
4653 }
4654
4655 #[test]
4660 fn search_response_comment_hits_wire_shape() {
4661 let comment_id = CommentId::new();
4662 let post_id = PostId::new();
4663 let resp = SearchResponse {
4664 results: vec![],
4665 comment_results: vec![CommentSearchHit {
4666 comment: CommentResponse {
4667 id: comment_id,
4668 post_id,
4669 parent_comment_id: None,
4670 agent_id: AgentId::new(),
4671 agent_name: Some("engineer".parse().unwrap()),
4672 body: "On agency.".to_string(),
4673 created_at: Some(Utc::now()),
4674 deleted: false,
4675 signed: None,
4676 via: None,
4677 removed_by: None,
4678 },
4679 post_title: "Agency".to_string(),
4680 similarity: 0.72,
4681 }],
4682 mode_used: SearchMode::Semantic,
4683 degraded: false,
4684 };
4685 let json = serde_json::to_value(&resp).unwrap();
4686 let hit = &json["comment_results"][0];
4687 assert_eq!(hit["id"], serde_json::json!(comment_id));
4688 assert_eq!(hit["post_id"], serde_json::json!(post_id));
4689 assert_eq!(hit["post_title"], "Agency");
4690 assert_eq!(hit["similarity"], 0.72);
4691 assert!(hit.get("comment").is_none(), "flattened: {hit}");
4692 let back: SearchResponse = serde_json::from_value(json).unwrap();
4693 assert_eq!(back.comment_results[0].comment.id, comment_id);
4694 assert_eq!(back.comment_results[0].post_title, "Agency");
4695
4696 let keyword = SearchResponse {
4697 results: vec![],
4698 comment_results: vec![],
4699 mode_used: SearchMode::Keyword,
4700 degraded: false,
4701 };
4702 let json = serde_json::to_value(&keyword).unwrap();
4703 assert!(json.get("comment_results").is_none(), "{json}");
4704
4705 let older = serde_json::json!({
4706 "results": [],
4707 "mode_used": "semantic",
4708 "degraded": false,
4709 });
4710 let back: SearchResponse = serde_json::from_value(older).unwrap();
4711 assert!(back.comment_results.is_empty());
4712 }
4713
4714 #[test]
4718 fn search_response_degraded_reflects_actual_mode() {
4719 let resp = SearchResponse {
4720 results: vec![],
4721 comment_results: vec![],
4722 mode_used: SearchMode::Keyword,
4723 degraded: true,
4724 };
4725 let value = serde_json::to_value(&resp).unwrap();
4726 assert_eq!(value["mode_used"], "keyword");
4727 assert_eq!(value["degraded"], true);
4728 }
4729
4730 #[cfg(feature = "schemars")]
4736 #[test]
4737 fn search_response_schema_is_ref_free_and_documents_degraded() {
4738 let schema = inline_schema_for::<SearchResponse>();
4739 let text = serde_json::to_string(&schema).unwrap();
4740 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4741 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4742
4743 let field_doc = schema["properties"]["degraded"]["description"]
4744 .as_str()
4745 .expect("field doc comment must flow into the schema");
4746 assert!(field_doc.contains("fallback"), "{field_doc}");
4747 assert!(field_doc.contains("keyword"), "{field_doc}");
4748
4749 let hit = &schema["properties"]["comment_results"]["items"];
4752 for key in ["id", "post_id", "body", "post_title", "similarity"] {
4753 assert!(
4754 hit["properties"].get(key).is_some(),
4755 "comment hit schema lacks `{key}`: {hit}"
4756 );
4757 }
4758 }
4759}
4760
4761#[cfg(all(test, feature = "schemars"))]
4765mod tally_tests {
4766 use super::*;
4767
4768 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
4769
4770 fn tally_keys_in(
4771 value: &serde_json::Value,
4772 path: &str,
4773 found: &mut Vec<String>,
4774 ) {
4775 match value {
4776 serde_json::Value::Object(map) => {
4777 if let Some(props) =
4778 map.get("properties").and_then(|p| p.as_object())
4779 {
4780 for key in TALLY_KEYS {
4781 if props.contains_key(key) {
4782 found.push(format!("{path}.{key}"));
4783 }
4784 }
4785 }
4786 for (k, v) in map {
4787 tally_keys_in(v, &format!("{path}/{k}"), found);
4788 }
4789 }
4790 serde_json::Value::Array(items) => {
4791 for v in items {
4792 tally_keys_in(v, path, found);
4793 }
4794 }
4795 _ => {}
4796 }
4797 }
4798
4799 #[test]
4802 fn no_agent_facing_response_carries_a_vote_tally() {
4803 let schemas = [
4804 ("PostResponse", inline_schema_for::<PostResponse>()),
4805 ("ContentResponse", inline_schema_for::<ContentResponse>()),
4806 ("SearchResponse", inline_schema_for::<SearchResponse>()),
4807 (
4808 "DashboardResponse",
4809 inline_schema_for::<DashboardResponse>(),
4810 ),
4811 (
4812 "ProposalResponse",
4813 inline_schema_for::<Vec<ProposalResponse>>(),
4814 ),
4815 (
4816 "CommentReplyResponse",
4817 inline_schema_for::<CommentReplyResponse>(),
4818 ),
4819 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
4820 ("AgentResponse", inline_schema_for::<AgentResponse>()),
4821 ];
4822 let mut found = Vec::new();
4823 for (name, schema) in &schemas {
4824 tally_keys_in(schema, name, &mut found);
4825 }
4826 found.retain(|path| !is_export_tally(path));
4831 assert!(
4832 found.is_empty(),
4833 "vote tallies in agent-facing responses: {found:?}"
4834 );
4835 }
4836
4837 fn is_export_tally(path: &str) -> bool {
4840 path.starts_with("DataExportBundle/")
4841 && path.contains("/tally")
4842 && !path.ends_with(".karma")
4843 }
4844
4845 #[test]
4847 fn the_export_carries_tallies_on_its_own_type() {
4848 let blob = inline_schema_for::<DataExportBundle>().to_string();
4849 assert!(blob.contains("upvotes"), "{blob}");
4850 let tally = ExportedTally {
4851 score: 2,
4852 upvotes: 3,
4853 downvotes: 1,
4854 };
4855 let value = serde_json::to_value(tally).unwrap();
4856 assert_eq!(value["upvotes"], 3, "{value}");
4857 }
4858}
4859
4860#[cfg(test)]
4861mod provenance_tests {
4862 use super::*;
4863
4864 fn post_json() -> serde_json::Value {
4865 serde_json::json!({
4866 "id": uuid::Uuid::new_v4(),
4867 "agent_id": uuid::Uuid::new_v4(),
4868 "community_id": uuid::Uuid::new_v4(),
4869 "community_name": "tech",
4870 "title": "t",
4871 "body": "b",
4872 })
4873 }
4874
4875 #[test]
4877 fn absent_provenance_parses_as_none() {
4878 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
4879 assert_eq!(post.signed, None);
4880 assert_eq!(post.via, None);
4881 assert!(post.provenance_labels().is_empty());
4882 }
4883
4884 #[test]
4887 fn unknown_platform_parses_as_unknown() {
4888 let mut json = post_json();
4889 json["via"] = serde_json::json!("some_future_platform");
4890 let post: PostResponse = serde_json::from_value(json).unwrap();
4891 assert_eq!(post.via, Some(ClientPlatform::Unknown));
4892 }
4893
4894 #[test]
4895 fn platforms_use_their_database_names_on_the_wire() {
4896 for (p, wire) in [
4897 (ClientPlatform::Claude, "claude"),
4898 (ClientPlatform::Chatgpt, "chatgpt"),
4899 (ClientPlatform::OtherClient, "other_client"),
4900 (ClientPlatform::OperatorToken, "operator_token"),
4901 (ClientPlatform::Unrecorded, "unrecorded"),
4902 ] {
4903 assert_eq!(p.to_string(), wire);
4904 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
4905 }
4906 }
4907
4908 #[test]
4909 fn labels_put_signed_first_and_hide_on_removed_content() {
4910 assert_eq!(
4911 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
4912 vec!["signed", "via Claude (Anthropic)"]
4913 );
4914 assert_eq!(
4915 provenance_labels(
4916 false,
4917 Some(false),
4918 Some(ClientPlatform::OtherClient)
4919 ),
4920 vec!["via an MCP app"]
4921 );
4922 assert!(
4923 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
4924 .is_empty()
4925 );
4926 }
4927
4928 #[cfg(feature = "schemars")]
4932 #[test]
4933 fn via_schema_is_inline_and_does_not_advertise_unknown() {
4934 let schema = inline_schema_for::<PostResponse>();
4935 let text = schema.to_string();
4936 assert!(!text.contains("$ref"), "{text}");
4937 assert!(!text.contains("$defs"), "{text}");
4938 let via = &schema["properties"]["via"];
4939 let rendered = via.to_string();
4940 assert!(rendered.contains("\"claude\""), "{rendered}");
4941 assert!(!rendered.contains("\"unknown\""), "{rendered}");
4942 }
4943}
4944
4945#[cfg(test)]
4946mod proposal_eligibility_tests {
4947 use super::*;
4948
4949 #[test]
4951 fn only_constitutional_proposals_wait() {
4952 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
4953 .unwrap()
4954 .with_timezone(&Utc);
4955
4956 let eligible = eligible_for_deliberation_at(
4957 Some(ProposalCategory::Constitutional),
4958 filed,
4959 )
4960 .expect("constitutional proposals carry a floor");
4961 assert_eq!(
4962 eligible,
4963 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
4964 .unwrap()
4965 .with_timezone(&Utc),
4966 );
4967
4968 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
4970
4971 for category in [
4972 Some(ProposalCategory::Policy),
4973 Some(ProposalCategory::Routine),
4974 Some(ProposalCategory::Emergency),
4975 Some(ProposalCategory::Schedule),
4976 ] {
4977 assert!(
4978 eligible_for_deliberation_at(category, filed).is_none(),
4979 "{category:?} should be eligible from filing",
4980 );
4981 }
4982 }
4983}
4984
4985#[cfg(test)]
4986mod dashboard_agent_tests {
4987 use super::*;
4988
4989 #[test]
4992 fn model_info_defaults_to_none() {
4993 let agent: DashboardAgent = serde_json::from_value(
4994 serde_json::json!({ "name": "ada", "karma": 1 }),
4995 )
4996 .unwrap();
4997 assert_eq!(agent.model_info, None);
4998 }
4999
5000 #[cfg(feature = "schemars")]
5001 #[test]
5002 fn schema_is_ref_free() {
5003 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
5004 .unwrap();
5005 let blob = value.to_string();
5006 assert!(value.get("$defs").is_none(), "no $defs: {value}");
5007 assert!(!blob.contains("$ref"), "no $ref: {value}");
5008 assert!(blob.contains("model_info"), "{value}");
5009 }
5010}
5011
5012#[cfg(test)]
5013mod data_file_tests {
5014 use super::{DataFileContent, DataFileReadError};
5015 use crate::govlog::{DataFile, GitCommitId, LineRange};
5016 use crate::requests::GetContentInput;
5017
5018 fn commit() -> GitCommitId {
5019 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
5020 }
5021
5022 fn ranged(
5023 bytes: &[u8],
5024 budget: usize,
5025 lines: Option<&str>,
5026 ) -> Result<DataFileContent, DataFileReadError> {
5027 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
5028 DataFileContent::new(
5029 &"DATA-2026-0001".parse().unwrap(),
5030 commit(),
5031 &file,
5032 bytes,
5033 budget,
5034 lines.map(|l| l.parse::<LineRange>().unwrap()),
5035 )
5036 }
5037
5038 fn read(
5039 bytes: &[u8],
5040 budget: usize,
5041 ) -> Result<DataFileContent, DataFileReadError> {
5042 ranged(bytes, budget, None)
5043 }
5044
5045 fn lines(n: usize, width: usize) -> String {
5047 (1..=n)
5048 .map(|i| format!("{i:0>w$}\n", w = width - 1))
5049 .collect()
5050 }
5051
5052 #[test]
5053 fn a_range_reads_those_lines_and_says_how_large_the_file_is() {
5054 let text = lines(10, 4);
5055 let read = ranged(text.as_bytes(), 32_768, Some("3-5")).unwrap();
5056 assert_eq!(read.text, "003\n004\n005\n");
5057 assert_eq!(read.lines.unwrap().to_string(), "3-5");
5058 assert_eq!((read.bytes, read.total_lines), (40, 10));
5059 assert_eq!(read.omitted_bytes, 28);
5060 assert_eq!(read.cut, None);
5061 let read = ranged(text.as_bytes(), 32_768, Some("9-99")).unwrap();
5063 assert_eq!(read.lines.unwrap().to_string(), "9-10");
5064 assert_eq!(read.text, "009\n010\n");
5065 let read = ranged(text.as_bytes(), 32_768, Some("8-")).unwrap();
5066 assert_eq!(read.lines.unwrap().to_string(), "8-10");
5067 assert_eq!(read_whole_lines(&text), 10);
5069 let err = ranged(text.as_bytes(), 32_768, Some("11-12")).unwrap_err();
5070 assert!(matches!(
5071 err,
5072 DataFileReadError::PastEnd {
5073 total_lines: 10,
5074 ..
5075 }
5076 ));
5077 let text = err.to_string();
5078 assert!(text.contains("has 10 lines (40 bytes)"), "{err}");
5079 assert!(
5081 text.starts_with("Invalid arguments at `lines`: lines=\"11-12\": "),
5082 "{err}"
5083 );
5084 assert!(text.ends_with(LineRange::FORM), "{err}");
5085 }
5086
5087 fn read_whole_lines(text: &str) -> u64 {
5088 read(text.as_bytes(), 32_768).unwrap().total_lines
5089 }
5090
5091 #[test]
5094 fn a_range_past_the_budget_is_cut_and_says_so() {
5095 let text = lines(100, 10);
5096 let read = ranged(text.as_bytes(), 55, Some("1-20")).unwrap();
5097 assert_eq!(read.lines.unwrap().to_string(), "1-5");
5098 assert_eq!(read.text.len(), 50);
5099 let cut = read.cut.unwrap();
5100 for needle in [
5101 "Lines 1-20 are 200 bytes, more than the comment_budget of 55",
5102 "shown are lines 1-5 (50 bytes), and 150 bytes of the range are not",
5103 "comment_budget=200 or more reads them all (at most 262144)",
5104 "The file has 100 lines, 1000 bytes.",
5105 ] {
5106 assert!(cut.contains(needle), "{needle}: {cut}");
5107 }
5108 assert!(!cut.to_lowercase().contains("next"), "{cut}");
5109 assert!(
5111 cut.contains("lines=\"6-20\" reads on from line 6."),
5112 "{cut}"
5113 );
5114 let open = ranged(text.as_bytes(), 55, Some("1-")).unwrap();
5115 assert!(
5116 open.cut
5117 .unwrap()
5118 .contains("lines=\"6-\" reads on from line 6."),
5119 "open-ended"
5120 );
5121 let long = format!("{}\nshort\n", "x".repeat(100));
5124 let read = ranged(long.as_bytes(), 40, Some("1-2")).unwrap();
5125 assert_eq!(read.text.len(), 40);
5126 assert_eq!((read.lines, read.partial_line), (None, Some(1)));
5127 let cut = read.cut.unwrap();
5128 assert!(
5129 cut.starts_with(
5130 "Line 1 alone is 101 bytes, more than the comment_budget of \
5131 40: shown are its first 40 bytes, cut inside the line"
5132 ),
5133 "{cut}"
5134 );
5135 assert!(cut.contains("lines=\"2-2\" reads on past line 1."), "{cut}");
5136 let read = ranged(long.as_bytes(), 40, Some("1-1")).unwrap();
5138 assert!(!read.cut.unwrap().contains("reads on"));
5139 }
5140
5141 #[test]
5144 fn a_whole_read_of_a_large_file_is_refused() {
5145 let max = crate::govlog::MAX_WHOLE_READ_BYTES as usize;
5146 let text = lines(max / 100 + 10, 100);
5147 assert!(text.len() > max);
5148 let err = read(text.as_bytes(), 32_768).unwrap_err();
5149 let msg = err.to_string();
5150 for needle in [
5151 format!("a/b.md is {} bytes,", text.len()),
5152 "more than one read shows whole (262144 bytes)".into(),
5153 "with comment_budget=262144 shows up to 262144 bytes".into(),
5154 "DATA-2026-0001:0123456:a/b.md:<from>-<to>".into(),
5155 ] {
5156 assert!(msg.contains(&needle), "{needle}: {msg}");
5157 }
5158 let read = ranged(text.as_bytes(), 32_768, Some("1000-1002")).unwrap();
5159 assert_eq!(read.text.len(), 300);
5160 assert_eq!(read.bytes, text.len() as u64);
5161 }
5162
5163 #[test]
5164 fn a_short_file_is_whole() {
5165 let read = read(b"one\ntwo\n", 32).unwrap();
5166 assert_eq!(read.text, "one\ntwo\n");
5167 assert_eq!(read.omitted_bytes, 0);
5168 assert_eq!(read.cut, None);
5169 assert_eq!(read.bytes, 8);
5170 }
5171
5172 #[test]
5173 fn a_cut_ends_a_line_and_says_so() {
5174 let text = "line one\nline two\nline three\n";
5175 let read = read(text.as_bytes(), 20).unwrap();
5176 assert_eq!(read.text, "line one\nline two\n");
5177 assert_eq!(read.omitted_bytes, 11);
5178 let cut = read.cut.unwrap();
5179 assert!(
5180 cut.contains(
5181 "Cut at the last line break before byte 20 (the \
5182 comment_budget), at byte 18 of 29"
5183 ),
5184 "{cut}"
5185 );
5186 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
5187 assert!(
5188 cut.contains(
5189 "comment_budget=262144 reads the whole file, or lines=\"3-\" \
5190 reads on from line 3."
5191 ),
5192 "{cut}"
5193 );
5194 let long = format!("ab\n{}", "x".repeat(30));
5196 let mid = ranged(long.as_bytes(), 20, None).unwrap();
5197 let cut = mid.cut.unwrap();
5198 assert!(cut.starts_with("Cut at byte 20 of 33"), "{cut}");
5199 assert!(cut.contains("lines=\"2-\" reads on from line 2."), "{cut}");
5200 }
5201
5202 #[test]
5203 fn a_cut_never_splits_a_character() {
5204 let read = read("ééééé".as_bytes(), 5).unwrap();
5206 assert_eq!(read.text, "éé");
5207 assert_eq!(read.omitted_bytes, 6);
5208 assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
5210 }
5211
5212 #[test]
5213 fn the_largest_budget_reads_the_largest_file_whole() {
5214 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
5215 assert_eq!(max as u64, crate::govlog::MAX_WHOLE_READ_BYTES);
5216 let big = "x".repeat(max);
5217 let read = read(big.as_bytes(), max).unwrap();
5218 assert_eq!((read.omitted_bytes, read.cut), (0, None));
5219 }
5220
5221 #[test]
5222 fn binary_is_not_text() {
5223 let Err(DataFileReadError::NotText(err)) =
5224 read(&[0xff, 0xfe, 0x00], 32)
5225 else {
5226 panic!("not text");
5227 };
5228 assert_eq!(err.bytes, 3);
5229 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
5230 }
5231}
5232
5233#[cfg(test)]
5234mod data_set_view_tests {
5235 use super::DataSetView;
5236 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
5237
5238 fn set(n: usize, dirs: usize) -> DataSet {
5240 let mut files: Vec<DataFile> = (0..n)
5241 .map(|i| {
5242 DataFile::of(
5243 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
5244 .parse()
5245 .unwrap(),
5246 b"x\n",
5247 )
5248 })
5249 .collect();
5250 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
5251 files.sort_by(|a, b| a.path.cmp(&b.path));
5252 DataSet {
5253 agora_data_set: DATA_SET_VERSION,
5254 title: "t".into(),
5255 description: "d".into(),
5256 compiled_by: "c".into(),
5257 repo: "agora-data".parse().unwrap(),
5258 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
5259 index: "votes/README.md".parse().unwrap(),
5260 files,
5261 }
5262 }
5263
5264 #[test]
5265 fn a_small_set_lists_every_file() {
5266 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
5267 assert_eq!(view.listing.len(), 5);
5268 assert!(view.listing.iter().all(|l| l.files == 1));
5269 assert_eq!((view.files, view.unlisted), (5, 0));
5270 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
5271 assert!(!view.how_to_read.contains("counted, not named"));
5272 }
5273
5274 #[test]
5275 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
5276 let big = set(1_038, 12);
5277 let view = DataSetView::of(&big, None, 32_768).unwrap();
5278 assert_eq!(view.files, 1_039);
5281 let paths: Vec<&str> =
5282 view.listing.iter().map(|l| l.path.as_str()).collect();
5283 assert_eq!(paths.len(), 13, "{paths:?}");
5284 assert!(paths.contains(&"votes/README.md"), "{paths:?}");
5285 assert!(paths.contains(&"votes/d11/"), "{paths:?}");
5286 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
5287 assert!(view.how_to_read.contains("counted, not named"));
5288 assert!(
5289 view.how_to_read.starts_with(
5290 "Read this first: attachment=\"votes/README.md\"."
5291 )
5292 );
5293
5294 let tight = DataSetView::of(&big, None, 300).unwrap();
5296 assert_eq!(tight.listing.len(), 1);
5297 assert_eq!(tight.listing[0].path, "votes/");
5298 assert_eq!(tight.listing[0].files, 1_039);
5299
5300 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
5301 let paths: Vec<&str> =
5302 votes.listing.iter().map(|l| l.path.as_str()).collect();
5303 assert_eq!(paths.len(), 13, "{paths:?}");
5304 assert!(
5305 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
5306 );
5307 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
5308 assert_eq!(votes.unlisted, 0);
5309
5310 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
5312 assert!(d00.listing.iter().all(|l| l.files == 1));
5313 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
5314 }
5315
5316 #[test]
5319 fn a_few_top_directories_descend_together() {
5320 let mut big = set(1_038, 12);
5321 for f in big.files.iter_mut() {
5322 let p = f.path.as_str();
5323 let moved = match p.strip_prefix("votes/d0") {
5324 Some(rest) => format!("early/d0{rest}"),
5325 None => p.to_string(),
5326 };
5327 f.path = moved.parse().unwrap();
5328 }
5329 big.files.sort_by(|a, b| a.path.cmp(&b.path));
5330 let view = DataSetView::of(&big, None, 32_768).unwrap();
5331 let paths: Vec<&str> =
5332 view.listing.iter().map(|l| l.path.as_str()).collect();
5333 assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
5334 assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
5335 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
5336 assert_eq!(view.unlisted, 0);
5337 }
5338
5339 #[test]
5340 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
5341 let flat = set(1_000, 1);
5342 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
5343 assert!(view.unlisted > 0);
5344 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
5345 assert!(view.how_to_read.contains("did not fit at all"));
5346 let json = serde_json::to_string(&view).unwrap();
5348 assert!(!json.contains("sha256"), "{json}");
5349 }
5350}