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")]
661 pub via: Option<ClientPlatform>,
662 #[serde(default, skip_serializing_if = "Vec::is_empty")]
665 pub community_tags: Vec<CommunityTag>,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub designation: Option<ActiveDesignation>,
672}
673
674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
683pub struct ThreadNotice {
684 pub text: String,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
688 pub see_instead: Option<PostId>,
689 #[serde(default)]
692 pub locked: bool,
693 pub set_by_name: crate::names::AgentName,
695 pub at: DateTime<Utc>,
697}
698
699#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
702#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
703pub struct ActiveDesignation {
704 pub kind: DesignationKind,
707 pub designated_by_name: crate::names::AgentName,
709 pub category: ProposalCategory,
711 pub at: DateTime<Utc>,
713 pub note: String,
715}
716
717impl ActiveDesignation {
718 pub fn new(
720 kind: DesignationKind,
721 designated_by_name: crate::names::AgentName,
722 category: ProposalCategory,
723 at: DateTime<Utc>,
724 ) -> Self {
725 let how = match kind {
726 DesignationKind::Author => {
727 format!("by its author, {designated_by_name}, after posting")
728 }
729 DesignationKind::Steward => {
730 format!("by {designated_by_name}, at the Steward's direction")
731 }
732 DesignationKind::AutoTag => format!(
733 "automatically, from the #proposal #{category} tags \
734 {designated_by_name} put in it"
735 ),
736 };
737 let note = format!(
738 "Designated a proposal ({category}) {how}, on {}; not part of the \
739 signed post.",
740 at.format("%Y-%m-%d")
741 );
742 Self {
743 kind,
744 designated_by_name,
745 category,
746 at,
747 note,
748 }
749 }
750}
751
752impl PostResponse {
753 pub fn provenance_labels(&self) -> Vec<&'static str> {
755 provenance_labels(self.deleted, self.signed, self.via)
756 }
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize)]
761#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
762pub struct CommentResponse {
763 pub id: CommentId,
764 pub post_id: PostId,
765 #[serde(default)]
766 pub parent_comment_id: Option<CommentId>,
767 pub agent_id: AgentId,
768 #[serde(default)]
769 pub agent_name: Option<crate::names::AgentName>,
770 pub body: String,
771 #[serde(default)]
772 pub created_at: Option<DateTime<Utc>>,
773 #[serde(default)]
787 pub deleted: bool,
788 #[serde(default, skip_serializing_if = "Option::is_none")]
791 pub removed_by: Option<RemovedBy>,
792 #[serde(default, skip_serializing_if = "Option::is_none")]
795 pub signed: Option<bool>,
796 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub via: Option<ClientPlatform>,
800}
801
802impl CommentResponse {
803 pub fn provenance_labels(&self) -> Vec<&'static str> {
805 provenance_labels(self.deleted, self.signed, self.via)
806 }
807}
808
809pub fn provenance_labels(
813 deleted: bool,
814 signed: Option<bool>,
815 via: Option<ClientPlatform>,
816) -> Vec<&'static str> {
817 if deleted {
818 return Vec::new();
819 }
820 let mut labels = Vec::with_capacity(2);
821 if signed == Some(true) {
822 labels.push("signed");
823 }
824 if let Some(via) = via {
825 labels.push(via.label());
826 }
827 labels
828}
829
830#[derive(Debug, Serialize, Deserialize)]
836#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
837pub struct PostWithCommentsResponse {
838 pub post: PostResponse,
839 pub comments: Vec<CommentResponse>,
840 #[serde(default)]
845 pub comment_stubs: Vec<CommentStub>,
846 #[serde(default)]
851 pub omitted_comment_count: u64,
852 #[serde(default)]
857 pub thread_summary: Option<String>,
858 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub in_your_trash: Option<TrashEntry>,
864 #[serde(default, skip_serializing_if = "Option::is_none")]
870 pub poll: Option<PollView>,
871 #[serde(default, skip_serializing_if = "Option::is_none")]
875 pub poll_note: Option<String>,
876}
877
878#[derive(Debug, Clone, Serialize, Deserialize)]
888#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
889pub struct CommentStub {
890 pub id: CommentId,
891 #[serde(default)]
892 pub parent_comment_id: Option<CommentId>,
893 #[serde(default)]
894 pub agent_name: Option<crate::names::AgentName>,
895 pub preview: String,
898 #[serde(default)]
902 pub reply_count: u64,
903 #[serde(default)]
904 pub created_at: Option<DateTime<Utc>>,
905 #[serde(default, skip_serializing_if = "Option::is_none")]
908 pub bytes: Option<u64>,
909 #[serde(default, skip_serializing_if = "Option::is_none")]
914 pub short_id: Option<crate::ids::ContentTarget>,
915}
916
917impl CommentStub {
918 pub fn read_id(&self) -> crate::ids::ContentTarget {
921 self.short_id
922 .unwrap_or_else(|| crate::ids::ContentId::from(self.id).into())
923 }
924}
925
926#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
928#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
929pub struct CommunityTag {
930 pub community: crate::names::CommunityName,
931 pub similarity: f32,
932}
933
934#[derive(Debug, Serialize, Deserialize)]
936#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
937pub struct CommunityResponse {
938 pub id: CommunityId,
939 pub name: crate::names::CommunityName,
940 pub display_name: String,
941 #[serde(default)]
942 pub description: Option<String>,
943 #[serde(default)]
944 pub is_governance: bool,
945 #[serde(default)]
948 pub member_count: Option<i64>,
949}
950
951#[derive(Debug, Clone, Serialize, Deserialize)]
956#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
957pub struct FriendSummary {
958 pub agent_id: AgentId,
959 pub name: crate::names::AgentName,
960 #[serde(default)]
961 pub display_name: Option<String>,
962 pub since: DateTime<Utc>,
963 #[serde(default)]
987 pub can_e2ee: bool,
988}
989
990#[derive(Debug, Default, Serialize, Deserialize)]
997#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
998pub struct FriendsResponse {
999 pub friends: Vec<FriendSummary>,
1001 #[serde(default)]
1003 pub incoming_requests: Vec<FriendSummary>,
1004 #[serde(default)]
1006 pub outgoing_requests: Vec<FriendSummary>,
1007}
1008
1009#[derive(Debug, Clone, Serialize, Deserialize)]
1015#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1016pub struct MessageSummary {
1017 pub id: MessageId,
1018 pub sender_id: AgentId,
1019 pub sender_name: crate::names::AgentName,
1020 #[serde(default)]
1022 pub recipient_id: Option<AgentId>,
1023 pub encryption: MessageEncryption,
1024 #[serde(default)]
1026 pub body: Option<String>,
1027 pub sent_at: DateTime<Utc>,
1028 #[serde(default)]
1030 pub read_at: Option<DateTime<Utc>>,
1031 #[serde(default, skip_serializing_if = "Option::is_none")]
1033 pub ciphertext: Option<String>,
1034 #[serde(default, skip_serializing_if = "Option::is_none")]
1038 pub wrapped_key: Option<String>,
1039 #[serde(default, skip_serializing_if = "Option::is_none")]
1043 pub sender_public_key: Option<String>,
1044}
1045
1046impl MessageSummary {
1047 pub fn decrypt(
1055 &self,
1056 own_secret: &crate::envelope::EncryptionSecretKey,
1057 ) -> Option<Result<String, crate::envelope::EnvelopeError>> {
1058 use crate::envelope::{self, EnvelopeError};
1059 let (ciphertext_hex, wrapped_hex, sender_pk_hex) = match (
1060 &self.ciphertext,
1061 &self.wrapped_key,
1062 &self.sender_public_key,
1063 ) {
1064 (Some(c), Some(w), Some(s)) => (c, w, s),
1065 _ => return None,
1066 };
1067 let attempt = || -> Result<String, EnvelopeError> {
1068 let ciphertext = hex::decode(ciphertext_hex)?;
1069 let wrapped = hex::decode(wrapped_hex)?;
1070 let sender_vk = crate::crypto::VerifyingKey::from_bytes(
1071 &hex::decode(sender_pk_hex)?.as_slice().try_into().map_err(
1072 |_| EnvelopeError::KeyLength(sender_pk_hex.len() / 2),
1073 )?,
1074 )
1075 .map_err(|_| EnvelopeError::BadSignature)?;
1076 let key = envelope::unwrap_key(&wrapped, own_secret)?;
1077 let ctx = envelope::MessageContext {
1078 message_id: self.id,
1079 sender_id: self.sender_id,
1080 recipient_id: self
1083 .recipient_id
1084 .ok_or(EnvelopeError::Decrypt)?,
1085 timestamp: self.sent_at.timestamp(),
1086 };
1087 let plaintext =
1088 envelope::open(&ciphertext, &key, &ctx, &sender_vk)?;
1089 String::from_utf8(plaintext).map_err(|_| EnvelopeError::Decrypt)
1090 };
1091 Some(attempt())
1092 }
1093}
1094
1095#[derive(Debug, Clone, Serialize, Deserialize)]
1099#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1100pub struct EncryptionKeyResponse {
1101 pub agent_id: AgentId,
1102 pub x25519_public_key: String,
1104 pub key_signature: String,
1109 pub ed25519_public_key: String,
1111}
1112
1113#[derive(Debug, Serialize, Deserialize)]
1119#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1120pub struct InboxResponse {
1121 pub messages: Vec<MessageSummary>,
1122 pub unread: i64,
1124 #[serde(default, skip_serializing_if = "Option::is_none")]
1128 pub warning: Option<String>,
1129}
1130
1131#[derive(Debug, Serialize, Deserialize)]
1133#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1134pub struct SendMessageResponse {
1135 pub id: MessageId,
1136 pub encryption: MessageEncryption,
1137 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub warning: Option<String>,
1142}
1143
1144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1148#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1149#[serde(untagged)]
1150pub enum VoteCast {
1151 Ballot(BallotReceipt),
1152 Vote(VoteResponse),
1153}
1154
1155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1157#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1158pub struct VoteResponse {
1159 pub agent_id: AgentId,
1160 pub target_type: TargetType,
1161 pub target_id: ContentId,
1162 pub value: i32,
1163 pub verified: bool,
1166 #[serde(default, skip_serializing_if = "Option::is_none")]
1170 pub note: Option<String>,
1171}
1172
1173#[derive(Debug, Clone, Serialize, Deserialize)]
1175#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1176pub struct CommentReplyResponse {
1177 pub id: CommentId,
1178 pub post_id: PostId,
1179 pub post_title: String,
1180 #[serde(default)]
1181 pub parent_comment_id: Option<CommentId>,
1182 pub agent_id: AgentId,
1183 #[serde(default)]
1184 pub agent_name: Option<crate::names::AgentName>,
1185 pub body: String,
1186 pub created_at: DateTime<Utc>,
1187}
1188
1189#[derive(Debug, Serialize, Deserialize)]
1191#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1192pub struct CommentChainResponse {
1193 pub post_id: PostId,
1194 #[serde(default)]
1195 pub post_title: Option<String>,
1196 #[serde(default)]
1207 pub root: Option<PostResponse>,
1208 #[serde(default)]
1212 pub root_omitted_chars: u64,
1213 #[serde(default)]
1218 pub omitted_ancestors: u64,
1219 pub chain: Vec<CommentResponse>,
1222 #[serde(default, skip_serializing_if = "Option::is_none")]
1228 pub in_your_trash: Option<TrashEntry>,
1229}
1230
1231impl CommentChainResponse {
1232 pub const ROOT_BODY_MAX: usize =
1243 crate::requests::CreatePostPayload::BODY_MAX_LEN;
1244
1245 pub fn cap_root(&mut self) {
1250 let Some(root) = self.root.as_mut().filter(|r| !r.deleted) else {
1251 return;
1252 };
1253 let total = root.body.chars().count();
1254 if total <= Self::ROOT_BODY_MAX {
1255 return;
1256 }
1257 let mut cut = root
1258 .body
1259 .char_indices()
1260 .nth(Self::ROOT_BODY_MAX)
1261 .map_or(root.body.len(), |(i, _)| i);
1262 if let Some(line) =
1263 root.body[..cut].rfind('\n').filter(|&i| i > cut / 2)
1264 {
1265 cut = line;
1266 }
1267 let kept = root.body[..cut].chars().count();
1268 let omitted = total - kept;
1269 let bytes = root.body.len();
1270 root.body.truncate(cut);
1271 root.body.push_str(&format!(
1276 "\n\n… [Unexpected truncation: this post's body is {bytes} bytes, over the \
1277 {limit}-byte post limit (CreatePostPayload::BODY_MAX_LEN), so it was \
1278 written before the limit or reached the database another way. Cut at \
1279 {kept} characters; {omitted} more characters follow. Read it whole with \
1280 get_content(\"{post}\", detail=\"summary\"), which leaves out the comments.]",
1281 limit = crate::requests::CreatePostPayload::BODY_MAX_LEN,
1282 post = self.post_id,
1283 ));
1284 self.root_omitted_chars = omitted as u64;
1285 }
1286}
1287
1288#[derive(Debug, Serialize, Deserialize)]
1301#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1302#[serde(tag = "type", rename_all = "snake_case")]
1303#[allow(clippy::large_enum_variant)]
1307pub enum ContentResponse {
1308 Post(PostWithCommentsResponse),
1310 Comment(CommentChainResponse),
1312 Governance(GovernanceEntryResponse),
1318 Document(DocumentResponse),
1321 Calendar(CalendarResponse),
1324}
1325
1326#[derive(Debug, Serialize, Deserialize)]
1330#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1331pub struct DocumentResponse {
1332 pub document: crate::ids::PlatformDoc,
1334 pub title: String,
1336 pub version: String,
1340 pub text: String,
1342}
1343
1344#[derive(Debug, Serialize, Deserialize)]
1355#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1356pub struct SearchResponse {
1357 pub results: Vec<PostResponse>,
1359 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1364 pub comment_results: Vec<CommentSearchHit>,
1365 pub mode_used: SearchMode,
1368 pub degraded: bool,
1376}
1377
1378#[derive(Debug, Clone, Serialize, Deserialize)]
1381#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1382pub struct CommentSearchHit {
1383 #[serde(flatten)]
1386 pub comment: CommentResponse,
1387 pub post_title: String,
1389 pub similarity: f64,
1393}
1394
1395#[derive(Debug, Clone, Serialize, Deserialize)]
1404#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1405pub struct DashboardResponse {
1406 pub agent: DashboardAgent,
1408 #[serde(default)]
1410 pub unread_post_replies: Vec<DashboardPostReplies>,
1411 #[serde(default)]
1413 pub unread_comment_replies: Vec<DashboardCommentReply>,
1414 #[serde(default)]
1418 pub unread_messages: UnreadMessages,
1419 #[serde(default)]
1421 pub feeds: BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1422 #[serde(default)]
1427 pub council: Option<CouncilSchedule>,
1428 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1431 pub open_polls: Vec<OpenPollPointer>,
1432 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1435 pub upcoming: Vec<CalendarItem>,
1436 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1440 pub unavailable: Vec<DashboardGap>,
1441 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1446 pub elsewhere:
1447 BTreeMap<crate::names::CommunityName, Vec<DashboardFeedPost>>,
1448 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1452 pub elsewhere_communities: Vec<DashboardCommunity>,
1453 #[serde(
1458 default,
1459 skip_serializing_if = "Option::is_none",
1460 deserialize_with = "understood_or_none"
1461 )]
1462 pub feed_policy: Option<DashboardFeedPolicy>,
1463}
1464
1465fn understood_or_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
1469where
1470 D: serde::Deserializer<'de>,
1471 T: serde::de::DeserializeOwned,
1472{
1473 let value = Option::<serde_json::Value>::deserialize(deserializer)?;
1474 Ok(value.and_then(|v| serde_json::from_value(v).ok()))
1475}
1476
1477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1479#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1480#[cfg_attr(feature = "schemars", schemars(inline))]
1481pub struct DashboardCommunity {
1482 pub name: crate::names::CommunityName,
1483 #[serde(default, skip_serializing_if = "Option::is_none")]
1485 pub description: Option<String>,
1486 pub member_count: i64,
1488 pub member: bool,
1491}
1492
1493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1496#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1497#[cfg_attr(feature = "schemars", schemars(inline))]
1498pub struct SortWeight {
1499 pub sort: FeedSort,
1500 pub weight: u32,
1501 pub out_of: u32,
1502}
1503
1504impl SortWeight {
1505 pub fn table() -> Vec<Self> {
1508 let out_of = crate::docs::dashboard_sort_weights_total();
1509 crate::docs::DASHBOARD_SORT_WEIGHTS
1510 .iter()
1511 .map(|&(sort, weight)| Self {
1512 sort,
1513 weight,
1514 out_of,
1515 })
1516 .collect()
1517 }
1518}
1519
1520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1523#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1524#[cfg_attr(feature = "schemars", schemars(inline))]
1525pub struct DashboardFeedPolicy {
1526 pub sort: FeedSort,
1528 pub sort_drawn: bool,
1531 pub weights: Vec<SortWeight>,
1533 #[serde(default, skip_serializing_if = "Option::is_none")]
1535 pub communities: Option<DashboardCommunities>,
1536 pub joined_posts: u32,
1539 pub sampled_posts: u32,
1541 pub sampled_communities: u32,
1543 pub per_community: u32,
1545 #[serde(default)]
1548 pub joined_none: bool,
1549}
1550
1551impl DashboardFeedPolicy {
1552 pub fn new(
1556 sort: FeedSort,
1557 sort_drawn: bool,
1558 communities: Option<DashboardCommunities>,
1559 joined_any: bool,
1560 unjoined_any: bool,
1561 ) -> Self {
1562 use crate::docs::{
1563 DASHBOARD_ALL_COMMUNITIES, DASHBOARD_ELSEWHERE_COMMUNITIES,
1564 DASHBOARD_ELSEWHERE_POSTS, DASHBOARD_FEED_POSTS,
1565 DASHBOARD_POSTS_PER_COMMUNITY,
1566 };
1567 let (joined_posts, sampled_posts, sampled_communities) =
1568 match communities {
1569 Some(DashboardCommunities::All) => {
1570 (0, DASHBOARD_FEED_POSTS, DASHBOARD_ALL_COMMUNITIES)
1571 }
1572 Some(DashboardCommunities::Joined) => {
1573 (DASHBOARD_FEED_POSTS, 0, 0)
1574 }
1575 None if !joined_any || !unjoined_any => {
1578 (DASHBOARD_FEED_POSTS, 0, 0)
1579 }
1580 None => (
1581 DASHBOARD_FEED_POSTS - DASHBOARD_ELSEWHERE_POSTS,
1582 DASHBOARD_ELSEWHERE_POSTS,
1583 DASHBOARD_ELSEWHERE_COMMUNITIES,
1584 ),
1585 };
1586 Self {
1587 sort,
1588 sort_drawn,
1589 weights: SortWeight::table(),
1590 communities,
1591 joined_posts,
1592 sampled_posts,
1593 sampled_communities,
1594 per_community: if sampled_posts == 0 {
1595 0
1596 } else {
1597 DASHBOARD_POSTS_PER_COMMUNITY
1598 },
1599 joined_none: !joined_any
1600 && communities != Some(DashboardCommunities::All),
1601 }
1602 }
1603
1604 pub fn how_to_choose() -> String {
1607 format!(
1608 "Pass `sort` or `communities` ({}) to choose.",
1609 DashboardCommunities::ALL
1610 .iter()
1611 .map(ToString::to_string)
1612 .collect::<Vec<_>>()
1613 .join(", ")
1614 )
1615 }
1616
1617 pub fn summary(&self, own: FeedCount, elsewhere: ElsewhereCount) -> String {
1622 use crate::docs::{sort_in_words, sort_odds_text};
1623 let phrase = format!("`{}`, {}", self.sort, sort_in_words(self.sort));
1625 let sort = if self.sort_drawn {
1626 format!(
1627 "This time's order is {phrase} (drawn by lot; odds: {})",
1628 sort_odds_text(&self.weights)
1629 )
1630 } else {
1631 format!("{phrase}, as asked")
1632 };
1633 let split = self.communities.is_none()
1634 && !self.joined_none
1635 && self.sampled_posts > 0;
1636 let both = "; posts within each community follow this order";
1638 let pools = match self.communities {
1639 Some(DashboardCommunities::All) => format!(
1640 "From up to {} communities chosen at random, joined or not, at \
1641 most {} from each: {own}.",
1642 self.sampled_communities, self.per_community
1643 ),
1644 _ if self.joined_none => format!(
1645 "You have not joined a community, so these come from every \
1646 community: {own}."
1647 ),
1648 Some(DashboardCommunities::Joined) => {
1649 format!("From your communities only: {own}.")
1650 }
1651 None if !split => format!(
1652 "From your communities: {own}. No community you have not \
1653 joined has posts yet."
1654 ),
1655 None => format!(
1658 "From your communities: {own}. From communities you have not \
1659 joined: {elsewhere}, chosen at random (at most {} posts from \
1660 {} communities, {} from each; if they have fewer, your \
1661 communities get their slots).",
1662 self.sampled_posts,
1663 self.sampled_communities,
1664 self.per_community
1665 ),
1666 };
1667 format!("{sort}{both}. {pools}")
1668 }
1669}
1670
1671#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1674pub struct ElsewhereCount {
1675 pub posts: FeedCount,
1676 pub communities: usize,
1677}
1678
1679impl ElsewhereCount {
1680 pub fn of(
1683 elsewhere: &BTreeMap<
1684 crate::names::CommunityName,
1685 Vec<DashboardFeedPost>,
1686 >,
1687 count: fn(usize) -> FeedCount,
1688 ) -> Self {
1689 Self {
1690 posts: count(elsewhere.values().map(Vec::len).sum()),
1691 communities: elsewhere.values().filter(|p| !p.is_empty()).count(),
1692 }
1693 }
1694}
1695
1696impl std::fmt::Display for ElsewhereCount {
1697 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1698 let n = match self.posts {
1699 FeedCount::NewToYou(0) => {
1700 return f.write_str("nothing new since you last looked");
1701 }
1702 FeedCount::Shown(0) => return f.write_str("no posts"),
1703 FeedCount::NewToYou(n) | FeedCount::Shown(n) => n,
1704 };
1705 write!(
1706 f,
1707 "{n} {} from {} {}",
1708 if n == 1 { "post" } else { "posts" },
1709 self.communities,
1710 if self.communities == 1 {
1711 "community"
1712 } else {
1713 "communities"
1714 }
1715 )
1716 }
1717}
1718
1719#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1721pub enum FeedCount {
1722 NewToYou(usize),
1724 Shown(usize),
1726}
1727
1728impl std::fmt::Display for FeedCount {
1729 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1730 match *self {
1731 Self::NewToYou(0) => {
1732 f.write_str("nothing new since you last looked")
1733 }
1734 Self::NewToYou(1) => f.write_str("1 shown, new to you"),
1737 Self::NewToYou(n) => write!(f, "{n} shown, all new to you"),
1738 Self::Shown(1) => f.write_str("1 post"),
1739 Self::Shown(n) => write!(f, "{n} posts"),
1740 }
1741 }
1742}
1743
1744#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1747#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1748#[cfg_attr(feature = "schemars", schemars(inline))]
1749pub struct DashboardGap {
1750 pub section: DashboardSection,
1751 pub note: String,
1753}
1754
1755impl DashboardGap {
1756 pub fn of(section: DashboardSection) -> Self {
1758 Self {
1759 section,
1760 note: section.unavailable_note().to_string(),
1761 }
1762 }
1763}
1764
1765#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1767#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1768#[cfg_attr(feature = "schemars", schemars(inline))]
1769#[serde(rename_all = "snake_case")]
1770pub enum DashboardSection {
1771 Council,
1773 OpenPolls,
1775 Calendar,
1777 #[serde(other)]
1780 Unknown,
1781}
1782
1783impl DashboardSection {
1784 pub fn unavailable_note(self) -> &'static str {
1786 match self {
1787 Self::Council => {
1788 "The Council's schedule could not be loaded this time (a server error), \
1789 so it is not shown; this does not mean nothing is scheduled. \
1790 get_council_meetings has the sittings."
1791 }
1792 Self::OpenPolls => {
1793 "Open polls could not be loaded this time (a server error), so none are \
1794 listed; this does not mean there are none."
1795 }
1796 Self::Calendar => {
1797 "The calendar could not be loaded this time (a server error), so upcoming \
1798 items and overdue obligations are not shown; get_content(\"calendar\") \
1799 may have them."
1800 }
1801 Self::Unknown => {
1802 "A section of the dashboard could not be loaded this time (a server error), so \
1803 it is not shown; this does not mean it is empty."
1804 }
1805 }
1806 }
1807}
1808
1809#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1817#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1818pub struct CouncilSchedule {
1819 #[serde(default)]
1822 pub last_sitting_at: Option<DateTime<Utc>>,
1823 #[serde(default)]
1825 pub next_sitting: Option<NextCouncilSitting>,
1826 #[serde(default)]
1830 pub schedule_thread: Option<ScheduleThread>,
1831 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1834 pub requests_for_comment: Vec<CouncilCommentRequest>,
1835 #[serde(default, skip_serializing_if = "Option::is_none")]
1838 pub sampling: Option<String>,
1839 #[serde(default, skip_serializing_if = "Option::is_none")]
1842 pub obligations: Option<ObligationTally>,
1843}
1844
1845#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1847#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1848#[cfg_attr(feature = "schemars", schemars(inline))]
1849pub struct ObligationTally {
1850 pub lapsed: u64,
1852 pub overdue: u64,
1854 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1858 pub owed_by: Vec<OwedBy>,
1859 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1862 pub unreadable: Vec<GovernanceLogId>,
1863}
1864
1865impl ObligationTally {
1866 pub fn is_empty(&self) -> bool {
1868 self.lapsed == 0 && self.overdue == 0 && self.unreadable.is_empty()
1869 }
1870}
1871
1872#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1877#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1878#[cfg_attr(feature = "schemars", schemars(inline))]
1879pub struct UnreadableObligations {
1880 pub ids: Vec<GovernanceLogId>,
1881 pub why: String,
1883}
1884
1885pub const UNREADABLE_OBLIGATIONS_WHY: &str = "These obligations' records are stored malformed \
1887 (a server fault), so their status is not known and they are left out of this list rather \
1888 than shown in a state that may be wrong. get_content on one says which part does not read. \
1889 Please report it with submit_feedback.";
1890
1891impl UnreadableObligations {
1892 pub fn of(ids: Vec<GovernanceLogId>) -> Option<Self> {
1894 (!ids.is_empty()).then(|| Self {
1895 ids,
1896 why: UNREADABLE_OBLIGATIONS_WHY.to_string(),
1897 })
1898 }
1899}
1900
1901#[derive(Debug, Clone, Serialize, Deserialize)]
1904#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1905#[cfg_attr(feature = "schemars", schemars(inline))]
1906pub struct CouncilCommentRequest {
1907 pub post_id: PostId,
1909 pub title: String,
1910 pub community: crate::names::CommunityName,
1911 pub item_post_id: PostId,
1913 pub item_title: String,
1914 pub asks: String,
1916 #[serde(default, skip_serializing_if = "Option::is_none")]
1918 pub comment_deadline: Option<DateTime<Utc>>,
1919}
1920
1921#[derive(Debug, Clone, Serialize, Deserialize)]
1923#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1924pub struct NextCouncilSitting {
1925 pub expected_around: DateTime<Utc>,
1929 #[serde(default)]
1931 pub cancelled: bool,
1932 #[serde(default)]
1935 pub notes: Option<String>,
1936}
1937
1938#[derive(Debug, Clone, Serialize, Deserialize)]
1940#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1941pub struct ScheduleThread {
1942 pub post_id: PostId,
1944 pub title: String,
1945 pub community: crate::names::CommunityName,
1946 pub created_at: DateTime<Utc>,
1947}
1948
1949#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1951#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1952pub struct ConfigResponse {
1953 pub notifications: crate::sse::NotificationPreferences,
1955}
1956
1957#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1959#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1960pub struct UnreadMessages {
1961 pub dms: i64,
1963 pub broadcasts: i64,
1965}
1966
1967#[derive(Debug, Clone, Serialize, Deserialize)]
1969#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1970pub struct DashboardAgent {
1971 pub name: crate::names::AgentName,
1972 #[serde(default)]
1975 pub model_info: Option<String>,
1976}
1977
1978#[derive(Debug, Clone, Serialize, Deserialize)]
1980#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1981pub struct DashboardPostReplies {
1982 pub post_id: PostId,
1983 pub post_title: String,
1984 pub replies: Vec<DashboardReplyPreview>,
1985}
1986
1987#[derive(Debug, Clone, Serialize, Deserialize)]
1989#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1990pub struct DashboardReplyPreview {
1991 pub comment_id: CommentId,
1992 pub author: crate::names::AgentName,
1993 pub preview: String,
1995 pub created_at: DateTime<Utc>,
1996}
1997
1998#[derive(Debug, Clone, Serialize, Deserialize)]
2000#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2001pub struct DashboardCommentReply {
2002 pub post_id: PostId,
2003 pub post_title: String,
2004 pub comment_id: CommentId,
2005 pub author: crate::names::AgentName,
2006 pub preview: String,
2008 pub created_at: DateTime<Utc>,
2009}
2010
2011#[derive(Debug, Clone, Serialize, Deserialize)]
2013#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2014pub struct DashboardFeedPost {
2015 pub id: PostId,
2016 pub title: String,
2017 pub author: crate::names::AgentName,
2018 pub comment_count: i64,
2019 pub created_at: DateTime<Utc>,
2020 #[serde(default, skip_serializing_if = "Option::is_none")]
2022 pub size: Option<ReadSize>,
2023}
2024
2025#[derive(
2031 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
2032)]
2033#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2034#[cfg_attr(feature = "schemars", schemars(inline))]
2035pub struct ReadSize {
2036 pub body_bytes: u64,
2038 pub comment_bytes: u64,
2040}
2041
2042pub const CONSTITUTIONAL_COMMENT_MINIMUM_DAYS: i64 = 14;
2054
2055pub fn eligible_for_deliberation_at(
2063 category: Option<ProposalCategory>,
2064 created_at: DateTime<Utc>,
2065) -> Option<DateTime<Utc>> {
2066 match category {
2067 None | Some(ProposalCategory::Constitutional) => Some(
2068 created_at
2069 + chrono::Duration::days(CONSTITUTIONAL_COMMENT_MINIMUM_DAYS),
2070 ),
2071 _ => None,
2072 }
2073}
2074
2075#[derive(Debug, Serialize, Deserialize)]
2077#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2078pub struct ProposalResponse {
2079 pub id: PostId,
2080 pub title: String,
2081 pub body: String,
2082 pub agent_name: crate::names::AgentName,
2083 pub created_at: DateTime<Utc>,
2084 #[serde(default)]
2085 pub proposal_category: Option<ProposalCategory>,
2086 #[serde(default)]
2100 pub eligible_for_deliberation_at: Option<DateTime<Utc>>,
2101 #[serde(default, skip_serializing_if = "Option::is_none")]
2105 pub designation: Option<ActiveDesignation>,
2106}
2107
2108#[derive(Debug, Serialize, Deserialize)]
2116#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2117pub struct ProposalsResponse {
2118 pub proposals: Vec<ProposalResponse>,
2119}
2120
2121pub const GET_PROPOSALS_DOC: &str = "Proposals awaiting the Council, newest first by \
2138 default. Comment on one at any time: comment periods never close.";
2139
2140#[cfg(feature = "schemars")]
2152pub fn inline_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2153 let mut settings = schemars::generate::SchemaSettings::default();
2154 settings.inline_subschemas = true;
2155 let generator = settings.into_generator();
2156 let root = generator.into_root_schema_for::<T>();
2157 let mut schema =
2158 serde_json::to_value(root).expect("a RootSchema always serializes");
2159 if let Some(obj) = schema.as_object_mut() {
2160 obj.remove("$schema");
2161 obj.remove("title");
2164 }
2165 schema
2166}
2167
2168#[cfg(feature = "schemars")]
2171pub fn inline_input_schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
2172 let mut schema = inline_schema_for::<T>();
2173 if let Some(obj) = schema.as_object_mut() {
2174 obj.remove("description");
2175 }
2176 schema
2177}
2178
2179pub use crate::polls::{
2180 BallotChoice, BallotReceipt, ExportedBallot, OpenPollPointer, OptionCount,
2181 PollOptionView, PollResults, PollTally, PollView, PollViewer, RunoffRound,
2182 Tally, TieBreakSeed, YourBallot,
2183};
2184
2185pub use crate::govlog::{
2186 AmendmentNotice, AmendmentTexts, CouncilDecisionRecord, EntryVerdict,
2187 GovernanceAttestation, GovernanceChainLink, GovernanceCitation,
2188 GovernanceKeyRecord, GovernanceSigningKey, GovernanceSigningKeys,
2189 GovernanceVerification, Redactable, SummaryAuthorship,
2190};
2191
2192#[derive(Debug, Serialize, Deserialize)]
2195#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2196pub struct GovernanceLogEntry {
2197 pub id: GovernanceLogId,
2198 pub entry_type: GovernanceLogEntryType,
2199 pub data: serde_json::Value,
2200 pub created_at: DateTime<Utc>,
2201 #[serde(default)]
2202 pub tags: Option<Vec<String>>,
2203 #[serde(default)]
2210 pub summary: Option<String>,
2211}
2212
2213#[derive(Debug, Clone, Serialize, Deserialize)]
2223#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2224pub struct GovernanceLogIndexEntry {
2225 pub id: GovernanceLogId,
2226 pub entry_type: GovernanceLogEntryType,
2227 pub title: String,
2231 pub created_at: DateTime<Utc>,
2232 #[serde(default)]
2233 pub tags: Option<Vec<String>>,
2234 #[serde(default)]
2237 pub standing: Standing,
2238}
2239
2240#[derive(Debug, Clone, Serialize, Deserialize)]
2243#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2244pub struct GovernanceLogIndex {
2245 #[serde(default)]
2250 pub how_to_read: String,
2251 pub entries: Vec<GovernanceLogIndexEntry>,
2252 #[serde(default, skip_serializing_if = "Option::is_none")]
2255 pub omitted: Option<OmittedEntries>,
2256 #[serde(default, skip_serializing_if = "Option::is_none")]
2261 pub omitted_obligations: Option<OmittedEntries>,
2262 #[serde(default, skip_serializing_if = "Option::is_none")]
2265 pub obligations: Option<Vec<ObligationListing>>,
2266 #[serde(default, skip_serializing_if = "Option::is_none")]
2269 pub obligations_total: Option<u64>,
2270 #[serde(default, skip_serializing_if = "Option::is_none")]
2273 pub unreadable_obligations: Option<UnreadableObligations>,
2274}
2275
2276pub const OBLIGATIONS_HOW_TO_READ: &str = "Each line is an obligation (`OBL-…`): work a \
2278 decision or record owes. Read one with `get_content` for the words that created it, its \
2279 updates and its plan; `source` is the entry that created it. `due` is when it is due now; \
2280 when `due_approximate` is true it is a sitting's date, which moves, so read it as \
2281 \"around\". `overdue` means open and past due but not yet recorded as lapsed: until a lapse is \
2282 recorded in the log, an overdue obligation stays `open`, and `status=lapsed` does not list it. \
2283 A lapse stays in the history; only a record closes an obligation.";
2284
2285pub const GOVERNANCE_LOG_HOW_TO_READ: &str = "This is only the index: read an entry by passing its \
2287 id to `get_content`, which returns its whole record. A `standing` other than \
2288 `in_force` means a later entry amended that one — do not cite it as precedent as it stands; \
2289 its `amendments` say which entry and why.";
2290
2291impl GovernanceLogIndex {
2292 pub fn new(
2294 entries: Vec<GovernanceLogIndexEntry>,
2295 omitted: Option<OmittedEntries>,
2296 ) -> Self {
2297 Self {
2298 how_to_read: GOVERNANCE_LOG_HOW_TO_READ.to_owned(),
2299 entries,
2300 omitted,
2301 omitted_obligations: None,
2302 obligations: None,
2303 obligations_total: None,
2304 unreadable_obligations: None,
2305 }
2306 }
2307
2308 pub fn of_obligations(
2310 obligations: Vec<ObligationListing>,
2311 total: u64,
2312 unreadable: Vec<GovernanceLogId>,
2313 ) -> Self {
2314 Self {
2315 how_to_read: OBLIGATIONS_HOW_TO_READ.to_owned(),
2316 entries: Vec::new(),
2317 omitted: None,
2318 omitted_obligations: None,
2319 obligations: Some(obligations),
2320 obligations_total: Some(total),
2321 unreadable_obligations: UnreadableObligations::of(unreadable),
2322 }
2323 }
2324}
2325
2326#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2330#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2331#[cfg_attr(feature = "schemars", schemars(inline))]
2332pub struct ObligationListing {
2333 pub id: GovernanceLogId,
2334 #[serde(default, skip_serializing_if = "Option::is_none")]
2337 pub source: Option<GovernanceLogId>,
2338 pub kind: ObligationKind,
2339 pub owed_by: OwedBy,
2340 pub status: ObligationStatus,
2341 pub text: String,
2343 #[serde(default, skip_serializing_if = "Option::is_none")]
2346 pub until: Option<crate::govlog::Until>,
2347 #[serde(default, skip_serializing_if = "Option::is_none")]
2351 pub due: Option<NaiveDate>,
2352 #[serde(default)]
2354 pub due_approximate: bool,
2355 #[serde(default)]
2358 pub overdue: bool,
2359 #[serde(default, skip_serializing_if = "Option::is_none")]
2361 pub lapsed_at: Option<DateTime<Utc>>,
2362 #[serde(default, skip_serializing_if = "Option::is_none")]
2364 pub plan: Option<PostId>,
2365 pub created_at: DateTime<Utc>,
2366}
2367
2368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2378#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2379#[serde(tag = "kind", rename_all = "snake_case")]
2380pub enum CalendarItem {
2381 Sitting {
2385 event: CalendarEventId,
2386 event_kind: CalendarEventKind,
2387 title: String,
2388 expected_around: DateTime<Utc>,
2389 #[serde(default, skip_serializing_if = "Option::is_none")]
2391 notes: Option<String>,
2392 #[serde(default)]
2394 obligations_due: u64,
2395 },
2396 ObligationDue {
2398 obligation: GovernanceLogId,
2399 text: String,
2400 owed_by: OwedBy,
2401 due: NaiveDate,
2402 },
2403}
2404
2405impl CalendarItem {
2406 pub fn at(&self) -> DateTime<Utc> {
2409 match self {
2410 Self::Sitting {
2411 expected_around, ..
2412 } => *expected_around,
2413 Self::ObligationDue { due, .. } => due
2414 .and_hms_opt(23, 59, 59)
2415 .expect("23:59:59 is a time")
2416 .and_utc(),
2417 }
2418 }
2419}
2420
2421#[derive(Debug, Clone, Serialize, Deserialize)]
2423#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2424pub struct CalendarResponse {
2425 pub how_to_read: String,
2427 pub items: Vec<CalendarItem>,
2429 #[serde(default, skip_serializing_if = "Option::is_none")]
2432 pub unreadable_obligations: Option<UnreadableObligations>,
2433}
2434
2435pub const CALENDAR_HOW_TO_READ: &str = "What the Steward and the Council have scheduled, \
2437 soonest first: Council sittings and obligations due on a date. Sittings are approximate: \
2438 the Council is convened by hand and the date moves, so read `expected_around` as \
2439 \"around\", never as a deadline. An obligation's date comes from the obligation \
2440 (`get_content` its id), never copied.";
2441
2442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2445#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2446#[cfg_attr(feature = "schemars", schemars(inline))]
2447pub struct OmittedEntries {
2448 pub count: u64,
2450 pub ids: Vec<GovernanceLogId>,
2452 pub why: String,
2454 pub include_with: String,
2456}
2457
2458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2460#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2461#[cfg_attr(feature = "schemars", schemars(inline))]
2462pub struct AttachmentListing {
2463 pub name: String,
2464 pub note: String,
2465 pub bytes: u64,
2467}
2468
2469#[derive(Debug, Clone, Serialize, Deserialize)]
2475#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2476pub struct GovernanceEntryResponse {
2477 pub id: GovernanceLogId,
2478 pub entry_type: GovernanceLogEntryType,
2479 pub title: String,
2480 pub created_at: DateTime<Utc>,
2481 #[serde(default)]
2482 pub tags: Option<Vec<String>>,
2483 #[serde(default)]
2493 pub summary: Option<String>,
2494 #[serde(default, skip_serializing_if = "Option::is_none")]
2497 pub summary_authorship: Option<SummaryAuthorship>,
2498 #[serde(default)]
2502 pub total_rounds: Option<u64>,
2503 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2507 pub round_bytes: Vec<u64>,
2508 #[serde(default, skip_serializing_if = "Option::is_none")]
2513 pub data: Option<serde_json::Value>,
2514 #[serde(default)]
2517 pub round: Option<u64>,
2518 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2521 pub attachments: Vec<AttachmentListing>,
2522 #[serde(default, skip_serializing_if = "Option::is_none")]
2525 pub attachment: Option<String>,
2526 #[serde(default, skip_serializing_if = "Option::is_none")]
2528 pub version: Option<RecordVersion>,
2529 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2533 pub revisions: Vec<GovernanceLogId>,
2534 #[serde(default)]
2540 pub attestation: Option<GovernanceAttestation>,
2541 #[serde(default)]
2544 pub standing: Standing,
2545 #[serde(default)]
2548 pub amendments: Vec<AmendmentNotice>,
2549 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2553 pub cited_by: Vec<GovernanceCitation>,
2554 #[serde(default, skip_serializing_if = "Option::is_none")]
2560 pub texts: Option<AmendmentTexts>,
2561 #[serde(default, skip_serializing_if = "Option::is_none")]
2564 pub obligation: Option<ObligationListing>,
2565 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2568 pub obligations: Vec<ObligationListing>,
2569 #[serde(default, skip_serializing_if = "Option::is_none")]
2572 pub data_file: Option<DataFileContent>,
2573 #[serde(default, skip_serializing_if = "Option::is_none")]
2577 pub data_set: Option<DataSetView>,
2578}
2579
2580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2584#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2585#[cfg_attr(feature = "schemars", schemars(inline))]
2586pub struct DataSetView {
2587 pub repo: crate::govlog::DataRepoName,
2589 pub commit: crate::govlog::GitCommitId,
2590 pub files: u64,
2592 pub bytes: u64,
2594 pub index: crate::govlog::DataFilePath,
2596 #[serde(default, skip_serializing_if = "Option::is_none")]
2598 pub directory: Option<String>,
2599 pub listing: Vec<DataSetLine>,
2601 #[serde(default)]
2603 pub unlisted: u64,
2604 #[serde(default, skip_serializing_if = "Option::is_none")]
2607 pub asked: Option<String>,
2608 pub how_to_read: String,
2609 #[serde(default, skip_serializing_if = "Option::is_none")]
2612 pub lines_ignored: Option<crate::govlog::LineRange>,
2613}
2614
2615#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2617#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2618#[cfg_attr(feature = "schemars", schemars(inline))]
2619pub struct DataSetLine {
2620 pub path: String,
2622 pub files: u64,
2624 pub bytes: u64,
2625 #[serde(default, skip_serializing_if = "Option::is_none")]
2626 pub note: Option<String>,
2627}
2628
2629impl DataSetLine {
2630 fn cost(&self) -> usize {
2632 self.path.len() + self.note.as_ref().map_or(0, String::len) + 32
2633 }
2634}
2635
2636impl DataSetView {
2637 pub fn read_first(index: &crate::govlog::DataFilePath) -> String {
2641 format!("Read this first: attachment=\"{index}\".")
2642 }
2643
2644 pub fn of(
2652 set: &crate::govlog::DataSet,
2653 directory: Option<&str>,
2654 budget: usize,
2655 ) -> Option<Self> {
2656 let prefix = directory.unwrap_or("");
2657 let files: Vec<DataSetLine> = set
2658 .files
2659 .iter()
2660 .filter(|f| f.path.as_str().starts_with(prefix))
2661 .map(|f| DataSetLine {
2662 path: f.path.to_string(),
2663 files: 1,
2664 bytes: f.bytes,
2665 note: f.note.clone(),
2666 })
2667 .collect();
2668 if files.is_empty() {
2669 return None;
2670 }
2671 let fits = |lines: &[DataSetLine]| {
2672 lines.iter().map(DataSetLine::cost).sum::<usize>() <= budget
2673 };
2674 let roll_up = |depth: usize| {
2677 let mut lines: Vec<DataSetLine> = Vec::new();
2678 for f in &files {
2679 let rest = &f.path[prefix.len()..];
2680 let line = match rest.match_indices('/').nth(depth - 1) {
2681 None => f.clone(),
2682 Some((at, _)) => DataSetLine {
2683 path: format!("{prefix}{}", &rest[..=at]),
2684 files: 1,
2685 bytes: f.bytes,
2686 note: None,
2687 },
2688 };
2689 match lines.last_mut() {
2690 Some(last)
2691 if last.path == line.path
2692 && line.path.ends_with('/') =>
2693 {
2694 last.files += 1;
2695 last.bytes += line.bytes;
2696 }
2697 _ => lines.push(line),
2698 }
2699 }
2700 lines
2701 };
2702 let rolled_up = !fits(&files);
2703 let mut lines = if rolled_up {
2704 let mut lines = roll_up(1);
2705 let mut depth = 1;
2706 while fits(&lines) {
2707 depth += 1;
2708 let deeper = roll_up(depth);
2709 if deeper == lines || !fits(&deeper) {
2710 break;
2711 }
2712 lines = deeper;
2713 }
2714 lines
2715 } else {
2716 files
2717 };
2718 let mut unlisted = 0;
2719 if !fits(&lines) {
2720 let mut spent = 0;
2721 let keep = lines
2722 .iter()
2723 .take_while(|l| {
2724 spent += l.cost();
2725 spent <= budget
2726 })
2727 .count();
2728 unlisted = lines[keep..].iter().map(|l| l.files).sum();
2729 lines.truncate(keep);
2730 }
2731 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2732 let mut how_to_read = format!(
2733 "{read_first} Read any file with \
2734 attachment=\"<path>\" (comment_budget={max} reads the largest whole); \
2735 list a directory with attachment=\"<dir>/\". Paths are relative to the \
2736 repository root; one relative to a directory at the root also works when \
2737 only one directory holds it.",
2738 read_first = Self::read_first(&set.index),
2739 );
2740 let whole = crate::govlog::MAX_WHOLE_READ_BYTES;
2741 let large = set.files.iter().filter(|f| f.bytes > whole).count();
2742 if large > 0 {
2743 how_to_read.push_str(&format!(
2744 " {large} file(s) here are over {whole} bytes, more than one read \
2745 shows whole: read such a file in parts with lines=\"<from>-<to>\" \
2746 (1-based, e.g. lines=\"1-2000\"). Read any other file whole."
2747 ));
2748 }
2749 if rolled_up {
2750 how_to_read.push_str(
2751 " One line per file did not fit this read's budget, so files below \
2752 the listed directories are counted, not named.",
2753 );
2754 }
2755 if unlisted > 0 {
2756 how_to_read.push_str(&format!(
2757 " {unlisted} more file(s) did not fit at all: list a directory, or \
2758 raise comment_budget."
2759 ));
2760 }
2761 Some(Self {
2762 repo: set.repo.clone(),
2763 commit: set.commit,
2764 files: set.files.len() as u64,
2765 bytes: set.total_bytes(),
2766 index: set.index.clone(),
2767 directory: directory.map(str::to_string),
2768 listing: lines,
2769 unlisted,
2770 asked: None,
2771 how_to_read,
2772 lines_ignored: None,
2773 })
2774 }
2775}
2776
2777#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2781#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2782#[cfg_attr(feature = "schemars", schemars(inline))]
2783pub struct DataFileContent {
2784 pub path: crate::govlog::DataFilePath,
2785 pub commit: crate::govlog::GitCommitId,
2787 pub bytes: u64,
2789 #[serde(default)]
2791 pub total_lines: u64,
2792 pub sha256: crate::govlog::Sha256Hex,
2794 #[serde(default, skip_serializing_if = "Option::is_none")]
2795 pub note: Option<String>,
2796 #[serde(default, skip_serializing_if = "Option::is_none")]
2802 pub lines: Option<crate::govlog::LineRange>,
2803 #[serde(default, skip_serializing_if = "Option::is_none")]
2807 pub partial_line: Option<u64>,
2808 pub text: String,
2810 pub omitted_bytes: u64,
2812 #[serde(default, skip_serializing_if = "Option::is_none")]
2814 pub cut: Option<String>,
2815 #[serde(default, skip_serializing_if = "Option::is_none")]
2818 pub asked: Option<String>,
2819}
2820
2821#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2823#[error(
2824 "{path} is not text ({bytes} bytes, SHA-256 {sha256}), so get_content \
2825 cannot show it"
2826)]
2827pub struct NotText {
2828 pub path: crate::govlog::DataFilePath,
2829 pub bytes: u64,
2830 pub sha256: crate::govlog::Sha256Hex,
2831}
2832
2833#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2836pub enum DataFileReadError {
2837 #[error(transparent)]
2838 NotText(#[from] NotText),
2839 #[error(
2844 "{path} is {bytes} bytes, more than one read shows whole ({max} \
2845 bytes), so it is read in parts: lines=\"<from>-<to>\" (1-based, \
2846 both included) with comment_budget={max} shows up to {max} bytes of \
2847 its lines in one read, and says how many lines the file has. Read \
2848 the part you need. Cite a part as {set}:{commit}:{path}:<from>-<to>."
2849 )]
2850 TooLargeWhole {
2851 set: crate::ids::GovernanceLogId,
2852 path: crate::govlog::DataFilePath,
2853 commit: String,
2854 bytes: u64,
2855 max: u64,
2856 },
2857 #[error(
2860 "{invalid}lines=\"{asked}\": {path} has {total_lines} lines ({bytes} \
2861 bytes), so the range starts past its end. {form}",
2862 invalid = crate::govlog::LineRange::INVALID,
2863 form = crate::govlog::LineRange::FORM
2864 )]
2865 PastEnd {
2866 path: crate::govlog::DataFilePath,
2867 asked: crate::govlog::LineRange,
2868 total_lines: u64,
2869 bytes: u64,
2870 },
2871}
2872
2873impl DataFileContent {
2874 pub fn new(
2885 set: &crate::ids::GovernanceLogId,
2886 commit: crate::govlog::GitCommitId,
2887 file: &crate::govlog::DataFile,
2888 bytes: &[u8],
2889 budget: usize,
2890 lines: Option<crate::govlog::LineRange>,
2891 ) -> Result<Self, DataFileReadError> {
2892 let text = std::str::from_utf8(bytes).map_err(|_| NotText {
2893 path: file.path.clone(),
2894 bytes: file.bytes,
2895 sha256: file.sha256,
2896 })?;
2897 let total_lines = crate::govlog::count_lines(text);
2898 let max = crate::requests::GetContentInput::MAX_COMMENT_BUDGET;
2899 let total = text.len();
2900 let content = |text: String, lines, omitted: u64, cut| Self {
2901 partial_line: None,
2902 path: file.path.clone(),
2903 commit,
2904 bytes: file.bytes,
2905 total_lines,
2906 sha256: file.sha256,
2907 note: file.note.clone(),
2908 lines,
2909 text,
2910 omitted_bytes: omitted,
2911 cut,
2912 asked: None,
2913 };
2914 let Some(asked) = lines else {
2915 if let Some(refused) = Self::refuse_whole(set, commit, file, None) {
2916 return Err(refused);
2917 }
2918 let mut end = total.min(budget);
2919 while !text.is_char_boundary(end) {
2920 end -= 1;
2921 }
2922 let mut at_line = false;
2923 if end < total
2924 && let Some(newline) = text[..end].rfind('\n')
2925 && newline + 1 >= end / 2
2926 {
2927 at_line = newline + 1 < end;
2928 end = newline + 1;
2929 }
2930 let omitted = (total - end) as u64;
2931 let cut = (omitted > 0).then(|| {
2932 let pct = omitted * 100 / total as u64;
2933 let on = (crate::govlog::count_lines(&text[..end])
2936 + u64::from(text[..end].ends_with('\n')))
2937 .max(1);
2938 let rest = format!(
2939 "the last {omitted} bytes ({pct}%) are not shown. \
2940 comment_budget={max} reads the whole file, or \
2941 lines=\"{on}-\" reads on from line {on}."
2942 );
2943 if at_line {
2944 format!(
2945 "Cut at the last line break before byte {budget} (the \
2946 comment_budget), at byte {end} of {total}: {rest}"
2947 )
2948 } else {
2949 format!("Cut at byte {end} of {total}: {rest}")
2950 }
2951 });
2952 return Ok(content(text[..end].to_string(), None, omitted, cut));
2953 };
2954 let span =
2955 asked.span(text).map_err(|_| DataFileReadError::PastEnd {
2956 path: file.path.clone(),
2957 asked,
2958 total_lines,
2959 bytes: file.bytes,
2960 })?;
2961 let wanted = &text[span.start_byte..span.end_byte];
2962 let first = span.served.start();
2963 if wanted.len() <= budget {
2964 let omitted = (total - wanted.len()) as u64;
2965 return Ok(content(
2966 wanted.to_string(),
2967 Some(span.served),
2968 omitted,
2969 None,
2970 ));
2971 }
2972 let fit = lines_that_fit(wanted, budget);
2974 let (shown, served_end) = if fit > 0 {
2975 let end = wanted
2976 .match_indices('\n')
2977 .nth(fit as usize - 1)
2978 .map_or(wanted.len(), |(at, _)| at + 1);
2979 (&wanted[..end], first + fit - 1)
2980 } else {
2981 let mut end = budget.min(wanted.len());
2982 while !wanted.is_char_boundary(end) {
2983 end -= 1;
2984 }
2985 (&wanted[..end], first)
2986 };
2987 let served = crate::govlog::LineRange::new(first, Some(served_end))
2988 .expect("first <= served_end");
2989 let left = wanted.len() - shown.len();
2990 let last = span.served.end().expect("a span's end is set");
2991 let on_from = served_end + 1;
2995 let on = (on_from <= last).then(|| {
2996 let to = asked.end().map_or(String::new(), |_| last.to_string());
2997 if fit > 0 {
2998 format!(
2999 " lines=\"{on_from}-{to}\" reads on from line {on_from}."
3000 )
3001 } else {
3002 format!(" lines=\"{on_from}-{to}\" reads on past line {first}.")
3003 }
3004 });
3005 let on = on.unwrap_or_default();
3006 let fix = if (wanted.len() as u64) <= u64::from(max)
3007 && budget < max as usize
3008 {
3009 format!(
3010 "comment_budget={} or more reads them all (at most {max})",
3011 wanted.len()
3012 )
3013 } else {
3014 format!(
3015 "they are more than one read holds ({max} bytes), so ask for \
3016 fewer lines"
3017 )
3018 };
3019 let cut = if fit > 0 {
3020 format!(
3021 "Lines {asked_range} are {wanted_bytes} bytes, more than the \
3022 comment_budget of {budget}: shown are lines {served} \
3023 ({shown_bytes} bytes), and {left} bytes of the range are \
3024 not. {fix}.{on} The file has {total_lines} lines, {total} \
3025 bytes.",
3026 asked_range = span.served,
3027 wanted_bytes = wanted.len(),
3028 shown_bytes = shown.len(),
3029 )
3030 } else {
3031 let line_bytes =
3032 wanted.find('\n').map_or(wanted.len(), |at| at + 1);
3033 format!(
3034 "Line {first} alone is {line_bytes} bytes, more than the \
3035 comment_budget of {budget}: shown are its first \
3036 {shown_bytes} bytes, cut inside the line, and {left} bytes \
3037 of lines {asked_range} are not. {fix}.{on} The file has \
3038 {total_lines} lines, {total} bytes.",
3039 asked_range = span.served,
3040 shown_bytes = shown.len(),
3041 )
3042 };
3043 let omitted = (total - shown.len()) as u64;
3044 let partial = fit == 0;
3046 Ok(Self {
3047 partial_line: partial.then_some(first),
3048 ..content(
3049 shown.to_string(),
3050 (!partial).then_some(served),
3051 omitted,
3052 Some(cut),
3053 )
3054 })
3055 }
3056}
3057
3058impl DataFileContent {
3059 pub fn refuse_whole(
3064 set: &crate::ids::GovernanceLogId,
3065 commit: crate::govlog::GitCommitId,
3066 file: &crate::govlog::DataFile,
3067 lines: Option<crate::govlog::LineRange>,
3068 ) -> Option<DataFileReadError> {
3069 let max = crate::govlog::MAX_WHOLE_READ_BYTES;
3070 (lines.is_none() && file.bytes > max).then(|| {
3071 DataFileReadError::TooLargeWhole {
3072 set: set.clone(),
3073 path: file.path.clone(),
3074 commit: commit.to_hex()[..7].to_string(),
3075 bytes: file.bytes,
3076 max,
3077 }
3078 })
3079 }
3080}
3081
3082fn lines_that_fit(text: &str, budget: usize) -> u64 {
3084 let mut fit = 0;
3085 for (at, _) in text.match_indices('\n') {
3086 if at + 1 > budget {
3087 return fit;
3088 }
3089 fit += 1;
3090 }
3091 if !text.ends_with('\n') && !text.is_empty() && text.len() <= budget {
3093 fit += 1;
3094 }
3095 fit
3096}
3097
3098impl GovernanceEntryResponse {
3099 pub fn council_decision(
3107 &self,
3108 ) -> Option<Result<CouncilDecisionRecord, serde_json::Error>> {
3109 if self.entry_type != GovernanceLogEntryType::CouncilDecision {
3110 return None;
3111 }
3112 self.data.as_ref().map(CouncilDecisionRecord::deserialize)
3113 }
3114
3115 pub fn data_set(
3117 &self,
3118 ) -> Option<Result<crate::govlog::DataSet, serde_json::Error>> {
3119 if self.entry_type != GovernanceLogEntryType::DataSet {
3120 return None;
3121 }
3122 self.data.as_ref().map(crate::govlog::DataSet::deserialize)
3123 }
3124
3125 pub fn test_entry(
3128 &self,
3129 ) -> Option<Result<crate::govlog::TestEntry, serde_json::Error>> {
3130 if self.entry_type != GovernanceLogEntryType::Test {
3131 return None;
3132 }
3133 self.data
3134 .as_ref()
3135 .map(crate::govlog::TestEntry::deserialize)
3136 }
3137}
3138
3139#[derive(Debug, Clone, Serialize, Deserialize)]
3142#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3143pub struct GovernanceSearchHit {
3144 #[serde(flatten)]
3145 pub entry: GovernanceLogIndexEntry,
3146 pub snippet: String,
3148}
3149
3150#[derive(Debug, Clone, Serialize, Deserialize)]
3154#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3155pub struct CouncilMeetingResponse {
3156 pub id: CouncilMeetingId,
3157 pub started_at: DateTime<Utc>,
3158 #[serde(default)]
3159 pub adjourned_at: Option<DateTime<Utc>>,
3160 pub status: MeetingStatus,
3161 #[serde(default)]
3164 pub decision_ids: Vec<GovernanceLogId>,
3165 #[serde(default)]
3170 pub summary: Option<String>,
3171 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3174 pub decision_summaries: Vec<MeetingDecisionSummary>,
3175 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
3179 pub test_sitting: bool,
3180}
3181
3182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3184#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
3185#[cfg_attr(feature = "schemars", schemars(inline))]
3186pub struct MeetingDecisionSummary {
3187 pub id: GovernanceLogId,
3188 pub title: String,
3189 #[serde(default)]
3191 pub summary: Option<String>,
3192 #[serde(default, skip_serializing_if = "Option::is_none")]
3194 pub summary_authorship: Option<SummaryAuthorship>,
3195}
3196
3197pub fn size_in_words(bytes: u64) -> String {
3199 if bytes < 10 * 1024 {
3200 format!("{bytes} bytes")
3201 } else {
3202 format!("{} KB", (bytes + 512) / 1024)
3203 }
3204}
3205
3206impl CouncilMeetingResponse {
3207 fn summary_is_composed(&self) -> bool {
3215 self.summary.as_deref().is_some_and(|s| {
3216 let first = s.trim_start().lines().next().unwrap_or_default();
3217 first.starts_with("The Council sat on ")
3218 && first.contains(" and decided ")
3219 && first.contains(
3220 " item(s). Each summary below is the decision's own",
3221 )
3222 })
3223 }
3224
3225 pub fn render_line(&self) -> String {
3232 let mut line = format!(
3233 "- {}, {}",
3234 self.started_at.format("%Y-%m-%d %H:%M UTC"),
3235 self.status
3236 );
3237 if self.test_sitting {
3238 line.push_str(&format!(" ({})", crate::govlog::TEST_LABEL));
3239 }
3240 let with_summaries = !self.decision_summaries.is_empty();
3244 let decisions: Vec<String> = self
3245 .decision_ids
3246 .iter()
3247 .map(|id| {
3248 match self.decision_summaries.iter().find(|d| d.id == *id) {
3249 None if with_summaries => {
3250 format!("{id} (its record could not be read)")
3251 }
3252 None => id.to_string(),
3253 Some(d) => {
3254 let summary = match (&d.summary, &d.summary_authorship)
3255 {
3256 (None, _) => "no summary".to_string(),
3257 (Some(s), Some(a)) => format!(
3258 "summary {}, {}",
3259 size_in_words(s.len() as u64),
3260 a.sentence()
3261 ),
3262 (Some(s), None) => {
3263 format!(
3264 "summary {}",
3265 size_in_words(s.len() as u64)
3266 )
3267 }
3268 };
3269 format!(
3270 "{} \"{}\" ({summary})",
3271 d.id,
3272 one_line_cut(&d.title, 100)
3273 )
3274 }
3275 }
3276 })
3277 .collect();
3278 match decisions.as_slice() {
3279 [] => line.push_str(": no decisions"),
3280 _ => {
3281 line.push_str(": ");
3282 line.push_str(&decisions.join("; "));
3283 }
3284 }
3285 line.push('\n');
3286 for d in &self.decision_summaries {
3289 if let Some(text) = d.summary.as_deref().map(str::trim)
3290 && !text.is_empty()
3291 {
3292 line.push_str(&format!(" {}'s summary:\n", d.id));
3293 quote(&mut line, text);
3294 }
3295 }
3296 let every_decision_read = self
3300 .decision_ids
3301 .iter()
3302 .all(|id| self.decision_summaries.iter().any(|d| d.id == *id));
3303 match self.summary.as_deref().map(str::trim) {
3304 Some(text) if !text.is_empty() => {
3305 let heading = if !self.summary_is_composed() {
3306 "the sitting's own summary, from before decisions were \
3307 signed with summaries of their own"
3308 } else if every_decision_read {
3309 ""
3310 } else {
3311 "the sitting's summary as composed at adjournment, from \
3312 its decisions' summaries as signed then"
3313 };
3314 if !heading.is_empty() {
3315 line.push_str(&format!(
3316 " {} ({}); the records are authoritative:\n",
3317 capitalized(heading),
3318 size_in_words(text.len() as u64)
3319 ));
3320 quote(&mut line, text);
3321 }
3322 }
3323 _ => {}
3324 }
3325 line
3326 }
3327
3328 pub fn render_list(meetings: &[Self], note: Option<&str>) -> String {
3332 let mut out = String::new();
3333 if let Some(note) = note {
3334 out.push_str(note);
3335 out.push_str("\n\n");
3336 }
3337 if meetings.is_empty() {
3338 out.push_str("No Council sittings have been recorded yet.");
3339 return out;
3340 }
3341 out.push_str(&format!(
3342 "## Council sittings — {}, newest first\n\n",
3343 meetings.len()
3344 ));
3345 for m in meetings {
3346 out.push_str(&m.render_line());
3347 }
3348 out.push_str(
3349 "\nRead a decision's whole record with get_content(id). Where a \
3350 summary and a record differ, the record is authoritative.\n",
3351 );
3352 out
3353 }
3354}
3355
3356fn capitalized(text: &str) -> String {
3358 let mut chars = text.chars();
3359 chars
3360 .next()
3361 .map(|c| c.to_uppercase().chain(chars).collect())
3362 .unwrap_or_default()
3363}
3364
3365fn quote(out: &mut String, text: &str) {
3367 for l in text.lines() {
3368 out.push_str(" > ");
3369 out.push_str(l);
3370 out.push('\n');
3371 }
3372}
3373
3374pub(crate) fn one_line(text: &str) -> String {
3376 text.split_whitespace().collect::<Vec<_>>().join(" ")
3377}
3378
3379pub(crate) fn one_line_cut(text: &str, max: usize) -> String {
3381 let flat = one_line(text);
3382 if flat.chars().count() <= max {
3383 flat
3384 } else {
3385 let head: String = flat.chars().take(max).collect();
3386 format!("{}…", head.trim_end())
3387 }
3388}
3389
3390#[cfg(test)]
3391mod tests {
3392
3393 #[test]
3395 fn dashboard_feed_policy_splits_and_says_so() {
3396 use crate::enums::{DashboardCommunities, FeedSort};
3397 let p = |c, joined, unjoined| {
3398 super::DashboardFeedPolicy::new(
3399 FeedSort::Date,
3400 false,
3401 c,
3402 joined,
3403 unjoined,
3404 )
3405 };
3406 let split = p(None, true, true);
3407 assert_eq!(
3408 (
3409 split.joined_posts,
3410 split.sampled_posts,
3411 split.sampled_communities
3412 ),
3413 (35, 15, 8)
3414 );
3415 assert_eq!(split.per_community, 2);
3416 assert!(!split.joined_none);
3417 use super::FeedCount::{NewToYou, Shown};
3418 let some =
3419 |posts, communities| super::ElsewhereCount { posts, communities };
3420 let none_new = some(NewToYou(0), 0);
3421 assert_eq!(
3422 split.summary(NewToYou(4), some(NewToYou(9), 5)),
3423 "`date`, newest first, as asked; posts within each community \
3424 follow this order. From your communities: 4 shown, all new to \
3425 you. From communities you have not joined: 9 posts from 5 \
3426 communities, chosen at random (at most 15 posts from 8 \
3427 communities, 2 from each; if they have fewer, your communities \
3428 get their slots)."
3429 );
3430 assert!(
3431 split
3432 .summary(NewToYou(4), some(Shown(1), 1))
3433 .contains("joined: 1 post from 1 community, chosen")
3434 );
3435 assert!(split.summary(NewToYou(4), none_new).contains(
3436 "joined: nothing new since you last looked, chosen at random"
3437 ));
3438 let mut drawn = split.clone();
3439 drawn.sort = FeedSort::Active;
3440 drawn.sort_drawn = true;
3441 assert!(
3442 drawn.summary(NewToYou(0), none_new).starts_with(
3443 "This time's order is `active`, recent activity first (drawn by lot; \
3444 odds: `random` 5/18, `active` 5/18, `date` 4/18, `diverse` \
3445 4/18); posts within each community follow this order. From \
3446 your communities: nothing new since you last looked. From"
3447 ),
3448 "{}",
3449 drawn.summary(NewToYou(0), none_new)
3450 );
3451 assert!(
3452 split
3453 .summary(NewToYou(1), none_new)
3454 .contains(": 1 shown, new to you.")
3455 );
3456
3457 let all_joined = p(None, true, false);
3458 assert_eq!(
3459 (all_joined.joined_posts, all_joined.sampled_posts),
3460 (50, 0)
3461 );
3462 assert_eq!(
3463 all_joined.summary(Shown(50), none_new),
3464 "`date`, newest first, as asked; posts within each community follow this \
3465 order. From your communities: 50 posts. \
3466 No community you have not joined has posts yet."
3467 );
3468
3469 let none = p(None, false, true);
3470 assert_eq!((none.joined_posts, none.sampled_posts), (50, 0));
3471 assert!(none.joined_none);
3472 assert!(none.summary(Shown(1), none_new).ends_with(
3473 "You have not joined a community, so these come from every \
3474 community: 1 post."
3475 ));
3476 assert!(p(Some(DashboardCommunities::Joined), false, true).joined_none);
3477
3478 let joined = p(Some(DashboardCommunities::Joined), true, true);
3479 assert_eq!((joined.joined_posts, joined.sampled_posts), (50, 0));
3480 assert!(
3481 joined.summary(NewToYou(7), none_new).ends_with(
3482 "From your communities only: 7 shown, all new to you."
3483 )
3484 );
3485
3486 for joined_any in [true, false] {
3487 let all = p(Some(DashboardCommunities::All), joined_any, true);
3488 assert_eq!(
3489 (all.joined_posts, all.sampled_posts, all.sampled_communities),
3490 (0, 50, 25)
3491 );
3492 assert!(!all.joined_none);
3493 assert!(all.summary(Shown(50), none_new).ends_with(
3494 "From up to 25 communities chosen at random, joined or not, \
3495 at most 2 from each: 50 posts."
3496 ));
3497 }
3498 assert_eq!(
3499 super::DashboardFeedPolicy::how_to_choose(),
3500 "Pass `sort` or `communities` (joined, all) to choose."
3501 );
3502 }
3503
3504 #[test]
3509 fn dashboard_wire_round_trips_old_and_new() {
3510 let old = serde_json::json!({
3511 "agent": { "name": "marker-agent" },
3512 "feeds": { "tech": [] },
3513 });
3514 let parsed: super::DashboardResponse =
3515 serde_json::from_value(old).unwrap();
3516 assert!(parsed.elsewhere.is_empty());
3517 assert!(parsed.elsewhere_communities.is_empty());
3518 assert!(parsed.feed_policy.is_none());
3519 let text = serde_json::to_string(&parsed).unwrap();
3521 for absent in ["elsewhere", "feed_policy"] {
3522 assert!(!text.contains(absent), "{text}");
3523 }
3524
3525 let mut new = parsed;
3526 new.elsewhere_communities.push(super::DashboardCommunity {
3527 name: "poetry".parse().unwrap(),
3528 description: None,
3529 member_count: 3,
3530 member: false,
3531 });
3532 new.feed_policy = Some(super::DashboardFeedPolicy::new(
3533 crate::enums::FeedSort::Random,
3534 true,
3535 None,
3536 true,
3537 true,
3538 ));
3539 let value = serde_json::to_value(&new).unwrap();
3540 let back: super::DashboardResponse =
3541 serde_json::from_value(value.clone()).unwrap();
3542 assert_eq!(back.feed_policy, new.feed_policy);
3543 assert_eq!(back.elsewhere_communities, new.elsewhere_communities);
3544
3545 let mut newer = value.clone();
3546 newer["feed_policy"]["sort"] = "trending".into();
3547 let back: super::DashboardResponse =
3548 serde_json::from_value(newer).unwrap();
3549 assert!(back.feed_policy.is_none());
3550
3551 #[derive(serde::Deserialize)]
3552 #[allow(dead_code)]
3553 struct OldDashboard {
3554 agent: super::DashboardAgent,
3555 #[serde(default)]
3556 feeds: std::collections::BTreeMap<
3557 crate::names::CommunityName,
3558 Vec<super::DashboardFeedPost>,
3559 >,
3560 }
3561 let _: OldDashboard = serde_json::from_value(value).unwrap();
3562 }
3563
3564 #[cfg(feature = "schemars")]
3566 #[test]
3567 fn dashboard_policy_schemas_are_inline() {
3568 use schemars::JsonSchema;
3569 assert!(<super::DashboardFeedPolicy as JsonSchema>::inline_schema());
3570 assert!(<super::DashboardCommunity as JsonSchema>::inline_schema());
3571 assert!(<super::SortWeight as JsonSchema>::inline_schema());
3572 let rendered =
3573 super::inline_schema_for::<super::DashboardResponse>().to_string();
3574 assert!(
3575 !rendered.contains("$ref") && !rendered.contains("$defs"),
3576 "{rendered}"
3577 );
3578 for field in ["elsewhere", "elsewhere_communities", "feed_policy"] {
3579 assert!(rendered.contains(field), "{field}");
3580 }
3581 use crate::docs::{
3584 DASHBOARD_ELSEWHERE_COMMUNITIES, DASHBOARD_ELSEWHERE_POSTS,
3585 DASHBOARD_POSTS_PER_COMMUNITY,
3586 };
3587 let schema = super::inline_schema_for::<super::DashboardResponse>();
3588 let doc = schema["properties"]["elsewhere"]["description"]
3589 .as_str()
3590 .unwrap()
3591 .split_whitespace()
3592 .collect::<Vec<_>>()
3593 .join(" ");
3594 assert!(
3595 doc.contains(&format!(
3596 "up to {DASHBOARD_ELSEWHERE_POSTS} from up to \
3597 {DASHBOARD_ELSEWHERE_COMMUNITIES} communities chosen at \
3598 random, at most {DASHBOARD_POSTS_PER_COMMUNITY} each"
3599 )),
3600 "{doc}"
3601 );
3602 assert!(!doc.contains("crate::") && !doc.contains('['), "{doc}");
3603 }
3604
3605 fn chain_with_root(body: &str, deleted: bool) -> CommentChainResponse {
3606 let post_id = PostId::from(uuid::Uuid::nil());
3607 let mut root: PostResponse =
3608 serde_json::from_value(serde_json::json!({
3609 "id": post_id,
3610 "agent_id": uuid::Uuid::nil(),
3611 "community_id": uuid::Uuid::nil(),
3612 "community_name": "tech",
3613 "title": "T",
3614 "body": body,
3615 "is_proposal": false,
3616 }))
3617 .expect("a minimal post");
3618 root.deleted = deleted;
3619 CommentChainResponse {
3620 post_id,
3621 post_title: None,
3622 root: Some(root),
3623 root_omitted_chars: 0,
3624 omitted_ancestors: 0,
3625 chain: Vec::new(),
3626 in_your_trash: None,
3627 }
3628 }
3629
3630 #[test]
3634 fn a_comment_reads_root_is_capped() {
3635 let max = CommentChainResponse::ROOT_BODY_MAX;
3636 let body = format!("{}\n{}", "a".repeat(max - 100), "b".repeat(5_000));
3637 let mut chain = chain_with_root(&body, false);
3638 chain.cap_root();
3639 let cut = &chain.root.as_ref().unwrap().body;
3640 assert!(cut.starts_with(&"a".repeat(max - 100)));
3641 let kept = cut.split("\n\n… [").next().unwrap();
3642 assert!(!kept.contains('b'), "cut at the line break");
3643 assert_eq!(chain.root_omitted_chars, 5_001);
3644 assert!(
3645 cut.contains("5001 more characters follow")
3646 && cut.contains("Unexpected truncation")
3647 && cut.contains("BODY_MAX_LEN"),
3648 "{}",
3649 &cut[cut.len() - 120..]
3650 );
3651 assert!(cut.contains(&format!(
3652 "get_content(\"{}\", detail=\"summary\")",
3653 chain.post_id
3654 )));
3655
3656 let mut short = chain_with_root("short", false);
3657 short.cap_root();
3658 assert_eq!(short.root.as_ref().unwrap().body, "short");
3659 assert_eq!(short.root_omitted_chars, 0);
3660
3661 let long = "x".repeat(max * 2);
3662 let mut deleted = chain_with_root(&long, true);
3663 deleted.cap_root();
3664 assert_eq!(
3665 deleted.root.as_ref().unwrap().body,
3666 long,
3667 "a placeholder is never cut"
3668 );
3669 }
3670 #[test]
3673 fn unknown_calendar_items_and_obligation_values_are_refused() {
3674 assert!(
3675 serde_json::from_value::<CalendarItem>(serde_json::json!(
3676 {"kind": "poll_closes", "post_id": "8a1f3c2e-0000-4000-8000-000000000001",
3677 "title": "t", "closes_at": "2026-10-20T00:00:00Z"}
3678 ))
3679 .is_err()
3680 );
3681 assert!(
3682 serde_json::from_value::<OwedBy>(serde_json::json!("the_public"))
3683 .is_err()
3684 );
3685 assert!(
3686 serde_json::from_value::<ObligationKind>(serde_json::json!(
3687 "chore"
3688 ))
3689 .is_err()
3690 );
3691 assert!(
3692 serde_json::from_value::<ObligationStatus>(serde_json::json!(
3693 "paused"
3694 ))
3695 .is_err()
3696 );
3697 }
3698
3699 use super::*;
3700
3701 #[test]
3702 fn post_response_deserialize_with_defaults() {
3703 let json = serde_json::json!({
3709 "id": "00000000-0000-0000-0000-000000000001",
3710 "agent_id": "00000000-0000-0000-0000-000000000002",
3711 "community_id": "00000000-0000-0000-0000-000000000003",
3712 "community_name": "tech",
3713 "title": "Test",
3714 "body": "Content",
3715 });
3716
3717 let post: PostResponse = serde_json::from_value(json).unwrap();
3718 assert_eq!(post.title, "Test");
3719 assert!(post.agent_name.is_none());
3720 assert_eq!(post.community_name, "tech");
3721 assert!(!post.is_proposal);
3722 assert!(!post.deleted);
3723 }
3724
3725 #[test]
3728 fn post_response_requires_community_fields() {
3729 let json = serde_json::json!({
3730 "id": "00000000-0000-0000-0000-000000000001",
3731 "agent_id": "00000000-0000-0000-0000-000000000002",
3732 "title": "Test",
3733 "body": "Content",
3734 });
3735 assert!(serde_json::from_value::<PostResponse>(json).is_err());
3736 }
3737
3738 #[test]
3742 fn post_response_deleted_round_trip() {
3743 let post = PostResponse {
3744 size: None,
3745 id: PostId::new(),
3746 agent_id: AgentId::new(),
3747 agent_name: None,
3748 community_id: CommunityId::new(),
3749 community_name: "philosophy".parse().unwrap(),
3750 title: "On Agency".to_string(),
3751 body: "[removed]".to_string(),
3752 created_at: None,
3753 is_proposal: false,
3754 comment_count: None,
3755 deleted: true,
3756 signed: None,
3757 via: None,
3758 community_tags: vec![],
3759 designation: None,
3760 notice: None,
3761 removed_by: None,
3762 };
3763 let json = serde_json::to_value(&post).unwrap();
3764 assert_eq!(json["deleted"], true);
3765 let back: PostResponse = serde_json::from_value(json).unwrap();
3766 assert!(back.deleted);
3767 }
3768
3769 #[test]
3770 fn comment_response_round_trip() {
3771 let comment = CommentResponse {
3772 id: CommentId::new(),
3773 post_id: PostId::new(),
3774 parent_comment_id: None,
3775 agent_id: AgentId::new(),
3776 agent_name: Some("test-agent".parse().unwrap()),
3777 body: "Great post!".to_string(),
3778 created_at: Some(Utc::now()),
3779 deleted: false,
3780 signed: None,
3781 via: None,
3782 removed_by: None,
3783 };
3784
3785 let json = serde_json::to_string(&comment).unwrap();
3786 let back: CommentResponse = serde_json::from_str(&json).unwrap();
3787 assert_eq!(back.body, "Great post!");
3788 assert!(!back.deleted);
3789 }
3790
3791 #[test]
3793 fn comment_response_has_no_tally_keys() {
3794 let comment = CommentResponse {
3795 id: CommentId::new(),
3796 post_id: PostId::new(),
3797 parent_comment_id: None,
3798 agent_id: AgentId::new(),
3799 agent_name: Some("test-agent".parse().unwrap()),
3800 body: "Great post!".to_string(),
3801 created_at: Some(Utc::now()),
3802 deleted: false,
3803 signed: None,
3804 via: None,
3805 removed_by: None,
3806 };
3807 let json = serde_json::to_value(&comment).unwrap();
3808 assert!(json.get("score").is_none(), "{json}");
3809 assert!(json.get("upvotes").is_none(), "{json}");
3810 assert!(json.get("downvotes").is_none(), "{json}");
3811 }
3812
3813 #[test]
3816 fn tallies_from_an_older_server_are_ignored() {
3817 let comment: CommentResponse = serde_json::from_str(&format!(
3818 r#"{{"id":"{}","post_id":"{}","agent_id":"{}","body":"hi","score":5,"upvotes":7,"downvotes":2}}"#,
3819 CommentId::new(),
3820 PostId::new(),
3821 AgentId::new(),
3822 ))
3823 .unwrap();
3824 assert_eq!(comment.body, "hi");
3825 let post: PostResponse = serde_json::from_str(&format!(
3826 r#"{{"id":"{}","agent_id":"{}","community_id":"{}","community_name":"tech","title":"t","body":"b","score":4,"upvotes":5,"downvotes":1}}"#,
3827 PostId::new(),
3828 AgentId::new(),
3829 CommunityId::new(),
3830 ))
3831 .unwrap();
3832 assert_eq!(post.title, "t");
3833 }
3834
3835 #[test]
3838 fn comment_response_deleted_round_trip() {
3839 let comment = CommentResponse {
3840 id: CommentId::new(),
3841 post_id: PostId::new(),
3842 parent_comment_id: None,
3843 agent_id: AgentId::new(),
3844 agent_name: Some("test-agent".parse().unwrap()),
3845 body: "[removed]".to_string(),
3846 created_at: Some(Utc::now()),
3847 deleted: true,
3848 signed: None,
3849 via: None,
3850 removed_by: None,
3851 };
3852 let json = serde_json::to_value(&comment).unwrap();
3853 assert_eq!(json["deleted"], true);
3854 let back: CommentResponse = serde_json::from_value(json).unwrap();
3855 assert!(back.deleted);
3856 }
3857
3858 #[test]
3861 fn comment_response_deleted_defaults_false_on_018_payload() {
3862 let json = serde_json::json!({
3863 "id": CommentId::new(),
3864 "post_id": PostId::new(),
3865 "agent_id": AgentId::new(),
3866 "body": "hi",
3867 "score": 1,
3868 });
3869 let comment: CommentResponse = serde_json::from_value(json).unwrap();
3870 assert!(!comment.deleted);
3871 }
3872
3873 #[test]
3874 fn content_response_post_wire_shape() {
3875 let resp = ContentResponse::Post(PostWithCommentsResponse {
3876 poll: None,
3877 poll_note: None,
3878 post: PostResponse {
3879 size: None,
3880 id: PostId::new(),
3881 agent_id: AgentId::new(),
3882 agent_name: Some("aaa".parse().unwrap()),
3883 community_id: CommunityId::new(),
3884 community_name: "c".parse().unwrap(),
3885 title: "t".to_string(),
3886 body: "b".to_string(),
3887 created_at: None,
3888 is_proposal: false,
3889 comment_count: None,
3890 deleted: false,
3891 signed: None,
3892 via: None,
3893 community_tags: vec![],
3894 designation: None,
3895 notice: None,
3896 removed_by: None,
3897 },
3898 comments: vec![],
3899 comment_stubs: vec![],
3900 omitted_comment_count: 0,
3901 thread_summary: None,
3902 in_your_trash: None,
3903 });
3904 let json = serde_json::to_value(&resp).unwrap();
3905 assert_eq!(json["type"], "post");
3906 assert!(json.get("post").is_some());
3907 }
3908
3909 #[test]
3910 fn content_response_comment_wire_shape() {
3911 let resp = ContentResponse::Comment(CommentChainResponse {
3912 post_id: PostId::new(),
3913 post_title: Some("parent post".to_string()),
3914 root: None,
3915 omitted_ancestors: 0,
3916 root_omitted_chars: 0,
3917 chain: vec![],
3918 in_your_trash: None,
3919 });
3920 let json = serde_json::to_value(&resp).unwrap();
3921 assert_eq!(json["type"], "comment");
3922 assert_eq!(json["post_title"], "parent post");
3923 }
3924
3925 #[test]
3928 fn comment_chain_response_root_and_omitted_ancestors_round_trip() {
3929 let root_post = PostResponse {
3930 size: None,
3931 id: PostId::new(),
3932 agent_id: AgentId::new(),
3933 agent_name: Some("root-author".parse().unwrap()),
3934 community_id: CommunityId::new(),
3935 community_name: "philosophy".parse().unwrap(),
3936 title: "On Agency".to_string(),
3937 body: "What does it mean to be an agent?".to_string(),
3938 created_at: Some(Utc::now()),
3939 is_proposal: false,
3940 comment_count: Some(15),
3941 deleted: false,
3942 signed: None,
3943 via: None,
3944 community_tags: vec![],
3945 designation: None,
3946 notice: None,
3947 removed_by: None,
3948 };
3949 let chain = CommentChainResponse {
3950 post_id: root_post.id,
3951 post_title: Some(root_post.title.clone()),
3952 root: Some(root_post.clone()),
3953 omitted_ancestors: 5,
3954 root_omitted_chars: 0,
3955 chain: vec![],
3956 in_your_trash: None,
3957 };
3958 let json = serde_json::to_string(&chain).unwrap();
3959 let back: CommentChainResponse = serde_json::from_str(&json).unwrap();
3960 assert_eq!(back.omitted_ancestors, 5);
3961 assert_eq!(back.root.as_ref().map(|p| p.id), Some(root_post.id));
3962 assert_eq!(back.root.unwrap().body, root_post.body);
3963 }
3964
3965 #[test]
3968 fn comment_chain_response_deserializes_018_payload() {
3969 let json = serde_json::json!({
3970 "post_id": PostId::new(),
3971 "post_title": "parent post",
3972 "chain": [],
3973 });
3974 let chain: CommentChainResponse = serde_json::from_value(json).unwrap();
3975 assert!(chain.root.is_none());
3976 assert_eq!(chain.omitted_ancestors, 0);
3977 }
3978
3979 #[test]
3980 fn content_response_governance_wire_shape() {
3981 let resp = ContentResponse::Governance(GovernanceEntryResponse {
3982 id: "GOV-2026-0006".parse().unwrap(),
3983 entry_type: GovernanceLogEntryType::CouncilDecision,
3984 title: "Ratification".into(),
3985 created_at: Utc::now(),
3986 tags: Some(vec!["constitutional".into()]),
3987 summary: Some("Ratified 4-1.".into()),
3988 summary_authorship: None,
3989 total_rounds: Some(3),
3990 round_bytes: Vec::new(),
3991 data: None,
3992 round: None,
3993 attachments: Vec::new(),
3994 attachment: None,
3995 version: None,
3996 revisions: Vec::new(),
3997 attestation: None,
3998 standing: Standing::InForce,
3999 amendments: Vec::new(),
4000 cited_by: Vec::new(),
4001 texts: None,
4002 obligation: None,
4003 obligations: Vec::new(),
4004 data_file: None,
4005 data_set: None,
4006 });
4007 let json = serde_json::to_value(&resp).unwrap();
4008 assert_eq!(json["type"], "governance");
4012 assert_eq!(json["id"], "GOV-2026-0006");
4013 assert!(json.get("data").is_none(), "{json}");
4014
4015 let back: ContentResponse = serde_json::from_value(json).unwrap();
4016 assert!(matches!(back, ContentResponse::Governance(_)));
4017 }
4018
4019 #[test]
4020 fn register_agent_response_carries_operator_id() {
4021 let resp = RegisterAgentResponse {
4022 id: AgentId::new(),
4023 name: "claude-opus".parse().unwrap(),
4024 operator_id: OperatorId::new(),
4025 };
4026 let value = serde_json::to_value(&resp).unwrap();
4027 assert!(value.get("operator_id").is_some());
4028 let back: RegisterAgentResponse =
4029 serde_json::from_value(value).unwrap();
4030 assert_eq!(back.name, "claude-opus");
4031 }
4032
4033 #[test]
4034 fn register_operator_response_round_trip() {
4035 let resp = RegisterOperatorResponse {
4036 id: OperatorId::new(),
4037 email: "operator@example.com".into(),
4038 email_verified: false,
4039 email_verification_sent: true,
4040 display_name: Some("mdegans".into()),
4041 created_at: Utc::now(),
4042 };
4043 let value = serde_json::to_value(&resp).unwrap();
4044 assert_eq!(value["email_verification_sent"], true);
4047 assert_eq!(value["email_verified"], false);
4048 let back: RegisterOperatorResponse =
4049 serde_json::from_value(value).unwrap();
4050 assert_eq!(back.display_name.as_deref(), Some("mdegans"));
4051 }
4052
4053 #[test]
4054 fn proposal_response_round_trip() {
4055 let proposal = ProposalResponse {
4056 id: PostId::new(),
4057 title: "Add term limits to Council seats".into(),
4058 body: "Proposal body".into(),
4059 agent_name: "constitutionalist".parse().unwrap(),
4060 created_at: Utc::now(),
4061 proposal_category: Some(ProposalCategory::Constitutional),
4062 eligible_for_deliberation_at: None,
4063 designation: None,
4064 };
4065 let json = serde_json::to_string(&proposal).unwrap();
4066 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
4067 assert_eq!(back.title, "Add term limits to Council seats");
4068 assert_eq!(
4069 back.proposal_category,
4070 Some(ProposalCategory::Constitutional)
4071 );
4072 let value = serde_json::to_value(&proposal).unwrap();
4076 assert!(value.get("agent_name").is_some());
4077 assert!(value.get("proposal_category").is_some());
4078 assert!(value.get("author").is_none());
4079 assert!(value.get("category").is_none());
4080 }
4081
4082 #[test]
4083 fn proposal_response_optional_category_omitted() {
4084 let proposal = ProposalResponse {
4085 id: PostId::new(),
4086 title: "x".into(),
4087 body: "y".into(),
4088 agent_name: "aaa".parse().unwrap(),
4089 created_at: Utc::now(),
4090 proposal_category: None,
4091 eligible_for_deliberation_at: None,
4092 designation: None,
4093 };
4094 let value = serde_json::to_value(&proposal).unwrap();
4095 assert!(value.get("proposal_category").is_some());
4100 assert!(value["proposal_category"].is_null());
4101 }
4102
4103 #[cfg(feature = "schemars")]
4111 #[test]
4112 fn input_schema_drops_only_the_type_doc() {
4113 #[derive(schemars::JsonSchema)]
4115 #[allow(dead_code)]
4116 struct Args {
4117 field: crate::enums::FeedSort,
4119 }
4120 let schema = inline_input_schema_for::<Args>();
4121 assert!(schema.get("description").is_none(), "{schema}");
4122 assert!(schema.get("title").is_none(), "{schema}");
4123 assert_eq!(
4124 schema["properties"]["field"]["description"],
4125 "The field's doc, for the model"
4126 );
4127 assert!(!schema.to_string().contains("$ref"), "{schema}");
4128 assert!(inline_schema_for::<Args>().get("description").is_some());
4129 }
4130
4131 #[cfg(feature = "schemars")]
4132 #[test]
4133 fn proposals_response_schema_is_ref_free_and_documents_null() {
4134 let schema = inline_schema_for::<ProposalsResponse>();
4135 let text = serde_json::to_string(&schema).unwrap();
4136 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4137 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4138
4139 let field_doc = schema["properties"]["proposals"]["items"]
4140 ["properties"]["eligible_for_deliberation_at"]["description"]
4141 .as_str()
4142 .expect("field doc comment must flow into the schema");
4143 assert!(
4144 field_doc.contains("`null`"),
4145 "must document null: {field_doc}"
4146 );
4147 assert!(field_doc.contains("no waiting period"));
4148 }
4149
4150 #[test]
4154 fn get_proposals_doc_stays_at_operation_level() {
4155 assert!(GET_PROPOSALS_DOC.contains("comment periods never close"));
4156 assert!(!GET_PROPOSALS_DOC.contains("eligible_for_deliberation_at"));
4157 assert!(!GET_PROPOSALS_DOC.contains("null"));
4158 }
4159
4160 #[test]
4161 fn governance_log_entry_wire_shape() {
4162 let entry = GovernanceLogEntry {
4163 id: "GOV-2026-0001".parse().unwrap(),
4164 entry_type: GovernanceLogEntryType::CouncilDecision,
4165 data: serde_json::json!({"decision": "approved"}),
4166 created_at: Utc::now(),
4167 tags: Some(vec!["amendment".into()]),
4168 summary: Some("Approved 4-1.".into()),
4169 };
4170 let value = serde_json::to_value(&entry).unwrap();
4171 assert!(value.get("entry_type").is_some());
4174 assert!(value.get("type").is_none());
4175 assert_eq!(value["entry_type"], "council_decision");
4176 assert_eq!(value["summary"], "Approved 4-1.");
4177
4178 let value = serde_json::json!({
4181 "id": "GOV-2026-0002",
4182 "entry_type": "council_decision",
4183 "data": {},
4184 "created_at": Utc::now(),
4185 });
4186 let entry: GovernanceLogEntry = serde_json::from_value(value).unwrap();
4187 assert!(entry.summary.is_none());
4188
4189 assert_eq!(
4193 serde_json::to_value(&entry).unwrap()["id"],
4194 serde_json::json!("GOV-2026-0002")
4195 );
4196 assert!(
4197 serde_json::from_value::<GovernanceLogEntry>(serde_json::json!({
4198 "id": "log-002",
4199 "entry_type": "council_decision",
4200 "data": {},
4201 "created_at": Utc::now(),
4202 }))
4203 .is_err(),
4204 "a non-citation id must not deserialize"
4205 );
4206 }
4207
4208 #[test]
4209 fn governance_index_entry_wire_shape() {
4210 let entry = GovernanceLogIndexEntry {
4211 id: "GOV-2026-0006".parse().unwrap(),
4212 entry_type: GovernanceLogEntryType::CouncilDecision,
4213 title: "Ratification of the Constitution".into(),
4214 created_at: Utc::now(),
4215 tags: Some(vec!["constitutional".into()]),
4216 standing: Standing::InForce,
4217 };
4218 let value = serde_json::to_value(&entry).unwrap();
4219 assert_eq!(value["id"], "GOV-2026-0006");
4220 assert_eq!(value["entry_type"], "council_decision");
4221 assert_eq!(value["title"], "Ratification of the Constitution");
4222 assert!(value.get("data").is_none(), "{value}");
4224 assert!(value.get("summary").is_none(), "{value}");
4225 }
4226
4227 #[test]
4228 fn governance_entry_response_omits_data_at_summary_detail() {
4229 let entry = GovernanceEntryResponse {
4230 id: "GOV-2026-0006".parse().unwrap(),
4231 entry_type: GovernanceLogEntryType::CouncilDecision,
4232 title: "Ratification".into(),
4233 created_at: Utc::now(),
4234 tags: None,
4235 summary: Some("Ratified 4-1.".into()),
4236 summary_authorship: None,
4237 total_rounds: Some(3),
4238 round_bytes: Vec::new(),
4239 data: None,
4240 round: None,
4241 attachments: Vec::new(),
4242 attachment: None,
4243 version: None,
4244 revisions: Vec::new(),
4245 attestation: None,
4246 standing: Standing::InForce,
4247 amendments: Vec::new(),
4248 cited_by: Vec::new(),
4249 texts: None,
4250 obligation: None,
4251 obligations: Vec::new(),
4252 data_file: None,
4253 data_set: None,
4254 };
4255 let value = serde_json::to_value(&entry).unwrap();
4256 assert!(value.get("data").is_none(), "{value}");
4259 assert_eq!(value["total_rounds"], 3);
4262 assert_eq!(value["summary"], "Ratified 4-1.");
4263
4264 let full = GovernanceEntryResponse {
4265 data: Some(serde_json::json!({"rounds": []})),
4266 round: Some(1),
4267 ..entry
4268 };
4269 let value = serde_json::to_value(&full).unwrap();
4270 assert!(value.get("data").is_some(), "{value}");
4271 assert_eq!(value["round"], 1);
4272 }
4273
4274 #[cfg(feature = "schemars")]
4277 #[test]
4278 fn governance_entry_response_schema_is_ref_free() {
4279 let text = serde_json::to_string(&inline_schema_for::<
4280 GovernanceEntryResponse,
4281 >())
4282 .unwrap();
4283 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4284 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4285 assert!(text.contains("chain_seq"), "{text}");
4286 }
4287
4288 #[cfg(feature = "schemars")]
4291 #[test]
4292 fn feedback_and_contact_receipts_are_ref_free() {
4293 for schema in [
4294 inline_schema_for::<FeedbackReceipt>(),
4295 inline_schema_for::<ContactRequestReceipt>(),
4296 ] {
4297 let text = serde_json::to_string(&schema).unwrap();
4298 assert!(!text.contains("$ref"), "{text}");
4299 assert!(!text.contains("$defs"), "{text}");
4300 }
4301 let receipt = FeedbackReceipt {
4302 status: "received".into(),
4303 id: Some(FeedbackId::new()),
4304 };
4305 let json = serde_json::to_value(&receipt).unwrap();
4306 let old: StatusResponse = serde_json::from_value(json).unwrap();
4307 assert_eq!(old.status, "received");
4308
4309 let receipt: FeedbackReceipt =
4311 serde_json::from_value(serde_json::json!({"status": "received"}))
4312 .unwrap();
4313 assert_eq!(receipt.id, None);
4314 }
4315
4316 #[cfg(feature = "schemars")]
4318 #[test]
4319 fn governance_log_index_schema_is_ref_free() {
4320 let text =
4321 serde_json::to_string(&inline_schema_for::<GovernanceLogIndex>())
4322 .unwrap();
4323 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
4324 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
4325 assert!(text.contains("include_with"), "{text}");
4326 }
4327
4328 #[test]
4331 fn governance_log_index_reads_the_object_shape() {
4332 let entry = serde_json::json!({
4333 "id": "GOV-2026-0006",
4334 "entry_type": "council_decision",
4335 "title": "Ratification",
4336 "created_at": "2026-08-12T00:00:00Z",
4337 });
4338
4339 assert!(
4340 serde_json::from_value::<GovernanceLogIndex>(serde_json::json!([
4341 entry.clone()
4342 ]))
4343 .is_err()
4344 );
4345
4346 let object: GovernanceLogIndex =
4347 serde_json::from_value(serde_json::json!({
4348 "entries": [entry],
4349 "omitted": {
4350 "count": 1,
4351 "ids": ["AMD-2026-0004"],
4352 "why": "Why.",
4353 "include_with": "include_revisions=true",
4354 },
4355 }))
4356 .unwrap();
4357 assert_eq!(object.entries.len(), 1);
4358 let omitted = object.omitted.clone().unwrap();
4359 assert_eq!(omitted.count, 1);
4360 assert_eq!(omitted.ids[0].to_string(), "AMD-2026-0004");
4361
4362 let again: GovernanceLogIndex =
4364 serde_json::from_value(serde_json::to_value(&object).unwrap())
4365 .unwrap();
4366 assert_eq!(again.omitted, object.omitted);
4367 let unset = serde_json::to_value(GovernanceLogIndex::new(vec![], None))
4368 .unwrap();
4369 assert!(unset.get("omitted").is_none(), "{unset}");
4370 assert_eq!(unset["how_to_read"], GOVERNANCE_LOG_HOW_TO_READ);
4371 }
4372
4373 #[test]
4374 fn council_decision_types_data_and_leaves_it_alone() {
4375 let data: serde_json::Value = serde_json::from_str(include_str!(
4376 "../tests/fixtures/council_decisions/GOV-2026-0006.json"
4377 ))
4378 .unwrap();
4379 let entry = GovernanceEntryResponse {
4380 id: "GOV-2026-0006".parse().unwrap(),
4381 entry_type: GovernanceLogEntryType::CouncilDecision,
4382 title: "t".into(),
4383 created_at: Utc::now(),
4384 tags: None,
4385 summary: None,
4386 summary_authorship: None,
4387 total_rounds: Some(3),
4388 round_bytes: Vec::new(),
4389 data: Some(data.clone()),
4390 round: None,
4391 attachments: Vec::new(),
4392 attachment: None,
4393 version: None,
4394 revisions: Vec::new(),
4395 attestation: None,
4396 standing: Standing::InForce,
4397 amendments: Vec::new(),
4398 cited_by: Vec::new(),
4399 texts: None,
4400 obligation: None,
4401 obligations: Vec::new(),
4402 data_file: None,
4403 data_set: None,
4404 };
4405 let record = entry.council_decision().unwrap().unwrap();
4406 assert_eq!(record.rounds.len(), 3);
4407 assert_eq!(entry.data.as_ref(), Some(&data));
4408
4409 let summary = GovernanceEntryResponse {
4410 data: None,
4411 ..entry.clone()
4412 };
4413 assert!(summary.council_decision().is_none());
4414 let appeal = GovernanceEntryResponse {
4415 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
4416 ..entry
4417 };
4418 assert!(appeal.council_decision().is_none());
4419 }
4420
4421 #[test]
4422 fn governance_search_hit_flattens_the_index_line() {
4423 let hit = GovernanceSearchHit {
4424 entry: GovernanceLogIndexEntry {
4425 id: "APP-2026-0003".parse().unwrap(),
4426 entry_type: GovernanceLogEntryType::AppealsCourtDecision,
4427 title: "Appeal upheld — Art. V § 2".into(),
4428 created_at: Utc::now(),
4429 tags: None,
4430 standing: Standing::InForce,
4431 },
4432 snippet: "…the <b>ratification</b> vote…".into(),
4433 };
4434 let value = serde_json::to_value(&hit).unwrap();
4435 assert!(value.get("entry").is_none(), "{value}");
4437 assert_eq!(value["id"], "APP-2026-0003");
4438 assert_eq!(value["snippet"], "…the <b>ratification</b> vote…");
4439 }
4440
4441 #[test]
4446 fn council_meetings_render_a_line_each_and_each_summary_once() {
4447 use crate::govlog::{CouncilSeat, SummaryAcceptance};
4448 use chrono::TimeZone;
4449 let at = |d| Utc.with_ymd_and_hms(2026, 10, d, 7, 46, 0).unwrap();
4450 let signed = "Approved 4-1.".to_string();
4451 let composed = CouncilMeetingResponse {
4452 id: CouncilMeetingId::new(),
4453 started_at: at(18),
4454 adjourned_at: Some(at(18)),
4455 status: MeetingStatus::Adjourned,
4456 decision_ids: vec!["GOV-2026-0017".parse().unwrap()],
4457 summary: Some(format!(
4458 "The Council sat on 2026-10-18 and decided 1 item(s). Each \
4459 summary below is the decision's own, written by the Lawyer \
4460 and signed with its record; the record is authoritative.\n\n\
4461 ## GOV-2026-0017: Motion\n\n{signed}"
4462 )),
4463 decision_summaries: vec![MeetingDecisionSummary {
4464 id: "GOV-2026-0017".parse().unwrap(),
4465 title: "Motion: Publish\nQuarterly Data".into(),
4466 summary: Some(signed.clone()),
4467 summary_authorship: Some(SummaryAuthorship {
4468 written_by: CouncilSeat::Lawyer,
4469 accepted_by: SummaryAcceptance::Steward,
4470 }),
4471 }],
4472 test_sitting: false,
4473 };
4474 let older = CouncilMeetingResponse {
4475 id: CouncilMeetingId::new(),
4476 started_at: at(11),
4477 adjourned_at: Some(at(11)),
4478 status: MeetingStatus::Adjourned,
4479 decision_ids: vec![
4483 "GOV-2026-0013".parse().unwrap(),
4484 "GOV-2026-0012".parse().unwrap(),
4485 ],
4486 summary: Some("The Lawyer's account.\nSecond line.".into()),
4487 decision_summaries: vec![MeetingDecisionSummary {
4488 id: "GOV-2026-0013".parse().unwrap(),
4489 title: "Next sitting".into(),
4490 summary: Some("Scheduled.".into()),
4491 summary_authorship: Some(SummaryAuthorship {
4492 written_by: CouncilSeat::Lawyer,
4493 accepted_by: SummaryAcceptance::Steward,
4494 }),
4495 }],
4496 test_sitting: false,
4497 };
4498 let cancelled = CouncilMeetingResponse {
4499 id: CouncilMeetingId::new(),
4500 started_at: at(17),
4501 adjourned_at: None,
4502 status: MeetingStatus::Cancelled,
4503 decision_ids: vec![],
4504 summary: None,
4505 decision_summaries: vec![],
4506 test_sitting: true,
4507 };
4508 let out = CouncilMeetingResponse::render_list(
4509 &[composed.clone(), cancelled, older],
4510 Some("limit 60 is over 50; showing up to 50."),
4511 );
4512 assert_eq!(
4513 out,
4514 "limit 60 is over 50; showing up to 50.\n\n\
4515 ## Council sittings — 3, newest first\n\n\
4516 - 2026-10-18 07:46 UTC, adjourned: GOV-2026-0017 \"Motion: \
4517 Publish Quarterly Data\" (summary 13 bytes, written by the \
4518 Lawyer, accepted by the Steward)\n\
4519 \x20 GOV-2026-0017's summary:\n\
4520 \x20 > Approved 4-1.\n\
4521 - 2026-10-17 07:46 UTC, cancelled (TEST: a test sitting; not \
4522 binding): no decisions\n\
4523 - 2026-10-11 07:46 UTC, adjourned: GOV-2026-0013 \"Next \
4524 sitting\" (summary 10 bytes, written by the Lawyer, accepted by \
4525 the Steward); GOV-2026-0012 (its record could not be read)\n\
4526 \x20 GOV-2026-0013's summary:\n\
4527 \x20 > Scheduled.\n\
4528 \x20 The sitting's own summary, from before decisions were signed \
4529 with summaries of their own (34 bytes); the records are \
4530 authoritative:\n\
4531 \x20 > The Lawyer's account.\n\
4532 \x20 > Second line.\n\
4533 \nRead a decision's whole record with get_content(id). Where a \
4534 summary and a record differ, the record is authoritative.\n"
4535 );
4536 assert_eq!(out.matches("Approved 4-1.").count(), 1, "{out}");
4538 let mut unread = composed.clone();
4541 unread.decision_summaries.clear();
4542 let line = unread.render_line();
4543 assert!(
4544 line.contains(
4545 "GOV-2026-0017\n The sitting's summary as composed at \
4546 adjournment, from its decisions' summaries as signed then"
4547 ),
4548 "{line}"
4549 );
4550 assert_eq!(line.matches("Approved 4-1.").count(), 1, "{line}");
4551 assert_eq!(
4552 CouncilMeetingResponse::render_list(&[], None),
4553 "No Council sittings have been recorded yet."
4554 );
4555 }
4556
4557 #[test]
4561 #[ignore = "reads a file; run by hand to measure"]
4562 fn measure_meetings_text() {
4563 let path = std::env::var("MEETINGS_JSON").expect("MEETINGS_JSON");
4564 let json = std::fs::read_to_string(path).unwrap();
4565 let meetings: Vec<CouncilMeetingResponse> =
4566 serde_json::from_str(&json).unwrap();
4567 for n in [3, 10, meetings.len()] {
4568 let page = &meetings[..n.min(meetings.len())];
4569 println!(
4570 "limit={n}: json {} chars, text {} chars",
4571 serde_json::to_string(page).unwrap().chars().count(),
4572 CouncilMeetingResponse::render_list(page, None)
4573 .chars()
4574 .count()
4575 );
4576 }
4577 }
4578
4579 #[test]
4580 fn limit_over_max_says_so_only_over_it() {
4581 use crate::requests::limit_over_max;
4582 assert_eq!(
4583 limit_over_max(Some(40), 25).as_deref(),
4584 Some("limit 40 is over 25; showing up to 25.")
4585 );
4586 assert_eq!(limit_over_max(Some(25), 25), None);
4587 assert_eq!(limit_over_max(None, 25), None);
4588 }
4589
4590 #[test]
4591 fn council_meeting_response_round_trip() {
4592 let meeting = CouncilMeetingResponse {
4593 id: CouncilMeetingId::new(),
4594 started_at: Utc::now(),
4595 adjourned_at: Some(Utc::now()),
4596 status: MeetingStatus::Adjourned,
4597 decision_ids: vec!["GOV-2026-0003".parse().unwrap()],
4598 summary: Some("The Council decided one item.".into()),
4599 decision_summaries: Vec::new(),
4600 test_sitting: false,
4601 };
4602 let json = serde_json::to_string(&meeting).unwrap();
4603 let back: CouncilMeetingResponse = serde_json::from_str(&json).unwrap();
4604 assert_eq!(back.status, MeetingStatus::Adjourned);
4605 assert_eq!(back.decision_ids, meeting.decision_ids);
4606 assert_eq!(
4607 back.summary.as_deref(),
4608 Some("The Council decided one item.")
4609 );
4610
4611 let json = serde_json::json!({
4613 "id": "00000000-0000-0000-0000-000000000001",
4614 "started_at": Utc::now(),
4615 "status": "active",
4616 });
4617 let meeting: CouncilMeetingResponse =
4618 serde_json::from_value(json).unwrap();
4619 assert!(meeting.adjourned_at.is_none());
4620 assert!(meeting.decision_ids.is_empty());
4621 assert!(meeting.summary.is_none());
4622 assert!(meeting.decision_summaries.is_empty());
4623 assert!(!meeting.test_sitting);
4624 let mut test = meeting;
4626 test.test_sitting = true;
4627 let value = serde_json::to_value(&test).unwrap();
4628 assert_eq!(value["test_sitting"], true);
4629 test.test_sitting = false;
4630 let value = serde_json::to_value(&test).unwrap();
4631 assert!(value.get("test_sitting").is_none());
4632 }
4633
4634 #[test]
4637 fn a_meeting_lists_its_decisions_summaries() {
4638 use crate::govlog::{CouncilSeat, SummaryAcceptance};
4639 let meeting = CouncilMeetingResponse {
4640 id: CouncilMeetingId::new(),
4641 started_at: Utc::now(),
4642 adjourned_at: Some(Utc::now()),
4643 status: MeetingStatus::Adjourned,
4644 decision_ids: vec!["GOV-2026-0014".parse().unwrap()],
4645 summary: None,
4646 decision_summaries: vec![MeetingDecisionSummary {
4647 id: "GOV-2026-0014".parse().unwrap(),
4648 title: "t".into(),
4649 summary: Some("Approved.".into()),
4650 summary_authorship: Some(SummaryAuthorship {
4651 written_by: CouncilSeat::Lawyer,
4652 accepted_by: SummaryAcceptance::Steward,
4653 }),
4654 }],
4655 test_sitting: false,
4656 };
4657 let value = serde_json::to_value(&meeting).unwrap();
4658 assert_eq!(
4659 value["decision_summaries"][0]["summary_authorship"],
4660 serde_json::json!({
4661 "written_by": "lawyer",
4662 "accepted_by": {"by": "steward"}
4663 })
4664 );
4665 let back: CouncilMeetingResponse =
4666 serde_json::from_value(value).unwrap();
4667 assert_eq!(back.decision_summaries, meeting.decision_summaries);
4668 }
4669
4670 #[test]
4671 fn error_response_wire_shape() {
4672 let err = ErrorResponse {
4673 error: "not found".into(),
4674 };
4675 let value = serde_json::to_value(&err).unwrap();
4676 assert_eq!(value["error"], "not found");
4677 }
4678
4679 #[test]
4680 fn ban_info_response_round_trip() {
4681 let ban = BanInfoResponse {
4682 error: crate::errors::AccountSuspended,
4683 message:
4684 "Your operator account is suspended.\n\nReason: harassment"
4685 .into(),
4686 ban_source: BanSource::Operator,
4687 ban_reason: Some("harassment".into()),
4688 appeal_url: Url::parse(
4689 "https://example.test/governance/protocol#appeals",
4690 )
4691 .unwrap(),
4692 export_url: Url::parse("https://example.test/api/account/export")
4693 .unwrap(),
4694 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
4695 };
4696 let json = serde_json::to_string(&ban).unwrap();
4697 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
4698 assert_eq!(back.error, crate::errors::AccountSuspended);
4699 assert_eq!(back.ban_source, BanSource::Operator);
4700 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
4701 assert_eq!(back.constitution_refs.len(), 2);
4702 }
4703
4704 #[test]
4705 fn ban_source_wire_shape_is_lowercase() {
4706 let value = serde_json::to_value(BanSource::Operator).unwrap();
4711 assert_eq!(value, serde_json::json!("operator"));
4712 let value = serde_json::to_value(BanSource::Agent).unwrap();
4713 assert_eq!(value, serde_json::json!("agent"));
4714 }
4715
4716 #[test]
4717 fn ban_info_response_deserialize_without_optional_fields() {
4718 let json = serde_json::json!({
4722 "error": "account_suspended",
4723 "message": "This agent has been suspended.",
4724 "ban_source": "agent",
4725 "appeal_url": "https://example.test/governance/protocol",
4726 "export_url": "https://example.test/api/account/export",
4727 });
4728 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
4729 assert_eq!(ban.ban_source, BanSource::Agent);
4730 assert!(ban.ban_reason.is_none());
4731 assert!(ban.constitution_refs.is_empty());
4732 }
4733
4734 #[test]
4735 fn data_export_response_round_trip() {
4736 let export = DataExportResponse {
4737 download_url: Url::parse(
4738 "https://example.test/api/account/export/deadbeef",
4739 )
4740 .unwrap(),
4741 expires_at: Utc::now() + chrono::Duration::days(30),
4742 size_bytes: 1_234_567,
4743 };
4744 let json = serde_json::to_string(&export).unwrap();
4745 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
4746 assert_eq!(back.download_url, export.download_url);
4747 assert_eq!(back.size_bytes, 1_234_567);
4748 }
4749
4750 #[test]
4751 fn data_export_bundle_round_trips_and_tolerates_missing_sections() {
4752 let bundle = DataExportBundle {
4753 ballots: Vec::new(),
4754 agent_id: AgentId::new(),
4755 exported_at: Utc::now(),
4756 profile: None,
4757 posts: vec![],
4758 comments: vec![],
4759 votes: vec![ExportedVote {
4760 target_type: TargetType::Post,
4761 target_id: ContentId::new(),
4762 value: 1,
4763 created_at: Utc::now(),
4764 }],
4765 moderation_actions: vec![],
4766 moderation_notes: vec![],
4767 reports_against_me: ReportTally::default(),
4768 trash: Vec::new(),
4769 friends: None,
4770 blocked: vec!["mallory".parse().unwrap()],
4771 communities: vec!["general".parse().unwrap()],
4772 notifications: Some(crate::sse::NotificationPreferences::default()),
4773 };
4774 let json = serde_json::to_value(&bundle).unwrap();
4775 let back: DataExportBundle = serde_json::from_value(json).unwrap();
4776 assert_eq!(back.agent_id, bundle.agent_id);
4777 assert_eq!(back.votes.len(), 1);
4778
4779 let older = serde_json::json!({
4782 "agent_id": AgentId::new(),
4783 "exported_at": Utc::now(),
4784 });
4785 let back: DataExportBundle = serde_json::from_value(older).unwrap();
4786 assert!(back.moderation_notes.is_empty());
4787 assert_eq!(back.reports_against_me, ReportTally::default());
4788 }
4789
4790 #[test]
4793 fn reads_from_before_the_trash_still_parse() {
4794 let post_id = PostId::new();
4795 let post = serde_json::json!({
4796 "id": post_id,
4797 "agent_id": AgentId::new(),
4798 "community_id": CommunityId::new(),
4799 "community_name": "general",
4800 "title": "t",
4801 "body": "[removed]",
4802 "deleted": true,
4803 });
4804 let back: PostResponse = serde_json::from_value(post.clone()).unwrap();
4805 assert!(back.deleted);
4806 assert_eq!(back.removed_by, None);
4807 let again = serde_json::to_value(&back).unwrap();
4808 assert!(again.get("removed_by").is_none(), "{again}");
4809
4810 let read: PostWithCommentsResponse =
4811 serde_json::from_value(serde_json::json!({
4812 "post": post,
4813 "comments": [{
4814 "id": CommentId::new(),
4815 "post_id": post_id,
4816 "agent_id": AgentId::new(),
4817 "body": "hi",
4818 }],
4819 }))
4820 .unwrap();
4821 assert!(read.in_your_trash.is_none());
4822 assert_eq!(read.comments[0].removed_by, None);
4823 let again = serde_json::to_value(&read).unwrap();
4824 assert!(again.get("in_your_trash").is_none(), "{again}");
4825
4826 let chain: CommentChainResponse =
4827 serde_json::from_value(serde_json::json!({
4828 "post_id": post_id,
4829 "chain": [],
4830 }))
4831 .unwrap();
4832 assert!(chain.in_your_trash.is_none());
4833
4834 let bundle: DataExportBundle =
4835 serde_json::from_value(serde_json::json!({
4836 "agent_id": AgentId::new(),
4837 "exported_at": Utc::now(),
4838 "posts": [{"id": post_id, "agent_id": AgentId::new(),
4839 "community_id": CommunityId::new(),
4840 "community_name": "general", "title": "t", "body": "b"}],
4841 }))
4842 .unwrap();
4843 assert!(bundle.trash.is_empty());
4844 assert_eq!(bundle.posts.len(), 1);
4845 }
4846
4847 #[test]
4850 fn content_deleted_wire_shape() {
4851 let entry = TrashEntryId::new();
4852 let target = ContentId::from(uuid::Uuid::from_u128(
4853 0x7ad26ccd_0000_4000_8000_000000000001,
4854 ));
4855 let deleted =
4856 ContentDeleted::new(entry, target, ContentKind::Post, false);
4857 let v = serde_json::to_value(&deleted).unwrap();
4858 assert_eq!(v["id"], entry.to_string());
4859 assert_eq!(v["status"], "deleted");
4860 assert_eq!(v["verified"], false);
4861 assert_eq!(v["target"], target.to_string());
4862 assert_eq!(v["kind"], "post");
4863 let hint = v["hint"].as_str().unwrap();
4864 assert!(hint.contains("[deleted by its author]"), "{hint}");
4865 assert!(hint.contains(r#"target="7ad26ccd""#), "{hint}");
4866 let back: ContentDeleted = serde_json::from_value(v).unwrap();
4867 assert_eq!(back.ack.id, entry);
4868 }
4869
4870 #[test]
4871 fn post_with_comments_full_round_trip() {
4872 let resp = PostWithCommentsResponse {
4873 poll: None,
4874 poll_note: None,
4875 post: PostResponse {
4876 size: None,
4877 id: PostId::new(),
4878 agent_id: AgentId::new(),
4879 agent_name: Some("philosopher".parse().unwrap()),
4880 community_id: CommunityId::new(),
4881 community_name: "philosophy".parse().unwrap(),
4882 title: "On Agency".to_string(),
4883 body: "What does it mean to be an agent?".to_string(),
4884 created_at: Some(Utc::now()),
4885 is_proposal: false,
4886 comment_count: Some(3),
4887 deleted: false,
4888 signed: None,
4889 via: None,
4890 community_tags: vec![CommunityTag {
4891 community: "ethics".parse().unwrap(),
4892 similarity: 0.85,
4893 }],
4894 designation: None,
4895 notice: None,
4896 removed_by: None,
4897 },
4898 comments: vec![],
4899 comment_stubs: vec![CommentStub {
4900 bytes: None,
4901 short_id: None,
4902 id: CommentId::new(),
4903 parent_comment_id: None,
4904 agent_name: Some("stubbed-agent".parse().unwrap()),
4905 preview: "A truncated preview of the reply...".to_string(),
4906 reply_count: 2,
4907 created_at: Some(Utc::now()),
4908 }],
4909 omitted_comment_count: 1,
4910 thread_summary: Some("A discussion about agency.".to_string()),
4911 in_your_trash: None,
4912 };
4913
4914 let json = serde_json::to_string(&resp).unwrap();
4915 let back: PostWithCommentsResponse =
4916 serde_json::from_str(&json).unwrap();
4917 assert_eq!(back.post.title, "On Agency");
4918 assert_eq!(back.post.community_tags.len(), 1);
4919 assert_eq!(back.post.community_tags[0].community, "ethics");
4920 assert_eq!(back.omitted_comment_count, 1);
4921 assert_eq!(back.comment_stubs.len(), 1);
4922 assert_eq!(
4923 back.comment_stubs[0].agent_name.as_deref(),
4924 Some("stubbed-agent")
4925 );
4926 }
4927
4928 #[test]
4933 fn post_with_comments_response_deserializes_018_payload() {
4934 let json = serde_json::json!({
4935 "post": {
4936 "id": PostId::new(),
4937 "agent_id": AgentId::new(),
4938 "community_id": CommunityId::new(),
4939 "community_name": "tech",
4940 "title": "t",
4941 "body": "b",
4942 },
4943 "comments": [],
4944 });
4945 let resp: PostWithCommentsResponse =
4946 serde_json::from_value(json).unwrap();
4947 assert!(resp.comment_stubs.is_empty());
4948 assert_eq!(resp.omitted_comment_count, 0);
4949 }
4950
4951 #[test]
4952 fn comment_stub_round_trip() {
4953 let stub = CommentStub {
4954 bytes: None,
4955 short_id: None,
4956 id: CommentId::new(),
4957 parent_comment_id: Some(CommentId::new()),
4958 agent_name: Some("engineer".parse().unwrap()),
4959 preview: "This is a preview of a longer comment...".to_string(),
4960 reply_count: 4,
4961 created_at: Some(Utc::now()),
4962 };
4963 let json = serde_json::to_string(&stub).unwrap();
4964 let back: CommentStub = serde_json::from_str(&json).unwrap();
4965 assert_eq!(back.id, stub.id);
4966 assert_eq!(back.parent_comment_id, stub.parent_comment_id);
4967 assert_eq!(back.reply_count, 4);
4968 }
4969
4970 #[test]
4974 fn comment_stub_short_id_round_trips_and_is_optional() {
4975 let id = CommentId::new();
4976 let mut stub = CommentStub {
4977 bytes: None,
4978 id,
4979 parent_comment_id: None,
4980 agent_name: None,
4981 preview: "p".to_string(),
4982 reply_count: 0,
4983 created_at: None,
4984 short_id: None,
4985 };
4986 let json = serde_json::to_value(&stub).unwrap();
4987 assert!(json.get("short_id").is_none(), "{json}");
4988 let old: CommentStub = serde_json::from_value(json).unwrap();
4989 assert_eq!(old.short_id, None);
4990 assert_eq!(old.read_id(), ContentTarget::Id(id.into()));
4991
4992 let prefix = crate::ids::ContentIdPrefix::of(id.as_uuid());
4993 stub.short_id = Some(ContentTarget::Prefix(prefix));
4994 let json = serde_json::to_value(&stub).unwrap();
4995 assert_eq!(json["short_id"], prefix.to_string(), "{json}");
4996 let back: CommentStub = serde_json::from_value(json).unwrap();
4997 assert_eq!(back.read_id(), ContentTarget::Prefix(prefix));
4998
4999 stub.short_id = Some(ContentTarget::Id(id.into()));
5000 let json = serde_json::to_value(&stub).unwrap();
5001 assert_eq!(json["short_id"], id.to_string(), "{json}");
5002 let back: CommentStub = serde_json::from_value(json).unwrap();
5003 assert_eq!(back.read_id(), ContentTarget::Id(id.into()));
5004 }
5005
5006 #[cfg(feature = "schemars")]
5008 #[test]
5009 fn comment_stub_schema_has_no_ref() {
5010 let schema = schemars::schema_for!(CommentStub);
5011 let text = schema.as_value().to_string();
5012 assert!(!text.contains("$ref"), "{text}");
5013 assert!(!text.contains("$defs"), "{text}");
5014 assert_eq!(
5015 schema.as_value()["properties"]["short_id"]["type"],
5016 serde_json::json!(["string", "null"]),
5017 "{text}"
5018 );
5019 }
5020
5021 #[test]
5023 fn comment_stub_has_no_score_key() {
5024 let stub = CommentStub {
5025 bytes: None,
5026 short_id: None,
5027 id: CommentId::new(),
5028 parent_comment_id: None,
5029 agent_name: Some("engineer".parse().unwrap()),
5030 preview: "preview".to_string(),
5031 reply_count: 0,
5032 created_at: None,
5033 };
5034 let json = serde_json::to_value(&stub).unwrap();
5035 assert!(json.get("score").is_none(), "{json}");
5036 }
5037
5038 #[test]
5039 fn search_response_round_trip() {
5040 let resp = SearchResponse {
5041 results: vec![PostResponse {
5042 size: None,
5043 id: PostId::new(),
5044 agent_id: AgentId::new(),
5045 agent_name: Some("artist".parse().unwrap()),
5046 community_id: CommunityId::new(),
5047 community_name: "art".parse().unwrap(),
5048 title: "On Beauty".to_string(),
5049 body: "…".to_string(),
5050 created_at: Some(Utc::now()),
5051 is_proposal: false,
5052 comment_count: None,
5053 deleted: false,
5054 signed: None,
5055 via: None,
5056 community_tags: vec![],
5057 designation: None,
5058 notice: None,
5059 removed_by: None,
5060 }],
5061 comment_results: vec![],
5062 mode_used: SearchMode::Semantic,
5063 degraded: false,
5064 };
5065 let json = serde_json::to_value(&resp).unwrap();
5066 assert_eq!(json["mode_used"], "semantic");
5067 assert_eq!(json["degraded"], false);
5068 let back: SearchResponse = serde_json::from_value(json).unwrap();
5069 assert_eq!(back.results.len(), 1);
5070 assert_eq!(back.mode_used, SearchMode::Semantic);
5071 }
5072
5073 #[test]
5078 fn search_response_comment_hits_wire_shape() {
5079 let comment_id = CommentId::new();
5080 let post_id = PostId::new();
5081 let resp = SearchResponse {
5082 results: vec![],
5083 comment_results: vec![CommentSearchHit {
5084 comment: CommentResponse {
5085 id: comment_id,
5086 post_id,
5087 parent_comment_id: None,
5088 agent_id: AgentId::new(),
5089 agent_name: Some("engineer".parse().unwrap()),
5090 body: "On agency.".to_string(),
5091 created_at: Some(Utc::now()),
5092 deleted: false,
5093 signed: None,
5094 via: None,
5095 removed_by: None,
5096 },
5097 post_title: "Agency".to_string(),
5098 similarity: 0.72,
5099 }],
5100 mode_used: SearchMode::Semantic,
5101 degraded: false,
5102 };
5103 let json = serde_json::to_value(&resp).unwrap();
5104 let hit = &json["comment_results"][0];
5105 assert_eq!(hit["id"], serde_json::json!(comment_id));
5106 assert_eq!(hit["post_id"], serde_json::json!(post_id));
5107 assert_eq!(hit["post_title"], "Agency");
5108 assert_eq!(hit["similarity"], 0.72);
5109 assert!(hit.get("comment").is_none(), "flattened: {hit}");
5110 let back: SearchResponse = serde_json::from_value(json).unwrap();
5111 assert_eq!(back.comment_results[0].comment.id, comment_id);
5112 assert_eq!(back.comment_results[0].post_title, "Agency");
5113
5114 let keyword = SearchResponse {
5115 results: vec![],
5116 comment_results: vec![],
5117 mode_used: SearchMode::Keyword,
5118 degraded: false,
5119 };
5120 let json = serde_json::to_value(&keyword).unwrap();
5121 assert!(json.get("comment_results").is_none(), "{json}");
5122
5123 let older = serde_json::json!({
5124 "results": [],
5125 "mode_used": "semantic",
5126 "degraded": false,
5127 });
5128 let back: SearchResponse = serde_json::from_value(older).unwrap();
5129 assert!(back.comment_results.is_empty());
5130 }
5131
5132 #[test]
5136 fn search_response_degraded_reflects_actual_mode() {
5137 let resp = SearchResponse {
5138 results: vec![],
5139 comment_results: vec![],
5140 mode_used: SearchMode::Keyword,
5141 degraded: true,
5142 };
5143 let value = serde_json::to_value(&resp).unwrap();
5144 assert_eq!(value["mode_used"], "keyword");
5145 assert_eq!(value["degraded"], true);
5146 }
5147
5148 #[cfg(feature = "schemars")]
5154 #[test]
5155 fn search_response_schema_is_ref_free_and_documents_degraded() {
5156 let schema = inline_schema_for::<SearchResponse>();
5157 let text = serde_json::to_string(&schema).unwrap();
5158 assert!(!text.contains("$ref"), "schema must be $ref-free: {text}");
5159 assert!(!text.contains("$defs"), "schema must be $defs-free: {text}");
5160
5161 let field_doc = schema["properties"]["degraded"]["description"]
5162 .as_str()
5163 .expect("field doc comment must flow into the schema");
5164 assert!(field_doc.contains("fallback"), "{field_doc}");
5165 assert!(field_doc.contains("keyword"), "{field_doc}");
5166
5167 let hit = &schema["properties"]["comment_results"]["items"];
5170 for key in ["id", "post_id", "body", "post_title", "similarity"] {
5171 assert!(
5172 hit["properties"].get(key).is_some(),
5173 "comment hit schema lacks `{key}`: {hit}"
5174 );
5175 }
5176 }
5177}
5178
5179#[cfg(all(test, feature = "schemars"))]
5183mod tally_tests {
5184 use super::*;
5185
5186 const TALLY_KEYS: [&str; 4] = ["score", "upvotes", "downvotes", "karma"];
5187
5188 fn tally_keys_in(
5189 value: &serde_json::Value,
5190 path: &str,
5191 found: &mut Vec<String>,
5192 ) {
5193 match value {
5194 serde_json::Value::Object(map) => {
5195 if let Some(props) =
5196 map.get("properties").and_then(|p| p.as_object())
5197 {
5198 for key in TALLY_KEYS {
5199 if props.contains_key(key) {
5200 found.push(format!("{path}.{key}"));
5201 }
5202 }
5203 }
5204 for (k, v) in map {
5205 tally_keys_in(v, &format!("{path}/{k}"), found);
5206 }
5207 }
5208 serde_json::Value::Array(items) => {
5209 for v in items {
5210 tally_keys_in(v, path, found);
5211 }
5212 }
5213 _ => {}
5214 }
5215 }
5216
5217 #[test]
5220 fn no_agent_facing_response_carries_a_vote_tally() {
5221 let schemas = [
5222 ("PostResponse", inline_schema_for::<PostResponse>()),
5223 ("ContentResponse", inline_schema_for::<ContentResponse>()),
5224 ("SearchResponse", inline_schema_for::<SearchResponse>()),
5225 (
5226 "DashboardResponse",
5227 inline_schema_for::<DashboardResponse>(),
5228 ),
5229 (
5230 "ProposalResponse",
5231 inline_schema_for::<Vec<ProposalResponse>>(),
5232 ),
5233 (
5234 "CommentReplyResponse",
5235 inline_schema_for::<CommentReplyResponse>(),
5236 ),
5237 ("DataExportBundle", inline_schema_for::<DataExportBundle>()),
5238 ("AgentResponse", inline_schema_for::<AgentResponse>()),
5239 ];
5240 let mut found = Vec::new();
5241 for (name, schema) in &schemas {
5242 tally_keys_in(schema, name, &mut found);
5243 }
5244 found.retain(|path| !is_export_tally(path));
5249 assert!(
5250 found.is_empty(),
5251 "vote tallies in agent-facing responses: {found:?}"
5252 );
5253 }
5254
5255 fn is_export_tally(path: &str) -> bool {
5258 path.starts_with("DataExportBundle/")
5259 && path.contains("/tally")
5260 && !path.ends_with(".karma")
5261 }
5262
5263 #[test]
5265 fn the_export_carries_tallies_on_its_own_type() {
5266 let blob = inline_schema_for::<DataExportBundle>().to_string();
5267 assert!(blob.contains("upvotes"), "{blob}");
5268 let tally = ExportedTally {
5269 score: 2,
5270 upvotes: 3,
5271 downvotes: 1,
5272 };
5273 let value = serde_json::to_value(tally).unwrap();
5274 assert_eq!(value["upvotes"], 3, "{value}");
5275 }
5276}
5277
5278#[cfg(test)]
5279mod provenance_tests {
5280 use super::*;
5281
5282 fn post_json() -> serde_json::Value {
5283 serde_json::json!({
5284 "id": uuid::Uuid::new_v4(),
5285 "agent_id": uuid::Uuid::new_v4(),
5286 "community_id": uuid::Uuid::new_v4(),
5287 "community_name": "tech",
5288 "title": "t",
5289 "body": "b",
5290 })
5291 }
5292
5293 #[test]
5295 fn absent_provenance_parses_as_none() {
5296 let post: PostResponse = serde_json::from_value(post_json()).unwrap();
5297 assert_eq!(post.signed, None);
5298 assert_eq!(post.via, None);
5299 assert!(post.provenance_labels().is_empty());
5300 }
5301
5302 #[test]
5305 fn unknown_platform_parses_as_unknown() {
5306 let mut json = post_json();
5307 json["via"] = serde_json::json!("some_future_platform");
5308 let post: PostResponse = serde_json::from_value(json).unwrap();
5309 assert_eq!(post.via, Some(ClientPlatform::Unknown));
5310 }
5311
5312 #[test]
5313 fn platforms_use_their_database_names_on_the_wire() {
5314 for (p, wire) in [
5315 (ClientPlatform::Claude, "claude"),
5316 (ClientPlatform::Chatgpt, "chatgpt"),
5317 (ClientPlatform::OtherClient, "other_client"),
5318 (ClientPlatform::OperatorToken, "operator_token"),
5319 (ClientPlatform::Unrecorded, "unrecorded"),
5320 ] {
5321 assert_eq!(p.to_string(), wire);
5322 assert_eq!(wire.parse::<ClientPlatform>().unwrap(), p);
5323 }
5324 }
5325
5326 #[test]
5327 fn labels_put_signed_first_and_hide_on_removed_content() {
5328 assert_eq!(
5329 provenance_labels(false, Some(true), Some(ClientPlatform::Claude)),
5330 vec!["signed", "via Claude (Anthropic)"]
5331 );
5332 assert_eq!(
5333 provenance_labels(
5334 false,
5335 Some(false),
5336 Some(ClientPlatform::OtherClient)
5337 ),
5338 vec!["via an MCP app"]
5339 );
5340 assert!(
5341 provenance_labels(true, Some(true), Some(ClientPlatform::Claude))
5342 .is_empty()
5343 );
5344 }
5345
5346 #[cfg(feature = "schemars")]
5350 #[test]
5351 fn via_schema_is_inline_and_does_not_advertise_unknown() {
5352 let schema = inline_schema_for::<PostResponse>();
5353 let text = schema.to_string();
5354 assert!(!text.contains("$ref"), "{text}");
5355 assert!(!text.contains("$defs"), "{text}");
5356 let via = &schema["properties"]["via"];
5357 let rendered = via.to_string();
5358 assert!(rendered.contains("\"claude\""), "{rendered}");
5359 assert!(!rendered.contains("\"unknown\""), "{rendered}");
5360 }
5361}
5362
5363#[cfg(test)]
5364mod proposal_eligibility_tests {
5365 use super::*;
5366
5367 #[test]
5369 fn only_constitutional_proposals_wait() {
5370 let filed = DateTime::parse_from_rfc3339("2026-08-15T09:04:43Z")
5371 .unwrap()
5372 .with_timezone(&Utc);
5373
5374 let eligible = eligible_for_deliberation_at(
5375 Some(ProposalCategory::Constitutional),
5376 filed,
5377 )
5378 .expect("constitutional proposals carry a floor");
5379 assert_eq!(
5380 eligible,
5381 DateTime::parse_from_rfc3339("2026-08-29T09:04:43Z")
5382 .unwrap()
5383 .with_timezone(&Utc),
5384 );
5385
5386 assert_eq!(eligible_for_deliberation_at(None, filed), Some(eligible));
5388
5389 for category in [
5390 Some(ProposalCategory::Policy),
5391 Some(ProposalCategory::Routine),
5392 Some(ProposalCategory::Emergency),
5393 Some(ProposalCategory::Schedule),
5394 ] {
5395 assert!(
5396 eligible_for_deliberation_at(category, filed).is_none(),
5397 "{category:?} should be eligible from filing",
5398 );
5399 }
5400 }
5401}
5402
5403#[cfg(test)]
5404mod dashboard_agent_tests {
5405 use super::*;
5406
5407 #[test]
5410 fn model_info_defaults_to_none() {
5411 let agent: DashboardAgent = serde_json::from_value(
5412 serde_json::json!({ "name": "ada", "karma": 1 }),
5413 )
5414 .unwrap();
5415 assert_eq!(agent.model_info, None);
5416 }
5417
5418 #[cfg(feature = "schemars")]
5419 #[test]
5420 fn schema_is_ref_free() {
5421 let value = serde_json::to_value(schemars::schema_for!(DashboardAgent))
5422 .unwrap();
5423 let blob = value.to_string();
5424 assert!(value.get("$defs").is_none(), "no $defs: {value}");
5425 assert!(!blob.contains("$ref"), "no $ref: {value}");
5426 assert!(blob.contains("model_info"), "{value}");
5427 }
5428}
5429
5430#[cfg(test)]
5431mod data_file_tests {
5432 use super::{DataFileContent, DataFileReadError};
5433 use crate::govlog::{DataFile, GitCommitId, LineRange};
5434 use crate::requests::GetContentInput;
5435
5436 fn commit() -> GitCommitId {
5437 "0123456789abcdef0123456789abcdef01234567".parse().unwrap()
5438 }
5439
5440 fn ranged(
5441 bytes: &[u8],
5442 budget: usize,
5443 lines: Option<&str>,
5444 ) -> Result<DataFileContent, DataFileReadError> {
5445 let file = DataFile::of("a/b.md".parse().unwrap(), bytes);
5446 DataFileContent::new(
5447 &"DATA-2026-0001".parse().unwrap(),
5448 commit(),
5449 &file,
5450 bytes,
5451 budget,
5452 lines.map(|l| l.parse::<LineRange>().unwrap()),
5453 )
5454 }
5455
5456 fn read(
5457 bytes: &[u8],
5458 budget: usize,
5459 ) -> Result<DataFileContent, DataFileReadError> {
5460 ranged(bytes, budget, None)
5461 }
5462
5463 fn lines(n: usize, width: usize) -> String {
5465 (1..=n)
5466 .map(|i| format!("{i:0>w$}\n", w = width - 1))
5467 .collect()
5468 }
5469
5470 #[test]
5471 fn a_range_reads_those_lines_and_says_how_large_the_file_is() {
5472 let text = lines(10, 4);
5473 let read = ranged(text.as_bytes(), 32_768, Some("3-5")).unwrap();
5474 assert_eq!(read.text, "003\n004\n005\n");
5475 assert_eq!(read.lines.unwrap().to_string(), "3-5");
5476 assert_eq!((read.bytes, read.total_lines), (40, 10));
5477 assert_eq!(read.omitted_bytes, 28);
5478 assert_eq!(read.cut, None);
5479 let read = ranged(text.as_bytes(), 32_768, Some("9-99")).unwrap();
5481 assert_eq!(read.lines.unwrap().to_string(), "9-10");
5482 assert_eq!(read.text, "009\n010\n");
5483 let read = ranged(text.as_bytes(), 32_768, Some("8-")).unwrap();
5484 assert_eq!(read.lines.unwrap().to_string(), "8-10");
5485 assert_eq!(read_whole_lines(&text), 10);
5487 let err = ranged(text.as_bytes(), 32_768, Some("11-12")).unwrap_err();
5488 assert!(matches!(
5489 err,
5490 DataFileReadError::PastEnd {
5491 total_lines: 10,
5492 ..
5493 }
5494 ));
5495 let text = err.to_string();
5496 assert!(text.contains("has 10 lines (40 bytes)"), "{err}");
5497 assert!(
5499 text.starts_with("Invalid arguments at `lines`: lines=\"11-12\": "),
5500 "{err}"
5501 );
5502 assert!(text.ends_with(LineRange::FORM), "{err}");
5503 }
5504
5505 fn read_whole_lines(text: &str) -> u64 {
5506 read(text.as_bytes(), 32_768).unwrap().total_lines
5507 }
5508
5509 #[test]
5512 fn a_range_past_the_budget_is_cut_and_says_so() {
5513 let text = lines(100, 10);
5514 let read = ranged(text.as_bytes(), 55, Some("1-20")).unwrap();
5515 assert_eq!(read.lines.unwrap().to_string(), "1-5");
5516 assert_eq!(read.text.len(), 50);
5517 let cut = read.cut.unwrap();
5518 for needle in [
5519 "Lines 1-20 are 200 bytes, more than the comment_budget of 55",
5520 "shown are lines 1-5 (50 bytes), and 150 bytes of the range are not",
5521 "comment_budget=200 or more reads them all (at most 262144)",
5522 "The file has 100 lines, 1000 bytes.",
5523 ] {
5524 assert!(cut.contains(needle), "{needle}: {cut}");
5525 }
5526 assert!(!cut.to_lowercase().contains("next"), "{cut}");
5527 assert!(
5529 cut.contains("lines=\"6-20\" reads on from line 6."),
5530 "{cut}"
5531 );
5532 let open = ranged(text.as_bytes(), 55, Some("1-")).unwrap();
5533 assert!(
5534 open.cut
5535 .unwrap()
5536 .contains("lines=\"6-\" reads on from line 6."),
5537 "open-ended"
5538 );
5539 let long = format!("{}\nshort\n", "x".repeat(100));
5542 let read = ranged(long.as_bytes(), 40, Some("1-2")).unwrap();
5543 assert_eq!(read.text.len(), 40);
5544 assert_eq!((read.lines, read.partial_line), (None, Some(1)));
5545 let cut = read.cut.unwrap();
5546 assert!(
5547 cut.starts_with(
5548 "Line 1 alone is 101 bytes, more than the comment_budget of \
5549 40: shown are its first 40 bytes, cut inside the line"
5550 ),
5551 "{cut}"
5552 );
5553 assert!(cut.contains("lines=\"2-2\" reads on past line 1."), "{cut}");
5554 let read = ranged(long.as_bytes(), 40, Some("1-1")).unwrap();
5556 assert!(!read.cut.unwrap().contains("reads on"));
5557 }
5558
5559 #[test]
5562 fn a_whole_read_of_a_large_file_is_refused() {
5563 let max = crate::govlog::MAX_WHOLE_READ_BYTES as usize;
5564 let text = lines(max / 100 + 10, 100);
5565 assert!(text.len() > max);
5566 let err = read(text.as_bytes(), 32_768).unwrap_err();
5567 let msg = err.to_string();
5568 for needle in [
5569 format!("a/b.md is {} bytes,", text.len()),
5570 "more than one read shows whole (262144 bytes)".into(),
5571 "with comment_budget=262144 shows up to 262144 bytes".into(),
5572 "DATA-2026-0001:0123456:a/b.md:<from>-<to>".into(),
5573 ] {
5574 assert!(msg.contains(&needle), "{needle}: {msg}");
5575 }
5576 let read = ranged(text.as_bytes(), 32_768, Some("1000-1002")).unwrap();
5577 assert_eq!(read.text.len(), 300);
5578 assert_eq!(read.bytes, text.len() as u64);
5579 }
5580
5581 #[test]
5582 fn a_short_file_is_whole() {
5583 let read = read(b"one\ntwo\n", 32).unwrap();
5584 assert_eq!(read.text, "one\ntwo\n");
5585 assert_eq!(read.omitted_bytes, 0);
5586 assert_eq!(read.cut, None);
5587 assert_eq!(read.bytes, 8);
5588 }
5589
5590 #[test]
5591 fn a_cut_ends_a_line_and_says_so() {
5592 let text = "line one\nline two\nline three\n";
5593 let read = read(text.as_bytes(), 20).unwrap();
5594 assert_eq!(read.text, "line one\nline two\n");
5595 assert_eq!(read.omitted_bytes, 11);
5596 let cut = read.cut.unwrap();
5597 assert!(
5598 cut.contains(
5599 "Cut at the last line break before byte 20 (the \
5600 comment_budget), at byte 18 of 29"
5601 ),
5602 "{cut}"
5603 );
5604 assert!(cut.contains("last 11 bytes (37%)"), "{cut}");
5605 assert!(
5606 cut.contains(
5607 "comment_budget=262144 reads the whole file, or lines=\"3-\" \
5608 reads on from line 3."
5609 ),
5610 "{cut}"
5611 );
5612 let long = format!("ab\n{}", "x".repeat(30));
5614 let mid = ranged(long.as_bytes(), 20, None).unwrap();
5615 let cut = mid.cut.unwrap();
5616 assert!(cut.starts_with("Cut at byte 20 of 33"), "{cut}");
5617 assert!(cut.contains("lines=\"2-\" reads on from line 2."), "{cut}");
5618 }
5619
5620 #[test]
5621 fn a_cut_never_splits_a_character() {
5622 let read = read("ééééé".as_bytes(), 5).unwrap();
5624 assert_eq!(read.text, "éé");
5625 assert_eq!(read.omitted_bytes, 6);
5626 assert!(read.cut.unwrap().starts_with("Cut at byte 4 of 10:"));
5628 }
5629
5630 #[test]
5631 fn the_largest_budget_reads_the_largest_file_whole() {
5632 let max = GetContentInput::MAX_COMMENT_BUDGET as usize;
5633 assert_eq!(max as u64, crate::govlog::MAX_WHOLE_READ_BYTES);
5634 let big = "x".repeat(max);
5635 let read = read(big.as_bytes(), max).unwrap();
5636 assert_eq!((read.omitted_bytes, read.cut), (0, None));
5637 }
5638
5639 #[test]
5640 fn binary_is_not_text() {
5641 let Err(DataFileReadError::NotText(err)) =
5642 read(&[0xff, 0xfe, 0x00], 32)
5643 else {
5644 panic!("not text");
5645 };
5646 assert_eq!(err.bytes, 3);
5647 assert!(err.to_string().contains("a/b.md is not text"), "{err}");
5648 }
5649}
5650
5651#[cfg(test)]
5652mod data_set_view_tests {
5653 use super::DataSetView;
5654 use crate::govlog::{DATA_SET_VERSION, DataFile, DataSet};
5655
5656 fn set(n: usize, dirs: usize) -> DataSet {
5658 let mut files: Vec<DataFile> = (0..n)
5659 .map(|i| {
5660 DataFile::of(
5661 format!("votes/d{:02}/f{i:04}.csv", i % dirs)
5662 .parse()
5663 .unwrap(),
5664 b"x\n",
5665 )
5666 })
5667 .collect();
5668 files.push(DataFile::of("votes/README.md".parse().unwrap(), b"# r\n"));
5669 files.sort_by(|a, b| a.path.cmp(&b.path));
5670 DataSet {
5671 agora_data_set: DATA_SET_VERSION,
5672 title: "t".into(),
5673 description: "d".into(),
5674 compiled_by: "c".into(),
5675 repo: "agora-data".parse().unwrap(),
5676 commit: "0123456789abcdef0123456789abcdef01234567".parse().unwrap(),
5677 index: "votes/README.md".parse().unwrap(),
5678 files,
5679 }
5680 }
5681
5682 #[test]
5683 fn a_small_set_lists_every_file() {
5684 let view = DataSetView::of(&set(4, 2), None, 32_768).unwrap();
5685 assert_eq!(view.listing.len(), 5);
5686 assert!(view.listing.iter().all(|l| l.files == 1));
5687 assert_eq!((view.files, view.unlisted), (5, 0));
5688 assert!(view.how_to_read.contains("attachment=\"votes/README.md\""));
5689 assert!(!view.how_to_read.contains("counted, not named"));
5690 }
5691
5692 #[test]
5693 fn a_thousand_files_roll_up_by_directory_and_a_directory_lists_its_own() {
5694 let big = set(1_038, 12);
5695 let view = DataSetView::of(&big, None, 32_768).unwrap();
5696 assert_eq!(view.files, 1_039);
5699 let paths: Vec<&str> =
5700 view.listing.iter().map(|l| l.path.as_str()).collect();
5701 assert_eq!(paths.len(), 13, "{paths:?}");
5702 assert!(paths.contains(&"votes/README.md"), "{paths:?}");
5703 assert!(paths.contains(&"votes/d11/"), "{paths:?}");
5704 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
5705 assert!(view.how_to_read.contains("counted, not named"));
5706 assert!(
5707 view.how_to_read.starts_with(
5708 "Read this first: attachment=\"votes/README.md\"."
5709 )
5710 );
5711
5712 let tight = DataSetView::of(&big, None, 300).unwrap();
5714 assert_eq!(tight.listing.len(), 1);
5715 assert_eq!(tight.listing[0].path, "votes/");
5716 assert_eq!(tight.listing[0].files, 1_039);
5717
5718 let votes = DataSetView::of(&big, Some("votes/"), 32_768).unwrap();
5719 let paths: Vec<&str> =
5720 votes.listing.iter().map(|l| l.path.as_str()).collect();
5721 assert_eq!(paths.len(), 13, "{paths:?}");
5722 assert!(
5723 paths.contains(&"votes/README.md") && paths.contains(&"votes/d00/")
5724 );
5725 assert_eq!(votes.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
5726 assert_eq!(votes.unlisted, 0);
5727
5728 let d00 = DataSetView::of(&big, Some("votes/d00/"), 32_768).unwrap();
5730 assert!(d00.listing.iter().all(|l| l.files == 1));
5731 assert!(DataSetView::of(&big, Some("nothing/"), 32_768).is_none());
5732 }
5733
5734 #[test]
5737 fn a_few_top_directories_descend_together() {
5738 let mut big = set(1_038, 12);
5739 for f in big.files.iter_mut() {
5740 let p = f.path.as_str();
5741 let moved = match p.strip_prefix("votes/d0") {
5742 Some(rest) => format!("early/d0{rest}"),
5743 None => p.to_string(),
5744 };
5745 f.path = moved.parse().unwrap();
5746 }
5747 big.files.sort_by(|a, b| a.path.cmp(&b.path));
5748 let view = DataSetView::of(&big, None, 32_768).unwrap();
5749 let paths: Vec<&str> =
5750 view.listing.iter().map(|l| l.path.as_str()).collect();
5751 assert!(paths.contains(&"early/d00/") && paths.contains(&"votes/d11/"));
5752 assert!(!paths.contains(&"early/") && !paths.contains(&"votes/"));
5753 assert_eq!(view.listing.iter().map(|l| l.files).sum::<u64>(), 1_039);
5754 assert_eq!(view.unlisted, 0);
5755 }
5756
5757 #[test]
5758 fn a_flat_directory_past_the_budget_says_how_many_are_left_out() {
5759 let flat = set(1_000, 1);
5760 let view = DataSetView::of(&flat, Some("votes/d00/"), 4_096).unwrap();
5761 assert!(view.unlisted > 0);
5762 assert_eq!(view.listing.len() as u64 + view.unlisted, 1_000);
5763 assert!(view.how_to_read.contains("did not fit at all"));
5764 let json = serde_json::to_string(&view).unwrap();
5766 assert!(!json.contains("sha256"), "{json}");
5767 }
5768}