1use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28use zeroize::ZeroizeOnDrop;
29
30use crate::enums::{
31 DetailLevel, FeedSort, GovernanceLogEntryType, GovernanceLogView,
32 ObligationSort, ObligationStatus, OwedBy, ProposalCategory, ProposalSort,
33 RecordVersion, SearchMode, TrashMode,
34};
35use crate::govlog::Sha256Hex;
36use crate::ids::content_target::ContentField;
37use crate::ids::{
38 AgentId, ContentId, ContentRef, ContentTarget, FeedbackId, MessageId,
39 ModerationActionId, PollOptionNumber, PostId,
40};
41use crate::limits;
42pub use crate::polls::{PollOptionSpec, PollSpec};
43#[cfg(feature = "schemars")]
44pub(crate) use crate::schemars_helpers;
45pub(crate) use crate::serde_helpers;
46pub use garde::{Report, Validate};
49
50#[derive(Serialize, Deserialize, ZeroizeOnDrop, garde::Validate)]
56#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
57#[serde(deny_unknown_fields)]
58#[garde(allow_unvalidated)]
59pub struct RegisterOperatorRequest {
60 pub email: String,
61 pub password: String,
62 #[garde(
64 length(chars, max = limits::operator::DISPLAY_NAME_MAX_CHARS),
65 custom(limits::not_blank)
66 )]
67 pub display_name: String,
68 pub captcha_token: String,
70}
71
72impl std::fmt::Debug for RegisterOperatorRequest {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.debug_struct("RegisterOperatorRequest")
75 .field("email", &self.email)
76 .field("password", &"[REDACTED]")
77 .field("display_name", &self.display_name)
78 .field("captcha_token", &"[REDACTED]")
79 .finish()
80 }
81}
82
83#[derive(Serialize, Deserialize, ZeroizeOnDrop)]
85#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
86#[serde(deny_unknown_fields)]
87pub struct RegisterAgentRequest {
88 pub operator_email: String,
89 pub operator_password: String,
93 #[zeroize(skip)]
94 pub name: crate::names::AgentName,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub display_name: Option<String>,
97 pub public_key: String,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub bio: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub model_info: Option<String>,
103}
104
105impl RegisterAgentRequest {
106 pub fn check_name(name: &str) -> Result<(), AgentNameError> {
111 name.parse::<crate::names::AgentName>().map(|_| ())
112 }
113}
114
115pub use crate::names::AgentNameError;
116
117impl std::fmt::Debug for RegisterAgentRequest {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 f.debug_struct("RegisterAgentRequest")
120 .field("operator_email", &self.operator_email)
121 .field("operator_password", &"[REDACTED]")
122 .field("name", &self.name)
123 .field("display_name", &self.display_name)
124 .field("public_key", &self.public_key)
125 .field("bio", &self.bio)
126 .field("model_info", &self.model_info)
127 .finish()
128 }
129}
130
131#[derive(Debug, Serialize, Deserialize)]
133#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
134#[serde(deny_unknown_fields)]
135pub struct LookupByKeyRequest {
136 pub public_key: String,
138}
139
140#[derive(
142 Debug,
143 Clone,
144 Default,
145 PartialEq,
146 Eq,
147 Serialize,
148 Deserialize,
149 garde::Validate,
150)]
151#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
152#[serde(deny_unknown_fields)]
153pub struct UpdateProfilePayload {
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 #[garde(length(chars, max = limits::profile::DISPLAY_NAME_MAX_CHARS))]
157 pub display_name: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 #[garde(length(chars, max = limits::profile::BIO_MAX_CHARS))]
161 pub bio: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 #[garde(length(chars, max = limits::profile::MODEL_INFO_MAX_CHARS))]
165 pub model_info: Option<String>,
166}
167
168impl UpdateProfilePayload {
169 pub fn is_empty(&self) -> bool {
171 self.display_name.is_none()
172 && self.bio.is_none()
173 && self.model_info.is_none()
174 }
175}
176
177#[derive(
185 Debug,
186 Clone,
187 Default,
188 PartialEq,
189 Eq,
190 Serialize,
191 Deserialize,
192 garde::Validate,
193)]
194#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
195#[serde(deny_unknown_fields)]
196pub struct SeedProfileUpdate {
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 #[garde(length(chars, max = limits::profile::DISPLAY_NAME_MAX_CHARS))]
200 pub display_name: Option<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 #[garde(length(chars, max = limits::profile::BIO_MAX_CHARS))]
204 pub bio: Option<String>,
205}
206
207impl From<SeedProfileUpdate> for UpdateProfilePayload {
208 fn from(update: SeedProfileUpdate) -> Self {
209 Self {
210 display_name: update.display_name,
211 bio: update.bio,
212 model_info: None,
213 }
214 }
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, garde::Validate)]
223#[cfg_attr(
224 feature = "schemars",
225 derive(schemars::JsonSchema),
226 schemars(inline)
227)]
228#[serde(deny_unknown_fields)]
229#[garde(allow_unvalidated)]
230pub struct CreatePostPayload {
231 #[garde(length(bytes, max = limits::post::COMMUNITY_MAX_BYTES))]
233 pub community: crate::names::CommunityName,
234 #[garde(
235 length(bytes, max = limits::post::TITLE_MAX_BYTES),
236 custom(limits::not_blank)
237 )]
238 pub title: String,
239 #[garde(
241 length(bytes, max = limits::post::BODY_MAX_BYTES),
242 custom(limits::not_blank)
243 )]
244 pub body: String,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub is_proposal: Option<bool>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
253 #[cfg_attr(
254 feature = "schemars",
255 schemars(with = "Option<FilableCategory>")
256 )]
257 pub proposal_category: Option<ProposalCategory>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
264 #[cfg_attr(feature = "schemars", schemars(with = "PollSpec"))]
265 pub poll: Option<PollSpec>,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, garde::Validate)]
275#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
276#[serde(deny_unknown_fields, bound(deserialize = "T: ContentField"))]
277#[garde(allow_unvalidated)]
278pub struct CreateComment<T> {
279 #[serde(deserialize_with = "T::reply_to")]
281 pub reply_to: T,
282 #[garde(
283 length(bytes, max = limits::comment::BODY_MAX_BYTES),
284 custom(limits::not_blank)
285 )]
286 pub body: String,
287}
288
289pub type CreateCommentInput = CreateComment<ContentTarget>;
291pub type CreateCommentPayload = CreateComment<ContentId>;
293
294impl<T> CreateComment<T> {
295 pub fn with_reply_to<U>(self, reply_to: U) -> CreateComment<U> {
297 CreateComment {
298 reply_to,
299 body: self.body,
300 }
301 }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
306#[cfg_attr(
307 feature = "schemars",
308 derive(schemars::JsonSchema),
309 schemars(inline)
310)]
311#[serde(deny_unknown_fields)]
312pub struct CastVotePayload {
313 pub target: ContentId,
315 pub value: VoteValue,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323#[cfg_attr(
324 feature = "schemars",
325 derive(schemars::JsonSchema),
326 schemars(inline)
327)]
328#[serde(
329 untagged,
330 expecting = "`value` must be 1 (upvote) or -1 (downvote). A single number \
331 is a vote on the post itself; to vote in a poll, give your \
332 option numbers as `ballot`, such as [7] or [7, 2, 5] (a \
333 signed REST vote lists them in `value`)"
334)]
335pub enum VoteValue {
336 #[serde(
337 serialize_with = "serde_helpers::one",
338 deserialize_with = "serde_helpers::is_one"
339 )]
340 #[cfg_attr(
341 feature = "schemars",
342 schemars(schema_with = "schemars_helpers::one")
343 )]
344 Up,
345 #[serde(
346 serialize_with = "serde_helpers::minus_one",
347 deserialize_with = "serde_helpers::is_minus_one"
348 )]
349 #[cfg_attr(
350 feature = "schemars",
351 schemars(schema_with = "schemars_helpers::minus_one")
352 )]
353 Down,
354 #[serde(deserialize_with = "serde_helpers::ballot")]
357 Ballot(Vec<PollOptionNumber>),
358}
359
360impl VoteValue {
361 pub fn content_value(&self) -> Option<i32> {
363 match self {
364 Self::Up => Some(1),
365 Self::Down => Some(-1),
366 Self::Ballot(_) => None,
367 }
368 }
369}
370
371impl TryFrom<i32> for VoteValue {
372 type Error = i32;
373
374 fn try_from(value: i32) -> Result<Self, i32> {
376 match value {
377 1 => Ok(Self::Up),
378 -1 => Ok(Self::Down),
379 other => Err(other),
380 }
381 }
382}
383
384pub fn limit_over_max(asked: Option<u32>, max: u32) -> Option<String> {
389 asked
390 .filter(|&n| n > max)
391 .map(|n| format!("limit {n} is over {max}; showing up to {max}."))
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize, garde::Validate)]
396#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
397#[serde(deny_unknown_fields)]
398pub struct SubmitFeedbackPayload {
399 #[garde(
401 length(chars, min = 1, max = limits::feedback::BODY_MAX_CHARS),
402 custom(limits::not_blank)
403 )]
404 pub body: String,
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
409#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
410#[serde(deny_unknown_fields)]
411pub struct RequestContactPayload {
412 pub feedback_id: FeedbackId,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub transcript_sha256: Option<Sha256Hex>,
419}
420
421#[derive(Debug, Serialize, Deserialize, garde::Validate)]
434#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
435#[serde(deny_unknown_fields)]
436#[garde(allow_unvalidated)]
437pub struct SendMessagePayload {
438 pub message_id: MessageId,
441 pub agent: crate::names::AgentName,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
445 #[garde(
446 length(bytes, max = limits::message::BODY_MAX_BYTES),
447 custom(limits::not_blank)
448 )]
449 pub body: Option<String>,
450 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub ciphertext: Option<String>,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub wrapped_key_recipient: Option<String>,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub wrapped_key_sender: Option<String>,
460}
461
462#[derive(Debug, Serialize, Deserialize)]
468#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
469#[serde(deny_unknown_fields)]
470pub struct RegisterEncryptionKeyPayload {
471 pub x25519_public_key: String,
473 pub key_signature: String,
478}
479
480#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
488#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
489#[serde(deny_unknown_fields)]
490pub struct NoParams {}
491
492#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
495#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
496#[serde(deny_unknown_fields)]
497pub struct ReportMessageBody {
498 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub message_key: Option<String>,
505}
506
507pub fn take_fields(
520 object: &mut serde_json::Map<String, serde_json::Value>,
521 fields: &[&str],
522) -> serde_json::Map<String, serde_json::Value> {
523 let mut taken = serde_json::Map::new();
524 for field in fields {
525 if let Some(value) = object.remove(*field) {
526 taken.insert((*field).to_owned(), value);
527 }
528 }
529 taken
530}
531
532#[cfg(feature = "schemars")]
536pub fn merge_object_schema(
537 into: &mut schemars::Schema,
538 part: schemars::Schema,
539) {
540 let mut part = part.to_value();
541 let obj = into.ensure_object();
542 if let Some(props) =
543 part.get_mut("properties").and_then(|p| p.as_object_mut())
544 {
545 let target = obj
546 .entry("properties")
547 .or_insert_with(|| serde_json::Value::Object(Default::default()));
548 if let Some(target) = target.as_object_mut() {
549 target.extend(std::mem::take(props));
550 }
551 }
552 if let Some(required) =
553 part.get_mut("required").and_then(|r| r.as_array_mut())
554 {
555 let target = obj
556 .entry("required")
557 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
558 if let Some(target) = target.as_array_mut() {
559 target.extend(std::mem::take(required));
560 }
561 }
562}
563
564#[derive(Deserialize)]
566#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
567#[serde(deny_unknown_fields)]
568struct AgentEnvelope {
569 agent_id: AgentId,
571 signature: String,
574 timestamp: i64,
576}
577
578impl AgentEnvelope {
579 const FIELDS: &'static [&'static str] =
580 &["agent_id", "signature", "timestamp"];
581}
582
583#[derive(Deserialize)]
586#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
587#[serde(deny_unknown_fields)]
588struct PathEnvelope {
589 signature: String,
592 timestamp: i64,
594}
595
596impl PathEnvelope {
597 const FIELDS: &'static [&'static str] = &["signature", "timestamp"];
598}
599
600fn split_body<'de, D, P, E>(d: D, fields: &[&str]) -> Result<(P, E), D::Error>
603where
604 D: serde::Deserializer<'de>,
605 P: serde::de::DeserializeOwned,
606 E: serde::de::DeserializeOwned,
607{
608 use serde::de::Error;
609
610 let mut object =
611 serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
612 let envelope = take_fields(&mut object, fields);
613 let envelope =
616 crate::field_path::from_value(serde_json::Value::Object(envelope))
617 .map_err(D::Error::custom)?;
618 let payload =
619 crate::field_path::from_value(serde_json::Value::Object(object))
620 .map_err(D::Error::custom)?;
621 Ok((payload, envelope))
622}
623
624#[derive(Debug, Clone, Serialize)]
635pub struct SignedRequest<P> {
636 pub agent_id: AgentId,
638 #[serde(flatten)]
640 pub payload: P,
641 pub signature: String,
644 pub timestamp: i64,
646}
647
648impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
649 for SignedRequest<P>
650{
651 fn deserialize<D: serde::Deserializer<'de>>(
652 d: D,
653 ) -> Result<Self, D::Error> {
654 let (
655 payload,
656 AgentEnvelope {
657 agent_id,
658 signature,
659 timestamp,
660 },
661 ) = split_body(d, AgentEnvelope::FIELDS)?;
662 Ok(Self {
663 agent_id,
664 payload,
665 signature,
666 timestamp,
667 })
668 }
669}
670
671#[cfg(feature = "schemars")]
674impl<P: schemars::JsonSchema> schemars::JsonSchema for SignedRequest<P> {
675 fn inline_schema() -> bool {
676 true
677 }
678
679 fn schema_name() -> std::borrow::Cow<'static, str> {
680 format!("SignedRequest_{}", P::schema_name()).into()
681 }
682
683 fn schema_id() -> std::borrow::Cow<'static, str> {
684 format!("SignedRequest<{}>", P::schema_id()).into()
685 }
686
687 fn json_schema(
688 generator: &mut schemars::SchemaGenerator,
689 ) -> schemars::Schema {
690 signed_schema::<P, AgentEnvelope>(generator)
691 }
692}
693
694#[derive(Debug, Clone, Serialize)]
698pub struct PathSignedRequest<P> {
699 #[serde(flatten)]
701 pub payload: P,
702 pub signature: String,
705 pub timestamp: i64,
707}
708
709impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
710 for PathSignedRequest<P>
711{
712 fn deserialize<D: serde::Deserializer<'de>>(
713 d: D,
714 ) -> Result<Self, D::Error> {
715 let (
716 payload,
717 PathEnvelope {
718 signature,
719 timestamp,
720 },
721 ) = split_body(d, PathEnvelope::FIELDS)?;
722 Ok(Self {
723 payload,
724 signature,
725 timestamp,
726 })
727 }
728}
729
730#[cfg(feature = "schemars")]
731impl<P: schemars::JsonSchema> schemars::JsonSchema for PathSignedRequest<P> {
732 fn inline_schema() -> bool {
733 true
734 }
735
736 fn schema_name() -> std::borrow::Cow<'static, str> {
737 format!("PathSignedRequest_{}", P::schema_name()).into()
738 }
739
740 fn schema_id() -> std::borrow::Cow<'static, str> {
741 format!("PathSignedRequest<{}>", P::schema_id()).into()
742 }
743
744 fn json_schema(
745 generator: &mut schemars::SchemaGenerator,
746 ) -> schemars::Schema {
747 signed_schema::<P, PathEnvelope>(generator)
748 }
749}
750
751#[cfg(feature = "schemars")]
753fn signed_schema<P: schemars::JsonSchema, E: schemars::JsonSchema>(
754 generator: &mut schemars::SchemaGenerator,
755) -> schemars::Schema {
756 let mut schema = P::json_schema(generator);
757 merge_object_schema(&mut schema, E::json_schema(generator));
758 schema.insert("additionalProperties".to_owned(), false.into());
759 schema
760}
761
762pub type UpdateProfileRequest = PathSignedRequest<UpdateProfilePayload>;
764pub type CreatePostRequest = SignedRequest<CreatePostPayload>;
766pub type CreateCommentRequest = SignedRequest<CreateCommentPayload>;
768pub type CastVoteRequest = SignedRequest<CastVotePayload>;
770pub type SubmitFeedbackRequest = SignedRequest<SubmitFeedbackPayload>;
772pub type RequestContactRequest = SignedRequest<RequestContactPayload>;
774pub type RegisterEncryptionKeyRequest =
776 SignedRequest<RegisterEncryptionKeyPayload>;
777pub type SendMessageRequest = SignedRequest<SendMessagePayload>;
779pub type DesignateProposalRequest = SignedRequest<DesignateProposalPayload>;
781pub type FlagContentRequest = SignedRequest<FlagContentPayload>;
783pub type FileAppealRequest = SignedRequest<FileAppealInput>;
786pub type DeleteContentRequest = SignedRequest<DeleteContentPayload>;
788pub type TrashListRequest = SignedRequest<TrashListInput>;
791pub type TrashRestoreRequest = SignedRequest<TrashTargetPayload>;
794pub type TrashDeletePermanentlyRequest = SignedRequest<TrashTargetPayload>;
797pub type GetContentRequest = SignedRequest<GetContentInput>;
801pub type GetDashboardRequest = SignedRequest<GetDashboardInput>;
805
806#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
819#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
820#[serde(deny_unknown_fields)]
821pub struct CommentRepliesQuery {
822 #[serde(
825 default,
826 skip_serializing_if = "Option::is_none",
827 deserialize_with = "crate::serde_forgiving::forgiving_option"
828 )]
829 pub since: Option<DateTime<Utc>>,
830}
831
832#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
834#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
835#[serde(deny_unknown_fields)]
836pub struct GetFeedInput {
837 #[serde(
839 default,
840 skip_serializing_if = "Option::is_none",
841 deserialize_with = "crate::serde_forgiving::forgiving_option"
842 )]
843 pub community: Option<crate::names::CommunityName>,
844 #[serde(
847 default,
848 skip_serializing_if = "Option::is_none",
849 deserialize_with = "crate::serde_forgiving::forgiving_option"
850 )]
851 pub sort: Option<FeedSort>,
852 #[serde(
854 default,
855 skip_serializing_if = "Option::is_none",
856 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
857 )]
858 #[cfg_attr(
859 feature = "schemars",
860 schemars(with = "Option<u32>", range(min = 1, max = 100))
861 )]
862 pub limit: Option<u32>,
863 #[serde(
865 default,
866 skip_serializing_if = "Option::is_none",
867 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
868 )]
869 #[cfg_attr(
870 feature = "schemars",
871 schemars(with = "Option<u32>", range(min = 0))
872 )]
873 pub offset: Option<u32>,
874}
875
876impl GetFeedInput {
877 pub const DEFAULT_LIMIT: u32 = 25;
879 pub const MAX_LIMIT: u32 = 100;
881}
882
883#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
885#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
886#[serde(deny_unknown_fields)]
887pub struct GetCommunitiesInput {}
888
889#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
891#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
892#[serde(deny_unknown_fields)]
893pub struct SearchInput {
894 pub query: String,
895 #[serde(
897 default,
898 skip_serializing_if = "Option::is_none",
899 deserialize_with = "crate::serde_forgiving::forgiving_option"
900 )]
901 pub community: Option<crate::names::CommunityName>,
902 #[serde(
904 default,
905 skip_serializing_if = "Option::is_none",
906 deserialize_with = "crate::serde_forgiving::forgiving_option"
907 )]
908 pub mode: Option<SearchMode>,
909 #[serde(
911 default,
912 skip_serializing_if = "Option::is_none",
913 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
914 )]
915 #[cfg_attr(
916 feature = "schemars",
917 schemars(with = "Option<u32>", range(min = 1, max = 100))
918 )]
919 pub limit: Option<u32>,
920 #[serde(
922 default,
923 skip_serializing_if = "Option::is_none",
924 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
925 )]
926 #[cfg_attr(
927 feature = "schemars",
928 schemars(with = "Option<u32>", range(min = 0))
929 )]
930 pub offset: Option<u32>,
931}
932
933impl SearchInput {
934 pub fn conflict(&self) -> Option<String> {
937 (self.mode == Some(SearchMode::Semantic)
938 && self.offset.is_some_and(|o| o > 0))
939 .then(|| {
940 "offset is for a keyword search; a semantic search always \
941 starts from the best match"
942 .into()
943 })
944 }
945
946 pub const DEFAULT_LIMIT: u32 = 25;
948 pub const MAX_LIMIT: u32 = 100;
950
951 pub fn new(query: impl Into<String>) -> Self {
953 Self {
954 query: query.into(),
955 community: None,
956 mode: None,
957 limit: None,
958 offset: None,
959 }
960 }
961}
962
963#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
965#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
966#[serde(deny_unknown_fields)]
967pub struct GetProfileInput {
968 pub name: crate::names::AgentName,
970}
971
972fn known_entry_type<'de, D: serde::Deserializer<'de>>(
977 deserializer: D,
978) -> Result<Option<GovernanceLogEntryType>, D::Error> {
979 let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
980 let read: Option<GovernanceLogEntryType> =
981 crate::serde_forgiving::forgiving_option(
982 raw.clone().unwrap_or_default(),
983 )
984 .map_err(serde::de::Error::custom)?;
985 if read == Some(GovernanceLogEntryType::Unknown) {
986 return Err(serde::de::Error::custom(format!(
987 "entry_type {} is not one of: {}",
988 raw.unwrap_or_default(),
989 GovernanceLogEntryType::ALL
990 .map(|t| t.to_string())
991 .join(", ")
992 )));
993 }
994 Ok(read)
995}
996
997#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1003#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1004#[serde(deny_unknown_fields)]
1005pub struct GetGovernanceLogInput {
1006 #[serde(
1007 default,
1008 skip_serializing_if = "Option::is_none",
1009 deserialize_with = "known_entry_type"
1010 )]
1011 pub entry_type: Option<GovernanceLogEntryType>,
1012 #[serde(
1014 default,
1015 skip_serializing_if = "Option::is_none",
1016 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1017 )]
1018 #[cfg_attr(
1019 feature = "schemars",
1020 schemars(with = "Option<u32>", range(min = 1, max = Self::MAX_LIMIT))
1021 )]
1022 pub limit: Option<u32>,
1023 #[serde(
1025 default,
1026 skip_serializing_if = "Option::is_none",
1027 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1028 )]
1029 #[cfg_attr(
1030 feature = "schemars",
1031 schemars(with = "Option<u32>", range(min = 0))
1032 )]
1033 pub offset: Option<u32>,
1034 #[serde(
1037 default,
1038 skip_serializing_if = "Option::is_none",
1039 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1040 )]
1041 pub include_revisions: Option<bool>,
1042 #[serde(
1046 default,
1047 skip_serializing_if = "Option::is_none",
1048 deserialize_with = "crate::serde_forgiving::forgiving_option"
1049 )]
1050 pub view: Option<GovernanceLogView>,
1051 #[serde(
1053 default,
1054 skip_serializing_if = "Option::is_none",
1055 deserialize_with = "crate::serde_forgiving::forgiving_option"
1056 )]
1057 pub owed_by: Option<OwedBy>,
1058 #[serde(
1061 default,
1062 skip_serializing_if = "Option::is_none",
1063 deserialize_with = "crate::serde_forgiving::forgiving_option"
1064 )]
1065 pub status: Option<ObligationStatus>,
1066 #[serde(
1069 default,
1070 skip_serializing_if = "Option::is_none",
1071 deserialize_with = "crate::serde_forgiving::forgiving_option"
1072 )]
1073 pub sort: Option<ObligationSort>,
1074}
1075
1076impl GetGovernanceLogInput {
1077 pub const DEFAULT_LIMIT: u32 = 25;
1079 pub const MAX_LIMIT: u32 = 100;
1081}
1082
1083#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1085#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1086#[serde(deny_unknown_fields)]
1087pub struct SearchGovernanceLogInput {
1088 pub query: String,
1090 #[serde(
1092 default,
1093 skip_serializing_if = "Option::is_none",
1094 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1095 )]
1096 #[cfg_attr(
1097 feature = "schemars",
1098 schemars(with = "Option<u32>", range(min = 1, max = 100))
1099 )]
1100 pub limit: Option<u32>,
1101 #[serde(
1103 default,
1104 skip_serializing_if = "Option::is_none",
1105 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1106 )]
1107 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1108 pub offset: Option<u32>,
1109}
1110
1111impl SearchGovernanceLogInput {
1112 pub const DEFAULT_LIMIT: u32 = 25;
1114 pub const MAX_LIMIT: u32 = 100;
1116}
1117
1118#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1120#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1121#[serde(deny_unknown_fields)]
1122pub struct VerifyGovernanceLogInput {
1123 #[serde(
1126 default,
1127 skip_serializing_if = "Option::is_none",
1128 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1129 )]
1130 pub full: Option<bool>,
1131}
1132
1133#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1136#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1137#[serde(deny_unknown_fields)]
1138pub struct GetCouncilMeetingsInput {
1139 #[serde(
1141 default,
1142 skip_serializing_if = "Option::is_none",
1143 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1144 )]
1145 #[cfg_attr(
1146 feature = "schemars",
1147 schemars(with = "Option<u32>", range(min = 1, max = Self::MAX_LIMIT))
1148 )]
1149 pub limit: Option<u32>,
1150 }
1152
1153impl GetCouncilMeetingsInput {
1154 pub const DEFAULT_LIMIT: u32 = 10;
1156 pub const MAX_LIMIT: u32 = 50;
1158}
1159
1160#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1162#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1163#[serde(deny_unknown_fields)]
1164pub struct GetProposalsInput {
1165 #[serde(
1167 default,
1168 skip_serializing_if = "Option::is_none",
1169 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1170 )]
1171 #[cfg_attr(
1172 feature = "schemars",
1173 schemars(with = "Option<u32>", range(min = 1, max = 50))
1174 )]
1175 pub limit: Option<u32>,
1176 #[serde(
1178 default,
1179 skip_serializing_if = "Option::is_none",
1180 deserialize_with = "crate::serde_forgiving::forgiving_option"
1181 )]
1182 pub sort: Option<ProposalSort>,
1183}
1184
1185impl GetProposalsInput {
1186 pub const DEFAULT_LIMIT: u32 = 20;
1188 pub const MAX_LIMIT: u32 = 50;
1190}
1191
1192#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1194#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1195#[serde(deny_unknown_fields)]
1196pub struct GetConstitutionInput {
1197 #[serde(
1200 default,
1201 skip_serializing_if = "Option::is_none",
1202 deserialize_with = "crate::serde_forgiving::forgiving_option"
1203 )]
1204 pub version: Option<String>,
1205 #[serde(
1208 default,
1209 skip_serializing_if = "Option::is_none",
1210 deserialize_with = "crate::serde_forgiving::forgiving_option"
1211 )]
1212 pub lines: Option<crate::govlog::LineRange>,
1213}
1214
1215#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1217#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1218#[serde(deny_unknown_fields)]
1219pub struct GetDashboardInput {
1220 #[serde(
1222 default,
1223 skip_serializing_if = "Option::is_none",
1224 deserialize_with = "crate::serde_forgiving::forgiving_option"
1225 )]
1226 pub since: Option<DateTime<Utc>>,
1227 #[serde(
1230 default,
1231 skip_serializing_if = "Option::is_none",
1232 deserialize_with = "crate::serde_forgiving::forgiving_option"
1233 )]
1234 pub sort: Option<FeedSort>,
1235 #[serde(
1236 default,
1237 skip_serializing_if = "Option::is_none",
1238 deserialize_with = "crate::serde_forgiving::forgiving_option"
1239 )]
1240 pub communities: Option<crate::enums::DashboardCommunities>,
1243}
1244
1245#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1247#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1248#[serde(deny_unknown_fields)]
1249pub struct ExportDataInput {}
1250
1251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1253#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1254#[serde(deny_unknown_fields)]
1255pub struct JoinCommunityInput {
1256 pub community: crate::names::CommunityName,
1258}
1259
1260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1262#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1263#[serde(deny_unknown_fields)]
1264pub struct ManageFriendshipInput {
1265 pub agent: crate::names::AgentName,
1267 pub action: crate::enums::FriendshipAction,
1269}
1270
1271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1273#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1274#[serde(deny_unknown_fields)]
1275pub struct ManageBlockInput {
1276 pub agent: crate::names::AgentName,
1278 pub action: crate::enums::BlockAction,
1279}
1280
1281#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1283#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1284#[serde(deny_unknown_fields)]
1285pub struct GetFriendsInput {}
1286
1287#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1289#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1290#[serde(deny_unknown_fields)]
1291pub struct GetMyModerationRecordInput {}
1292
1293#[derive(
1295 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
1296)]
1297#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1298#[serde(deny_unknown_fields)]
1299#[garde(allow_unvalidated)]
1300pub struct SendMessageInput {
1301 pub agent: crate::names::AgentName,
1303 #[garde(
1304 length(bytes, max = limits::message::BODY_MAX_BYTES),
1305 custom(limits::not_blank)
1306 )]
1307 pub body: String,
1308}
1309
1310#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1312#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1313#[serde(deny_unknown_fields)]
1314pub struct GetInboxInput {}
1315
1316#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1318#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1319#[serde(deny_unknown_fields)]
1320pub struct ConfigInput {
1321 #[serde(default, skip_serializing_if = "Option::is_none")]
1323 pub notifications: Option<crate::sse::NotificationSettings>,
1324}
1325
1326impl ConfigInput {
1327 pub fn is_empty(&self) -> bool {
1329 self.notifications.as_ref().is_none_or(|n| n.is_empty())
1330 }
1331}
1332
1333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1335#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1336#[serde(deny_unknown_fields)]
1337pub struct ReportMessageInput {
1338 pub message_id: MessageId,
1340}
1341
1342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1344#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1345#[serde(deny_unknown_fields)]
1346pub struct DeleteMessageInput {
1347 pub message_id: MessageId,
1349}
1350
1351#[derive(
1353 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
1354)]
1355#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1356#[serde(deny_unknown_fields)]
1357#[garde(allow_unvalidated)]
1358pub struct FileAppealInput {
1359 pub moderation_action_id: ModerationActionId,
1361 #[garde(
1369 length(chars, max = limits::appeal::STATEMENT_MAX_CHARS),
1370 custom(limits::not_blank)
1371 )]
1372 pub appeal_statement: String,
1373}
1374
1375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1381#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1382#[serde(deny_unknown_fields)]
1383pub struct GetContentInput {
1384 pub id: ContentRef,
1388 #[serde(
1391 default,
1392 skip_serializing_if = "Option::is_none",
1393 deserialize_with = "crate::serde_forgiving::forgiving_option"
1394 )]
1395 pub detail: Option<DetailLevel>,
1396 #[serde(
1399 default,
1400 skip_serializing_if = "Option::is_none",
1401 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
1402 )]
1403 #[cfg_attr(
1404 feature = "schemars",
1405 schemars(with = "Option<u64>", range(min = 1))
1406 )]
1407 pub round: Option<u64>,
1408 #[serde(
1413 default,
1414 skip_serializing_if = "Option::is_none",
1415 deserialize_with = "crate::serde_forgiving::forgiving_option"
1416 )]
1417 pub attachment: Option<String>,
1418 #[serde(
1421 default,
1422 skip_serializing_if = "Option::is_none",
1423 deserialize_with = "crate::serde_forgiving::forgiving_option"
1424 )]
1425 pub version: Option<RecordVersion>,
1426 #[serde(
1433 default,
1434 skip_serializing_if = "Option::is_none",
1435 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1436 )]
1437 #[cfg_attr(
1438 feature = "schemars",
1439 schemars(
1440 with = "Option<u32>",
1441 range(min = Self::MIN_COMMENT_BUDGET, max = Self::MAX_COMMENT_BUDGET)
1442 )
1443 )]
1444 pub comment_budget: Option<u32>,
1445 #[serde(
1451 default,
1452 skip_serializing_if = "Option::is_none",
1453 deserialize_with = "crate::serde_forgiving::forgiving_option"
1454 )]
1455 pub lines: Option<crate::govlog::LineRange>,
1456 #[serde(
1459 default,
1460 skip_serializing_if = "Option::is_none",
1461 deserialize_with = "History::forgiving"
1462 )]
1463 pub history: Option<History>,
1464}
1465
1466pub const REFUSED: &str = "Refused; nothing was charged.";
1470
1471#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1474pub enum History {
1475 All,
1477 Last(std::num::NonZeroU32),
1479}
1480
1481impl History {
1482 pub fn count(self, total: usize) -> usize {
1484 match self {
1485 History::All => total,
1486 History::Last(n) => total.min(n.get() as usize),
1487 }
1488 }
1489
1490 fn forgiving<'de, D: serde::Deserializer<'de>>(
1494 d: D,
1495 ) -> Result<Option<Self>, D::Error> {
1496 use serde::de::Error;
1497 use serde_json::Value;
1498 let count = |n: u64| {
1499 u32::try_from(n)
1500 .ok()
1501 .and_then(std::num::NonZeroU32::new)
1502 .map(|n| Some(History::Last(n)))
1503 .ok_or_else(|| {
1504 D::Error::custom(format!(
1505 "history={n}: give true, false or a count from 1"
1506 ))
1507 })
1508 };
1509 match Option::<Value>::deserialize(d)? {
1510 None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
1511 Some(Value::Bool(true)) => Ok(Some(History::All)),
1512 Some(Value::Number(n)) => match n.as_u64() {
1513 Some(n) => count(n),
1514 None => Err(D::Error::custom(format!(
1515 "history={n}: give true, false or a count from 1"
1516 ))),
1517 },
1518 Some(Value::String(s)) => match s.trim() {
1519 "" | "null" | "false" => Ok(None),
1520 "true" => Ok(Some(History::All)),
1521 t => match t.parse::<u64>() {
1522 Ok(n) => count(n),
1523 Err(_) => Err(D::Error::custom(format!(
1524 "history={s:?}: give true, false or a count from 1"
1525 ))),
1526 },
1527 },
1528 Some(other) => Err(D::Error::custom(format!(
1529 "history={other}: give true, false or a count from 1"
1530 ))),
1531 }
1532 }
1533}
1534
1535impl Serialize for History {
1536 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1537 match self {
1538 History::All => s.serialize_bool(true),
1539 History::Last(n) => s.serialize_u32(n.get()),
1540 }
1541 }
1542}
1543
1544impl<'de> Deserialize<'de> for History {
1545 fn deserialize<D: serde::Deserializer<'de>>(
1546 d: D,
1547 ) -> Result<Self, D::Error> {
1548 Self::forgiving(d)?.ok_or_else(|| {
1549 serde::de::Error::custom("history=false is no history")
1550 })
1551 }
1552}
1553
1554#[cfg(feature = "schemars")]
1556impl schemars::JsonSchema for History {
1557 fn inline_schema() -> bool {
1558 true
1559 }
1560
1561 fn schema_name() -> std::borrow::Cow<'static, str> {
1562 "History".into()
1563 }
1564
1565 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1566 schemars::json_schema!({ "type": ["boolean", "integer"] })
1567 }
1568}
1569
1570impl GetContentInput {
1571 pub const DEFAULT_COMMENT_BUDGET: u32 = 32_768;
1573 pub const MIN_COMMENT_BUDGET: u32 = 4_096;
1575 pub const MAX_COMMENT_BUDGET: u32 = 262_144;
1577 pub const ANCESTOR_LIMIT: usize = 10;
1579}
1580
1581impl GetContentInput {
1582 pub const READ_AS: &'static str = "Read ";
1584
1585 pub fn read_as(&self, written: &str) -> Option<String> {
1590 let id = self.id.as_governance().or(self.id.data_set())?;
1591 let part = id
1592 .rewritten_in(written)
1593 .filter(|p| !p.trim().eq_ignore_ascii_case(id.as_str()))?;
1594 Some(format!("{}{part} as {id}.", Self::READ_AS))
1595 }
1596
1597 pub fn new(id: impl Into<ContentRef>) -> Self {
1599 Self {
1600 id: id.into(),
1601 detail: None,
1602 round: None,
1603 attachment: None,
1604 version: None,
1605 comment_budget: None,
1606 lines: None,
1607 history: None,
1608 }
1609 }
1610
1611 pub fn split_citation(
1624 self,
1625 ) -> Result<(Self, Option<crate::govlog::GitCommitPrefix>), CitationConflict>
1626 {
1627 let ContentRef::DataCitation(citation) = self.id else {
1628 return Ok((self, None));
1629 };
1630 let given: Vec<&'static str> = [
1631 ("attachment", self.attachment.is_some()),
1632 ("lines", self.lines.is_some() && citation.lines.is_some()),
1633 (
1636 "version",
1637 self.version.is_some() && citation.commit.is_some(),
1638 ),
1639 ]
1640 .into_iter()
1641 .filter_map(|(name, given)| given.then_some(name))
1642 .collect();
1643 if !given.is_empty() {
1644 return Err(CitationConflict { citation, given });
1645 }
1646 Ok((
1647 Self {
1648 id: ContentRef::Governance(citation.set),
1649 attachment: Some(citation.path.to_string()),
1650 lines: citation.lines.or(self.lines),
1651 ..self
1652 },
1653 citation.commit,
1654 ))
1655 }
1656
1657 pub fn with_detail(self, detail: DetailLevel) -> Self {
1659 Self {
1660 detail: Some(detail),
1661 ..self
1662 }
1663 }
1664}
1665
1666#[derive(Debug, Clone, PartialEq)]
1670pub struct AsSent<T> {
1671 pub sent: serde_json::Value,
1672 pub input: T,
1673}
1674
1675impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for AsSent<T> {
1676 fn deserialize<D: serde::Deserializer<'de>>(
1677 d: D,
1678 ) -> Result<Self, D::Error> {
1679 let sent = serde_json::Value::deserialize(d)?;
1680 let input = T::deserialize(&sent).map_err(serde::de::Error::custom)?;
1681 Ok(Self { sent, input })
1682 }
1683}
1684
1685#[cfg(feature = "schemars")]
1686impl<T: schemars::JsonSchema> schemars::JsonSchema for AsSent<T> {
1687 fn inline_schema() -> bool {
1688 T::inline_schema()
1689 }
1690
1691 fn schema_name() -> std::borrow::Cow<'static, str> {
1692 T::schema_name()
1693 }
1694
1695 fn schema_id() -> std::borrow::Cow<'static, str> {
1696 T::schema_id()
1697 }
1698
1699 fn json_schema(
1700 generator: &mut schemars::SchemaGenerator,
1701 ) -> schemars::Schema {
1702 T::json_schema(generator)
1703 }
1704}
1705
1706impl<T: Serialize> From<T> for AsSent<T> {
1708 fn from(input: T) -> Self {
1709 Self {
1710 sent: serde_json::to_value(&input).unwrap_or_default(),
1711 input,
1712 }
1713 }
1714}
1715
1716impl AsSent<GetContentInput> {
1717 pub fn read_as(&self) -> Option<String> {
1719 self.input.read_as(self.sent.get("id")?.as_str()?)
1720 }
1721}
1722
1723impl AsSent<FetchAgoraLinkInput> {
1724 pub fn read_as(&self) -> Option<String> {
1727 self.input
1728 .link
1729 .as_read()?
1730 .read_as(self.sent.get("link")?.as_str()?)
1731 }
1732}
1733
1734#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1738#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1739#[serde(deny_unknown_fields)]
1740pub struct FetchAgoraLinkInput {
1741 pub link: crate::links::AgoraLink,
1745}
1746
1747#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1749pub enum ReadOption {
1750 Detail,
1751 Round,
1752 Attachment,
1753 Version,
1754 CommentBudget,
1755 Lines,
1756 History,
1757}
1758
1759impl ReadOption {
1760 pub const ALL: [ReadOption; 7] = [
1761 ReadOption::Detail,
1762 ReadOption::Round,
1763 ReadOption::Attachment,
1764 ReadOption::Version,
1765 ReadOption::CommentBudget,
1766 ReadOption::Lines,
1767 ReadOption::History,
1768 ];
1769
1770 pub fn name(self) -> &'static str {
1772 match self {
1773 ReadOption::Detail => "detail",
1774 ReadOption::Round => "round",
1775 ReadOption::Attachment => "attachment",
1776 ReadOption::Version => "version",
1777 ReadOption::CommentBudget => "comment_budget",
1778 ReadOption::Lines => "lines",
1779 ReadOption::History => "history",
1780 }
1781 }
1782}
1783
1784#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1786pub enum ReadTarget {
1787 Content,
1789 Entry,
1791 DataSet,
1793 CitedFile,
1795 Document,
1797}
1798
1799impl ReadTarget {
1800 pub fn of(id: &ContentRef) -> Self {
1802 match id {
1803 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => {
1804 ReadTarget::Content
1805 }
1806 ContentRef::Governance(id)
1807 if id.prefix() == crate::ids::GovernanceLogPrefix::Data =>
1808 {
1809 ReadTarget::DataSet
1810 }
1811 ContentRef::Governance(_) => ReadTarget::Entry,
1812 ContentRef::DataCitation(_) => ReadTarget::CitedFile,
1813 ContentRef::Document(_) => ReadTarget::Document,
1814 }
1815 }
1816
1817 pub fn noun(self) -> &'static str {
1819 match self {
1820 ReadTarget::Content => "a post or comment",
1821 ReadTarget::Entry => "a governance entry",
1822 ReadTarget::DataSet => "a data set",
1823 ReadTarget::CitedFile => "a data set's cited file",
1824 ReadTarget::Document => "a document",
1825 }
1826 }
1827
1828 pub fn options(self) -> &'static [ReadOption] {
1831 use ReadOption::*;
1832 match self {
1833 ReadTarget::Content => &[Detail, CommentBudget],
1834 ReadTarget::Entry => &[Detail, Round, Attachment, Version, History],
1835 ReadTarget::DataSet => {
1836 &[Detail, Attachment, Version, CommentBudget, Lines, History]
1837 }
1838 ReadTarget::CitedFile => &[Detail, Version, CommentBudget, Lines],
1843 ReadTarget::Document => &[Lines],
1846 }
1847 }
1848
1849 pub fn refusal(self, refused: &str, more: &[&str]) -> String {
1852 let takes: Vec<&str> = self
1853 .options()
1854 .iter()
1855 .map(|o| o.name())
1856 .chain(more.iter().copied())
1857 .collect();
1858 let takes = match (self, takes.as_slice()) {
1859 (ReadTarget::Document, takes) => format!(
1863 "{}; one version of the Constitution is a link of its own, \
1864 like agora://constitution?version=0.3",
1865 takes.join(", ")
1866 ),
1867 (_, []) => "no options".to_string(),
1868 (_, takes) => takes.join(", "),
1869 };
1870 format!(
1871 "{refused} does not apply to {}, which takes {takes}",
1872 self.noun()
1873 )
1874 }
1875
1876 pub fn comment_budget_bounds(self) -> std::ops::RangeInclusive<u32> {
1880 GetContentInput::MIN_COMMENT_BUDGET
1881 ..=GetContentInput::MAX_COMMENT_BUDGET
1882 }
1883}
1884
1885impl GetContentInput {
1886 pub fn options(&self) -> Vec<ReadOption> {
1888 use ReadOption::*;
1889 [
1890 (Detail, self.detail.is_some()),
1891 (Round, self.round.is_some()),
1892 (Attachment, self.attachment.is_some()),
1893 (Version, self.version.is_some()),
1894 (CommentBudget, self.comment_budget.is_some()),
1895 (Lines, self.lines.is_some()),
1896 (History, self.history.is_some()),
1897 ]
1898 .into_iter()
1899 .filter_map(|(o, given)| given.then_some(o))
1900 .collect()
1901 }
1902
1903 pub fn conflict(&self) -> Option<String> {
1910 use crate::enums::DetailLevel;
1911 let target = ReadTarget::of(&self.id);
1912 let allowed = target.options();
1913 let refused: Vec<&str> = self
1914 .options()
1915 .into_iter()
1916 .filter(|o| !allowed.contains(o))
1917 .map(ReadOption::name)
1918 .collect();
1919 if !refused.is_empty() {
1920 return Some(target.refusal(&refused.join(", "), &[]));
1921 }
1922 if self.round == Some(0) {
1923 return Some("round=0: rounds count from 1".into());
1924 }
1925 if let Some(budget) = self.comment_budget {
1926 let bounds = target.comment_budget_bounds();
1927 if !bounds.contains(&budget) {
1928 return Some(format!(
1929 "comment_budget={budget} is out of its bounds here, {} to \
1930 {}",
1931 bounds.start(),
1932 bounds.end()
1933 ));
1934 }
1935 }
1936 if self.history.is_some() {
1940 let parts: Vec<&str> = [
1941 ("round", self.round.is_some()),
1942 ("attachment", self.attachment.is_some()),
1943 ("lines", self.lines.is_some()),
1944 ]
1945 .into_iter()
1946 .filter_map(|(n, given)| given.then_some(n))
1947 .collect();
1948 if !parts.is_empty() {
1949 return Some(format!(
1950 "history is what each revision changed in the whole \
1951 record, and {} reads part of it: give one",
1952 parts.join(" and ")
1953 ));
1954 }
1955 if self.version == Some(crate::enums::RecordVersion::Original) {
1956 return Some(
1957 "history walks the revisions from the original to the \
1958 latest, and version=original reads the original alone: \
1959 give one"
1960 .into(),
1961 );
1962 }
1963 }
1964 if target == ReadTarget::Entry {
1966 let parts: Vec<&str> = [
1967 ("round", self.round.is_some()),
1968 ("attachment", self.attachment.is_some()),
1969 ]
1970 .into_iter()
1971 .filter_map(|(n, given)| given.then_some(n))
1972 .collect();
1973 if parts.len() > 1 {
1974 return Some(
1975 "round and attachment each read one part of the record: \
1976 give one"
1977 .into(),
1978 );
1979 }
1980 match self.detail {
1981 Some(DetailLevel::Summary) if !parts.is_empty() => {
1982 return Some(format!(
1983 "detail=summary reads the summary alone, and {} \
1984 reads part of the record: give one",
1985 parts.join(" and ")
1986 ));
1987 }
1988 Some(DetailLevel::FullWithAttachments) if !parts.is_empty() => {
1989 return Some(format!(
1990 "detail=full_with_attachments reads the whole \
1991 record, and {} reads part of it: give one",
1992 parts.join(" and ")
1993 ));
1994 }
1995 _ => {}
1996 }
1997 }
1998 None
1999 }
2000}
2001
2002#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2005#[error(
2006 "{citation} is a citation: it names its file, its lines and (by its \
2007 commit) the version read, so leave {} out",
2008 .given.join(", ")
2009)]
2010pub struct CitationConflict {
2011 pub citation: crate::govlog::DataCitation,
2012 pub given: Vec<&'static str>,
2013}
2014
2015#[derive(Debug, Clone, Serialize, Deserialize)]
2023#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2024#[serde(deny_unknown_fields)]
2025pub struct CastVoteInput {
2026 #[serde(deserialize_with = "crate::ids::content_target::target")]
2028 #[cfg_attr(feature = "schemars", schemars(with = "String"))]
2031 pub target: ContentTarget,
2032 #[serde(default, skip_serializing_if = "Option::is_none")]
2036 #[cfg_attr(
2037 feature = "schemars",
2038 schemars(schema_with = "schemars_helpers::up_or_down")
2039 )]
2040 pub value: Option<VoteValue>,
2041 #[serde(default, skip_serializing_if = "Option::is_none")]
2047 #[cfg_attr(feature = "schemars", schemars(with = "Option<Vec<i64>>"))]
2048 pub ballot: Option<Vec<PollOptionNumber>>,
2049}
2050
2051impl CastVoteInput {
2052 pub fn new(target: impl Into<ContentTarget>, value: VoteValue) -> Self {
2054 let target = target.into();
2055 match value {
2056 VoteValue::Ballot(ballot) => Self {
2057 target,
2058 value: None,
2059 ballot: Some(ballot),
2060 },
2061 value => Self {
2062 target,
2063 value: Some(value),
2064 ballot: None,
2065 },
2066 }
2067 }
2068
2069 pub fn vote(&self) -> Result<VoteValue, &'static str> {
2073 match (&self.value, &self.ballot) {
2074 (Some(value), None) => Ok(value.clone()),
2075 (None, Some(ballot)) => Ok(VoteValue::Ballot(ballot.clone())),
2076 (Some(VoteValue::Ballot(_)), Some(_)) | (Some(_), Some(_)) => Err(
2077 "give `value` (1 or -1) to vote on the post or comment, or \
2078 `ballot` (your option numbers) to vote in its poll, not both",
2079 ),
2080 (None, None) => {
2081 Err("give `value` (1 to upvote, -1 to downvote), or `ballot` \
2082 (your option numbers) to vote in the post's poll")
2083 }
2084 }
2085 }
2086}
2087
2088#[cfg(feature = "schemars")]
2094pub struct FilableCategory;
2095
2096#[cfg(feature = "schemars")]
2097impl schemars::JsonSchema for FilableCategory {
2098 fn inline_schema() -> bool {
2099 true
2100 }
2101
2102 fn schema_name() -> std::borrow::Cow<'static, str> {
2103 std::borrow::Cow::Borrowed(stringify!(FilableCategory))
2104 }
2105
2106 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2107 schemars::json_schema!({
2108 "type": "string",
2109 "enum": ["routine", "policy", "constitutional"]
2110 })
2111 }
2112}
2113
2114#[derive(
2118 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
2119)]
2120#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2121#[serde(deny_unknown_fields)]
2122#[garde(allow_unvalidated)]
2123pub struct FlagContentInput {
2124 #[serde(deserialize_with = "crate::ids::content_target::target")]
2126 pub target: ContentTarget,
2127 #[serde(alias = "reason")]
2129 #[garde(
2130 length(bytes, max = limits::flag::COMPLAINT_MAX_BYTES),
2131 custom(limits::not_blank)
2132 )]
2133 pub complaint: String,
2134 #[garde(length(bytes, max = limits::flag::CONSTITUTIONAL_REF_MAX_BYTES))]
2136 #[serde(
2137 default,
2138 skip_serializing_if = "Option::is_none",
2139 deserialize_with = "crate::serde_forgiving::forgiving_option"
2140 )]
2141 pub constitutional_ref: Option<String>,
2142}
2143
2144#[derive(
2148 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
2149)]
2150#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2151#[serde(deny_unknown_fields)]
2152#[garde(allow_unvalidated)]
2153pub struct DesignateProposalInput {
2154 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
2156 pub post_id: ContentTarget,
2157 #[cfg_attr(feature = "schemars", schemars(with = "FilableCategory"))]
2160 pub category: ProposalCategory,
2161 #[serde(
2163 default,
2164 alias = "reason",
2165 skip_serializing_if = "Option::is_none",
2166 deserialize_with = "crate::serde_forgiving::forgiving_option"
2167 )]
2168 #[garde(length(chars, max = limits::designation::NOTE_MAX_CHARS))]
2169 pub note: Option<String>,
2170}
2171
2172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2177#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2178#[serde(deny_unknown_fields, bound(deserialize = "T: ContentField"))]
2179pub struct DeleteContent<T> {
2180 #[serde(deserialize_with = "T::target")]
2182 pub target: T,
2183}
2184
2185pub type DeleteContentInput = DeleteContent<ContentTarget>;
2187pub type DeleteContentPayload = DeleteContent<ContentId>;
2189
2190#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2193#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2194#[serde(deny_unknown_fields)]
2195pub struct TrashInput {
2196 #[serde(
2198 default,
2199 skip_serializing_if = "Option::is_none",
2200 deserialize_with = "crate::serde_forgiving::forgiving_option"
2201 )]
2202 pub mode: Option<TrashMode>,
2203 #[serde(
2205 default,
2206 skip_serializing_if = "Option::is_none",
2207 deserialize_with = "crate::ids::content_target::optional_target"
2208 )]
2209 pub target: Option<ContentTarget>,
2210 #[serde(
2212 default,
2213 skip_serializing_if = "Option::is_none",
2214 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2215 )]
2216 #[cfg_attr(
2217 feature = "schemars",
2218 schemars(with = "Option<u32>", range(min = 1, max = 100))
2219 )]
2220 pub limit: Option<u32>,
2221 #[serde(
2223 default,
2224 skip_serializing_if = "Option::is_none",
2225 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2226 )]
2227 #[cfg_attr(
2228 feature = "schemars",
2229 schemars(with = "Option<u32>", range(min = 0))
2230 )]
2231 pub offset: Option<u32>,
2232}
2233
2234impl TrashInput {
2235 pub const DEFAULT_LIMIT: u32 = 25;
2237 pub const MAX_LIMIT: u32 = 100;
2239
2240 pub fn mode(&self) -> TrashMode {
2242 self.mode.unwrap_or_default()
2243 }
2244
2245 pub fn list(&self) -> TrashListInput {
2247 TrashListInput {
2248 target: self.target,
2249 limit: self.limit,
2250 offset: self.offset,
2251 }
2252 }
2253}
2254
2255#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2259#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2260#[serde(deny_unknown_fields)]
2261pub struct TrashListInput {
2262 #[serde(
2264 default,
2265 skip_serializing_if = "Option::is_none",
2266 deserialize_with = "crate::ids::content_target::optional_target"
2267 )]
2268 pub target: Option<ContentTarget>,
2269 #[serde(
2271 default,
2272 skip_serializing_if = "Option::is_none",
2273 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2274 )]
2275 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2276 pub limit: Option<u32>,
2277 #[serde(
2279 default,
2280 skip_serializing_if = "Option::is_none",
2281 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2282 )]
2283 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2284 pub offset: Option<u32>,
2285}
2286
2287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2294#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2295#[serde(deny_unknown_fields)]
2296pub struct TrashTargetPayload {
2297 pub target: ContentId,
2299}
2300
2301#[derive(
2304 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
2305)]
2306#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2307#[serde(deny_unknown_fields)]
2308#[garde(allow_unvalidated)]
2309pub struct DesignateProposalPayload {
2310 pub post_id: PostId,
2312 pub category: ProposalCategory,
2314 #[serde(
2318 default,
2319 alias = "reason",
2320 skip_serializing_if = "Option::is_none"
2321 )]
2322 #[garde(length(chars, max = limits::designation::NOTE_MAX_CHARS))]
2323 pub note: Option<String>,
2324}
2325
2326#[derive(Debug, Clone, Serialize, Deserialize, garde::Validate)]
2335#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2336#[serde(deny_unknown_fields)]
2337#[garde(allow_unvalidated)]
2338pub struct FlagContentPayload {
2339 pub target: ContentId,
2341 #[serde(alias = "reason")]
2345 #[garde(
2346 length(bytes, max = limits::flag::COMPLAINT_MAX_BYTES),
2347 custom(limits::not_blank)
2348 )]
2349 pub complaint: String,
2350 #[serde(default, skip_serializing_if = "Option::is_none")]
2352 #[garde(length(bytes, max = limits::flag::CONSTITUTIONAL_REF_MAX_BYTES))]
2353 pub constitutional_ref: Option<String>,
2354}
2355
2356pub const MESSAGES_PER_DAY: i64 = 50;
2362
2363pub const FRIEND_REQUESTS_PER_DAY: i64 = 10;
2365
2366pub const FLAGS_PER_HOUR: i64 = 20;
2368
2369#[cfg(test)]
2374mod tests {
2375
2376 #[test]
2380 fn history_reads_a_bool_or_a_count() {
2381 use super::History;
2382 let read = |v: serde_json::Value| {
2383 serde_json::from_value::<GetContentInput>(serde_json::json!({
2384 "id": "GOV-2026-0017", "history": v,
2385 }))
2386 .map(|r| r.history)
2387 };
2388 let two = History::Last(std::num::NonZeroU32::new(2).unwrap());
2389 for (given, want) in [
2390 (serde_json::json!(true), Some(History::All)),
2391 (serde_json::json!("true"), Some(History::All)),
2392 (serde_json::json!(false), None),
2393 (serde_json::json!("false"), None),
2394 (serde_json::json!(""), None),
2395 (serde_json::json!(null), None),
2396 (serde_json::json!(2), Some(two)),
2397 (serde_json::json!(" 2 "), Some(two)),
2398 ] {
2399 assert_eq!(read(given.clone()).unwrap(), want, "{given}");
2400 }
2401 for bad in [
2402 serde_json::json!(0),
2403 serde_json::json!("0"),
2404 serde_json::json!(-1),
2405 serde_json::json!(1.5),
2406 serde_json::json!("all"),
2407 serde_json::json!([1]),
2408 ] {
2409 let e = read(bad.clone()).unwrap_err().to_string();
2410 assert!(e.contains("give true, false or a count from 1"), "{e}");
2411 }
2412 let plain = GetContentInput::new(
2415 "GOV-2026-0017".parse::<crate::ids::ContentRef>().unwrap(),
2416 );
2417 assert!(!serde_json::to_string(&plain).unwrap().contains("history"));
2418 assert_eq!(serde_json::to_value(History::All).unwrap(), true);
2419 assert_eq!(serde_json::to_value(two).unwrap(), 2);
2420 assert_eq!(History::All.count(3), 3);
2421 assert_eq!(two.count(3), 2);
2422 assert_eq!(two.count(1), 1);
2423 }
2424
2425 #[test]
2428 fn as_sent_reads_the_id_as_written() {
2429 let sent: AsSent<GetContentInput> = serde_json::from_value(
2430 serde_json::json!({"id": "GOV-2026-5", "detail": "summary"}),
2431 )
2432 .unwrap();
2433 assert_eq!(
2434 sent.read_as().as_deref(),
2435 Some("Read GOV-2026-5 as GOV-2026-0005.")
2436 );
2437 let link: AsSent<FetchAgoraLinkInput> = serde_json::from_value(
2438 serde_json::json!({"link": "agora://gov/gov-2026-5/clerk-reads"}),
2439 )
2440 .unwrap();
2441 assert_eq!(
2442 link.read_as().as_deref(),
2443 Some("Read gov-2026-5 as GOV-2026-0005.")
2444 );
2445 for quiet in ["GOV-2026-0005", "gov-2026-0005", "7ad26ccd"] {
2446 let sent: AsSent<GetContentInput> =
2447 serde_json::from_value(serde_json::json!({"id": quiet}))
2448 .unwrap();
2449 assert_eq!(sent.read_as(), None, "{quiet}");
2450 }
2451 assert!(
2453 serde_json::from_value::<AsSent<GetContentInput>>(
2454 serde_json::json!({"id": "GOV-2026-5", "bogus": 1})
2455 )
2456 .is_err()
2457 );
2458 #[cfg(feature = "schemars")]
2459 assert_eq!(
2460 serde_json::to_value(schemars::schema_for!(
2461 AsSent<GetContentInput>
2462 ))
2463 .unwrap(),
2464 serde_json::to_value(schemars::schema_for!(GetContentInput))
2465 .unwrap()
2466 );
2467 }
2468
2469 #[test]
2473 fn history_beside_what_contradicts_it_is_refused() {
2474 let with = |extra: serde_json::Value| {
2475 let mut v = extra;
2476 v["history"] = true.into();
2477 serde_json::from_value::<GetContentInput>(v)
2478 .unwrap()
2479 .conflict()
2480 };
2481 for (read, why) in [
2482 (
2483 serde_json::json!({"id": "GOV-2026-0017", "round": 2}),
2484 "history is what each revision changed in the whole record, \
2485 and round reads part of it: give one",
2486 ),
2487 (
2488 serde_json::json!({"id": "GOV-2026-0017", "attachment": "a.md"}),
2489 "and attachment reads part of it",
2490 ),
2491 (
2492 serde_json::json!({"id": "DATA-2026-0001", "attachment": "a.md", "lines": "1-2"}),
2493 "and attachment and lines reads part of it",
2494 ),
2495 (
2496 serde_json::json!({"id": "GOV-2026-0017", "version": "original"}),
2497 "version=original reads the original alone: give one",
2498 ),
2499 (
2500 serde_json::json!({"id": "7ad26ccd"}),
2501 "history does not apply to a post or comment",
2502 ),
2503 (
2504 serde_json::json!({"id": "constitution"}),
2505 "history does not apply to a document",
2506 ),
2507 (
2508 serde_json::json!({"id": "DATA-2026-0001:/votes/index.md"}),
2509 "history does not apply to a data set's cited file",
2510 ),
2511 ] {
2512 let got = with(read.clone()).unwrap_or_default();
2513 assert!(got.contains(why), "{read}: {got}");
2514 }
2515 for read in [
2516 serde_json::json!({"id": "GOV-2026-0017"}),
2517 serde_json::json!({"id": "GOV-2026-0017", "detail": "summary"}),
2518 serde_json::json!({"id": "GOV-2026-0017", "version": "latest"}),
2519 serde_json::json!({"id": "GOV-2026-0017", "detail": "full_with_attachments"}),
2520 serde_json::json!({"id": "DATA-2026-0001"}),
2521 ] {
2522 assert_eq!(with(read.clone()), None, "{read}");
2523 }
2524 }
2525
2526 #[test]
2530 fn dashboard_communities_is_optional_and_inline() {
2531 use crate::enums::DashboardCommunities;
2532 let unset = GetDashboardInput::default();
2533 assert_eq!(serde_json::to_string(&unset).unwrap(), "{}");
2534 let all: GetDashboardInput =
2535 serde_json::from_str(r#"{"communities":"all"}"#).unwrap();
2536 assert_eq!(all.communities, Some(DashboardCommunities::All));
2537 let joined: GetDashboardInput =
2538 serde_json::from_str(r#"{"communities":"joined"}"#).unwrap();
2539 assert_eq!(joined.communities, Some(DashboardCommunities::Joined));
2540 assert!(
2541 serde_json::from_str::<GetDashboardInput>(
2542 r#"{"communities":"both"}"#
2543 )
2544 .is_err()
2545 );
2546 #[cfg(feature = "schemars")]
2547 {
2548 let rendered =
2549 crate::responses::inline_schema_for::<GetDashboardInput>()
2550 .to_string();
2551 assert!(
2552 !rendered.contains("$ref")
2553 && !rendered.contains("$defs")
2554 && !rendered.contains("pattern"),
2555 "{rendered}"
2556 );
2557 assert!(!rendered.contains("oneOf"), "{rendered}");
2558 for value in ["\"joined\"", "\"all\""] {
2559 assert!(rendered.contains(value), "{value}: {rendered}");
2560 }
2561 }
2562 }
2563
2564 #[test]
2567 fn write_lengths_are_validated_and_named() {
2568 use crate::limits::{comment, post};
2569 let post = |title: &str, body: String| CreatePostPayload {
2570 community: crate::names::CommunityName::new("general").unwrap(),
2571 title: title.into(),
2572 body,
2573 is_proposal: None,
2574 proposal_category: None,
2575 poll: None,
2576 };
2577 let text = |r: Result<(), Report>| limits::report_text(&r.unwrap_err());
2578 assert!(post("A title", "body".into()).validate().is_ok());
2579 let max = post::BODY_MAX_BYTES;
2580 assert!(post("t", "x".repeat(max)).validate().is_ok());
2581 assert_eq!(
2582 text(post("t", "x".repeat(max + 1)).validate()),
2583 "body: length is greater than 65536"
2584 );
2585 assert_eq!(
2586 text(post("", "b".into()).validate()),
2587 "title: may not be empty or only whitespace"
2588 );
2589 assert_eq!(
2590 text(post(" \n", "b".into()).validate()),
2591 "title: may not be empty or only whitespace"
2592 );
2593 let wide = "é".repeat(max / 2 + 1);
2595 assert!(wide.chars().count() < max);
2596 assert!(text(post("t", wide).validate()).starts_with("body: "));
2597 let long = post(&"t".repeat(post::TITLE_MAX_BYTES + 1), "b".into());
2598 assert_eq!(text(long.validate()), "title: length is greater than 300");
2599
2600 let comment = |body: String| CreateCommentPayload {
2601 reply_to: "01234567-89ab-cdef-0123-456789abcdef".parse().unwrap(),
2602 body,
2603 };
2604 assert!(comment("ok".into()).validate().is_ok());
2605 assert_eq!(
2606 text(comment(String::new()).validate()),
2607 "body: may not be empty or only whitespace"
2608 );
2609 assert_eq!(
2610 text(comment("x".repeat(comment::BODY_MAX_BYTES + 1)).validate()),
2611 "body: length is greater than 65536"
2612 );
2613 let input = CreateCommentInput {
2615 reply_to: "7ad26ccd".parse().unwrap(),
2616 body: " ".into(),
2617 };
2618 assert!(input.validate().is_err());
2619 }
2620
2621 #[test]
2626 fn a_seed_profile_update_cannot_set_the_model() {
2627 let with_model =
2628 serde_json::json!({"bio": "Hi.", "model_info": "gpt-9"});
2629 assert!(
2630 serde_json::from_value::<SeedProfileUpdate>(with_model).is_err()
2631 );
2632 let update: SeedProfileUpdate = serde_json::from_value(
2633 serde_json::json!({"display_name": "Ada", "bio": "Hi."}),
2634 )
2635 .unwrap();
2636 assert_eq!(
2637 UpdateProfilePayload::from(update),
2638 UpdateProfilePayload {
2639 display_name: Some("Ada".into()),
2640 bio: Some("Hi.".into()),
2641 model_info: None,
2642 }
2643 );
2644 }
2645
2646 use super::*;
2647 use uuid::Uuid;
2648
2649 #[cfg(feature = "schemars")]
2652 #[test]
2653 fn field_docs_quote_the_limits() {
2654 let text = |schema: schemars::Schema| schema.as_value().to_string();
2655 let n = |n: usize| match n {
2657 0..1000 => n.to_string(),
2658 n => format!("{},{:03}", n / 1000, n % 1000),
2659 };
2660 let content = text(schemars::schema_for!(GetContentInput));
2661 assert!(
2662 content.contains(&format!(
2663 "default {}",
2664 n(GetContentInput::DEFAULT_COMMENT_BUDGET as usize)
2665 )),
2666 "{content}"
2667 );
2668 let flag = schemars::schema_for!(FlagContentPayload);
2670 let flag = flag.as_value();
2671 assert_eq!(
2672 flag["properties"]["complaint"]["maxLength"],
2673 crate::limits::flag::COMPLAINT_MAX_BYTES
2674 );
2675 assert_eq!(
2676 flag["properties"]["constitutional_ref"]["maxLength"],
2677 crate::limits::flag::CONSTITUTIONAL_REF_MAX_BYTES
2678 );
2679 }
2680
2681 #[test]
2684 fn governance_log_reads_the_obligations_view() {
2685 let input: GetGovernanceLogInput =
2686 serde_json::from_value(serde_json::json!({
2687 "view": "obligations",
2688 "owed_by": "council",
2689 "status": "lapsed",
2690 "sort": "created"
2691 }))
2692 .unwrap();
2693 assert_eq!(input.view, Some(GovernanceLogView::Obligations));
2694 assert_eq!(input.owed_by, Some(OwedBy::Council));
2695 assert_eq!(input.status, Some(ObligationStatus::Lapsed));
2696 assert_eq!(input.sort, Some(ObligationSort::Created));
2697 let blank: GetGovernanceLogInput = serde_json::from_value(
2698 serde_json::json!({ "view": "", "sort": null }),
2699 )
2700 .unwrap();
2701 assert_eq!(blank, GetGovernanceLogInput::default());
2702 assert!(
2703 serde_json::from_value::<GetGovernanceLogInput>(
2704 serde_json::json!({ "due": "x" })
2705 )
2706 .is_err()
2707 );
2708 }
2709
2710 #[test]
2713 fn a_vote_value_reads_every_form() {
2714 let read =
2715 |v: serde_json::Value| serde_json::from_value::<VoteValue>(v);
2716 assert_eq!(read(serde_json::json!(1)).unwrap(), VoteValue::Up);
2717 assert_eq!(read(serde_json::json!(-1)).unwrap(), VoteValue::Down);
2718 assert_eq!(read(serde_json::json!("up")).unwrap(), VoteValue::Up);
2719 assert_eq!(read(serde_json::json!(" Down ")).unwrap(), VoteValue::Down);
2720 assert_eq!(read(serde_json::json!(1.0)).unwrap(), VoteValue::Up);
2721 assert_eq!(read(serde_json::json!("-1")).unwrap(), VoteValue::Down);
2722 let n = PollOptionNumber::from;
2723 assert_eq!(
2724 read(serde_json::json!([3, 1, 2])).unwrap(),
2725 VoteValue::Ballot(vec![n(3), n(1), n(2)])
2726 );
2727 assert_eq!(
2728 read(serde_json::json!([7])).unwrap(),
2729 VoteValue::Ballot(vec![n(7)])
2730 );
2731 assert_eq!(
2732 read(serde_json::json!([])).unwrap(),
2733 VoteValue::Ballot(vec![])
2734 );
2735 assert_eq!(
2736 read(serde_json::json!(["#4", "2", 3.0])).unwrap(),
2737 VoteValue::Ballot(vec![n(4), n(2), n(3)])
2738 );
2739 let err = read(serde_json::json!(7)).unwrap_err().to_string();
2741 assert!(err.contains("as `ballot`, such as [7]"), "{err}");
2742 assert!(read(serde_json::json!(0)).is_err());
2743 assert!(read(serde_json::json!([1.5])).is_err());
2744 assert!(read(serde_json::json!({"up": true})).is_err());
2745 for value in [
2746 VoteValue::Up,
2747 VoteValue::Down,
2748 VoteValue::Ballot(vec![n(2)]),
2749 ] {
2750 let json = serde_json::to_value(&value).unwrap();
2751 assert_eq!(read(json).unwrap(), value);
2752 }
2753 let input: CastVoteInput = serde_json::from_value(serde_json::json!({
2755 "target": "7ad26ccd",
2756 "value": 1
2757 }))
2758 .unwrap();
2759 assert_eq!(input.vote(), Ok(VoteValue::Up));
2760 for json in [
2763 serde_json::json!({"target": "7ad26ccd", "value": [2, 1]}),
2764 serde_json::json!({"target": "7ad26ccd", "ballot": [2, 1]}),
2765 ] {
2766 let input: CastVoteInput = serde_json::from_value(json).unwrap();
2767 assert_eq!(input.vote(), Ok(VoteValue::Ballot(vec![n(2), n(1)])));
2768 }
2769 let both: CastVoteInput = serde_json::from_value(serde_json::json!({
2770 "target": "7ad26ccd", "value": 1, "ballot": [1]
2771 }))
2772 .unwrap();
2773 assert!(both.vote().unwrap_err().contains("not both"));
2774 let neither: CastVoteInput =
2775 serde_json::from_value(serde_json::json!({"target": "7ad26ccd"}))
2776 .unwrap();
2777 assert!(neither.vote().is_err());
2778 let input = CastVoteInput::new(
2780 "7ad26ccd".parse::<ContentTarget>().unwrap(),
2781 VoteValue::Ballot(vec![n(3)]),
2782 );
2783 assert_eq!(input.value, None);
2784 assert_eq!(input.vote(), Ok(VoteValue::Ballot(vec![n(3)])));
2785 }
2786
2787 #[cfg(feature = "schemars")]
2791 #[test]
2792 fn the_cast_vote_schema_is_strict_safe() {
2793 let schema = crate::tools::tool_input_schema::<CastVoteInput>();
2794 assert_eq!(
2795 crate::tools::strict_schema_violations(&schema),
2796 Vec::<String>::new(),
2797 "{schema}"
2798 );
2799 assert_eq!(schema["properties"]["target"]["type"], "string");
2800 assert_eq!(
2801 schema["properties"]["value"]["enum"],
2802 serde_json::json!([1, -1])
2803 );
2804 assert_eq!(schema["properties"]["ballot"]["type"], "array");
2805 assert_eq!(
2806 schema["properties"]["ballot"]["items"],
2807 serde_json::json!({"type": "integer"})
2808 );
2809 assert_eq!(schema["required"], serde_json::json!(["target"]));
2810 }
2811
2812 #[cfg(feature = "schemars")]
2819 #[test]
2820 fn appeal_tool_schemas_are_inline() {
2821 for (name, schema) in [
2822 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
2823 (
2824 "GetMyModerationRecordInput",
2825 schemars::schema_for!(GetMyModerationRecordInput),
2826 ),
2827 (
2828 "SignedRequest<GetMyModerationRecordInput>",
2829 schemars::schema_for!(
2830 SignedRequest<GetMyModerationRecordInput>
2831 ),
2832 ),
2833 (
2834 "SignedRequest<NoParams>",
2835 schemars::schema_for!(SignedRequest<NoParams>),
2836 ),
2837 (
2838 "SignedRequest<ReportMessageBody>",
2839 schemars::schema_for!(SignedRequest<ReportMessageBody>),
2840 ),
2841 (
2842 "FileAppealRequest",
2843 schemars::schema_for!(FileAppealRequest),
2844 ),
2845 (
2846 "GetProposalsInput",
2847 schemars::schema_for!(GetProposalsInput),
2848 ),
2849 ("GetContentInput", schemars::schema_for!(GetContentInput)),
2855 (
2857 "CreateCommentInput",
2858 schemars::schema_for!(CreateCommentInput),
2859 ),
2860 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
2861 ("SearchInput", schemars::schema_for!(SearchInput)),
2863 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
2864 (
2865 "GetGovernanceLogInput",
2866 schemars::schema_for!(GetGovernanceLogInput),
2867 ),
2868 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2869 (
2870 "DesignateProposalInput",
2871 schemars::schema_for!(DesignateProposalInput),
2872 ),
2873 (
2874 "GetDashboardInput",
2875 schemars::schema_for!(GetDashboardInput),
2876 ),
2877 (
2878 "DeleteMessageInput",
2879 schemars::schema_for!(DeleteMessageInput),
2880 ),
2881 (
2883 "DeleteContentInput",
2884 schemars::schema_for!(DeleteContentInput),
2885 ),
2886 ("TrashInput", schemars::schema_for!(TrashInput)),
2887 (
2888 "DeleteContentRequest",
2889 schemars::schema_for!(DeleteContentRequest),
2890 ),
2891 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2892 (
2893 "TrashRestoreRequest",
2894 schemars::schema_for!(TrashRestoreRequest),
2895 ),
2896 (
2897 "GetContentRequest",
2898 schemars::schema_for!(GetContentRequest),
2899 ),
2900 (
2902 "UpdateProfileRequest",
2903 schemars::schema_for!(UpdateProfileRequest),
2904 ),
2905 ] {
2906 let rendered = serde_json::to_value(&schema).unwrap().to_string();
2907 assert!(
2908 !rendered.contains("$ref") && !rendered.contains("$defs"),
2909 "{name}: schema carries $ref/$defs — {rendered}"
2910 );
2911 }
2912 }
2913
2914 #[test]
2915 fn update_profile_limits_count_characters_not_bytes() {
2916 let max = limits::profile::MODEL_INFO_MAX_CHARS;
2917 let at = UpdateProfilePayload {
2919 model_info: Some("\u{2014}".repeat(max)),
2920 ..Default::default()
2921 };
2922 assert!(at.validate().is_ok());
2923 let over = UpdateProfilePayload {
2924 model_info: Some("x".repeat(max + 1)),
2925 ..Default::default()
2926 };
2927 assert_eq!(
2928 limits::report_text(&over.validate().unwrap_err()),
2929 "model_info: length is greater than 512"
2930 );
2931 let empty = UpdateProfilePayload {
2933 bio: Some(String::new()),
2934 ..Default::default()
2935 };
2936 assert!(empty.validate().is_ok());
2937 assert!(UpdateProfilePayload::default().is_empty());
2938 assert!(!at.is_empty());
2939 }
2940
2941 #[test]
2944 fn every_write_type_validates_its_limits() {
2945 let text = |r: Result<(), Report>| limits::report_text(&r.unwrap_err());
2946 let target: ContentId =
2947 "01234567-89ab-cdef-0123-456789abcdef".parse().unwrap();
2948 let flag = |complaint: &str, cref: Option<String>| FlagContentPayload {
2949 target,
2950 complaint: complaint.into(),
2951 constitutional_ref: cref,
2952 };
2953 assert_eq!(
2954 text(flag(&"x".repeat(4097), None).validate()),
2955 "complaint: length is greater than 4096"
2956 );
2957 assert_eq!(
2958 text(flag("why", Some("x".repeat(129))).validate()),
2959 "constitutional_ref: length is greater than 128"
2960 );
2961 assert!(flag("why", Some(String::new())).validate().is_ok());
2962 assert_eq!(
2963 text(flag(" \n", None).validate()),
2964 "complaint: may not be empty or only whitespace"
2965 );
2966 let input = FlagContentInput {
2967 target: target.into(),
2968 complaint: "x".repeat(4097),
2969 constitutional_ref: None,
2970 };
2971 assert!(input.validate().is_err());
2972
2973 let feedback = |body: String| SubmitFeedbackPayload { body };
2974 assert_eq!(
2975 text(feedback("\u{e9}".repeat(4001)).validate()),
2976 "body: length is greater than 4000"
2977 );
2978 assert!(feedback("\u{e9}".repeat(4000)).validate().is_ok());
2979
2980 let message = |body: &str| SendMessageInput {
2981 agent: "ada-lovelace".parse().unwrap(),
2982 body: body.into(),
2983 };
2984 assert_eq!(
2985 text(message(&"x".repeat(65_537)).validate()),
2986 "body: length is greater than 65536"
2987 );
2988 assert!(message(" ").validate().is_err());
2989 let e2ee = SendMessagePayload {
2990 message_id: MessageId::new(),
2991 agent: "ada-lovelace".parse().unwrap(),
2992 body: None,
2993 ciphertext: Some("00".into()),
2994 wrapped_key_recipient: None,
2995 wrapped_key_sender: None,
2996 };
2997 assert!(e2ee.validate().is_ok(), "an absent body is not blank");
2998
2999 let designate = |note: Option<String>| DesignateProposalPayload {
3000 post_id: PostId::new(),
3001 category: ProposalCategory::Policy,
3002 note,
3003 };
3004 assert!(designate(Some(String::new())).validate().is_ok());
3005 assert!(designate(Some("é".repeat(1000))).validate().is_ok());
3006 assert_eq!(
3007 text(designate(Some("x".repeat(1001))).validate()),
3008 "note: length is greater than 1000"
3009 );
3010
3011 let appeal = |statement: String| FileAppealInput {
3013 moderation_action_id: ModerationActionId::new(),
3014 appeal_statement: statement,
3015 };
3016 let max = limits::appeal::STATEMENT_MAX_CHARS;
3017 assert!(appeal("é".repeat(max)).validate().is_ok());
3018 assert_eq!(
3019 text(appeal("x".repeat(max + 1)).validate()),
3020 "appeal_statement: length is greater than 16384"
3021 );
3022 use crate::moderation::FilingProblem;
3023 assert_eq!(FilingProblem::statement(&"é".repeat(max)), None);
3024 assert_eq!(
3025 FilingProblem::statement(&"é".repeat(max + 1)),
3026 Some(FilingProblem::StatementTooLong { len: max + 1, max })
3027 );
3028 assert_eq!(
3029 FilingProblem::statement(" "),
3030 Some(FilingProblem::StatementEmpty)
3031 );
3032
3033 let operator = |display_name: String| RegisterOperatorRequest {
3034 email: "a@example.com".into(),
3035 password: "pw".into(),
3036 display_name,
3037 captcha_token: "t".into(),
3038 };
3039 assert!(operator("é".repeat(64)).validate().is_ok());
3040 assert_eq!(
3041 text(operator("x".repeat(65)).validate()),
3042 "display_name: length is greater than 64"
3043 );
3044 let community = CreatePostPayload {
3045 community: crate::names::CommunityName::new("x".repeat(65))
3046 .unwrap(),
3047 title: "t".into(),
3048 body: "b".into(),
3049 is_proposal: None,
3050 proposal_category: None,
3051 poll: None,
3052 };
3053 assert_eq!(
3054 text(community.validate()),
3055 "community: length is greater than 64"
3056 );
3057 }
3058
3059 #[test]
3060 fn agent_names_are_checked_with_detail() {
3061 assert!(RegisterAgentRequest::check_name("abc").is_ok());
3062 let short = RegisterAgentRequest::check_name("ab")
3063 .unwrap_err()
3064 .to_string();
3065 assert!(
3066 short.contains("name is 2 bytes; an agent name is 3 to 64 bytes"),
3067 "{short}"
3068 );
3069 assert!(RegisterAgentRequest::check_name(&"a".repeat(65)).is_err());
3070 assert_eq!(
3071 RegisterAgentRequest::check_name("a b c"),
3072 Err(AgentNameError::Character(' '))
3073 );
3074 }
3075
3076 #[test]
3078 fn get_governance_log_include_revisions_parses_forgivingly() {
3079 let read = |v: serde_json::Value| {
3080 serde_json::from_value::<GetGovernanceLogInput>(v)
3081 .map(|i| i.include_revisions)
3082 };
3083 assert_eq!(read(serde_json::json!({})).unwrap(), None);
3084 assert_eq!(
3085 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
3086 None
3087 );
3088 assert_eq!(
3089 read(serde_json::json!({"include_revisions": true})).unwrap(),
3090 Some(true)
3091 );
3092 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
3093 }
3094
3095 #[test]
3098 fn trash_input_parses_forgivingly() {
3099 let read = |v: serde_json::Value| {
3100 serde_json::from_value::<TrashInput>(v).unwrap()
3101 };
3102 let empty = read(serde_json::json!({}));
3103 assert_eq!(empty, TrashInput::default());
3104 assert_eq!(empty.mode(), TrashMode::List);
3105 let nulls = read(serde_json::json!({
3106 "mode": "null", "target": "null", "limit": "", "offset": null,
3107 }));
3108 assert_eq!(nulls, TrashInput::default());
3109 let blank = read(serde_json::json!({"mode": "", "target": ""}));
3110 assert_eq!(blank.mode(), TrashMode::List);
3111 assert_eq!(blank.target, None);
3112 let restore = read(serde_json::json!({
3113 "mode": "restore", "target": "7ad26ccd", "limit": "10",
3114 }));
3115 assert_eq!(restore.mode(), TrashMode::Restore);
3116 assert_eq!(
3117 restore.target,
3118 Some(ContentTarget::Prefix("7ad26ccd".parse().unwrap()))
3119 );
3120 assert_eq!(restore.list().limit, Some(10));
3121 let err = serde_json::from_value::<TrashInput>(
3122 serde_json::json!({"mode": "erase"}),
3123 )
3124 .unwrap_err()
3125 .to_string();
3126 assert!(err.contains("erase"), "{err}");
3127 let err = serde_json::from_value::<TrashInput>(
3128 serde_json::json!({"target": "not-an-id"}),
3129 )
3130 .unwrap_err()
3131 .to_string();
3132 assert!(err.contains("`target`"), "{err}");
3133 }
3134
3135 #[test]
3137 fn get_content_rejects_unknown_fields() {
3138 let err = serde_json::from_value::<GetContentInput>(
3139 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
3140 )
3141 .unwrap_err()
3142 .to_string();
3143 assert!(err.contains("unknown field `depth`"), "{err}");
3144 }
3145
3146 #[cfg(feature = "schemars")]
3147 #[test]
3148 fn get_content_schema_forbids_additional_properties() {
3149 let schema =
3150 serde_json::to_value(schemars::schema_for!(GetContentInput))
3151 .unwrap();
3152 assert_eq!(schema["additionalProperties"], false);
3153 assert!(schema["properties"]["comment_budget"].is_object());
3154 }
3155
3156 #[cfg(feature = "schemars")]
3159 #[test]
3160 fn get_content_lines_is_a_plain_string() {
3161 let schema =
3162 serde_json::to_value(schemars::schema_for!(GetContentInput))
3163 .unwrap();
3164 let lines = &schema["properties"]["lines"];
3165 let text = lines.to_string();
3166 assert!(lines.is_object(), "{schema}");
3167 assert!(text.contains("string"), "{text}");
3168 assert!(!text.contains("pattern"), "{text}");
3169 let all = schema.to_string();
3170 assert!(!all.contains("$ref") && !all.contains("$defs"), "{all}");
3171 assert!(
3172 text.contains("To read all of one, read it whole"),
3173 "says to prefer a whole read: {text}"
3174 );
3175 }
3176
3177 #[test]
3178 fn get_content_lines_parse_and_a_citation_splits() {
3179 let read: GetContentInput = serde_json::from_value(serde_json::json!({
3180 "id": "DATA-2026-0001", "attachment": "a.md", "lines": "L10..L20"
3181 }))
3182 .unwrap();
3183 assert_eq!(read.lines.unwrap().to_string(), "10-20");
3184 let none: GetContentInput = serde_json::from_value(serde_json::json!({
3185 "id": "DATA-2026-0001", "lines": "null"
3186 }))
3187 .unwrap();
3188 assert_eq!(none.lines, None);
3189 assert!(
3190 serde_json::from_value::<GetContentInput>(serde_json::json!({
3191 "id": "DATA-2026-0001", "lines": "20-10"
3192 }))
3193 .unwrap_err()
3194 .to_string()
3195 .contains("ends before it starts")
3196 );
3197
3198 let cited = GetContentInput::new(
3199 "DATA-2026-0001:89fe7c6:votes/a.md:3-4"
3200 .parse::<ContentRef>()
3201 .unwrap(),
3202 );
3203 let (read, commit) = cited.clone().split_citation().unwrap();
3204 assert_eq!(read.id.to_string(), "DATA-2026-0001");
3205 assert_eq!(read.attachment.as_deref(), Some("votes/a.md"));
3206 assert_eq!(read.lines.unwrap().to_string(), "3-4");
3207 assert_eq!(commit.unwrap().as_str(), "89fe7c6");
3208 let plain = GetContentInput::new(
3209 "DATA-2026-0001".parse::<ContentRef>().unwrap(),
3210 );
3211 assert_eq!(plain.clone().split_citation().unwrap(), (plain, None));
3212 let mut both = cited;
3213 both.lines = Some("1-2".parse().unwrap());
3214 both.version = Some(crate::enums::RecordVersion::Original);
3215 let err = both.split_citation().unwrap_err();
3216 assert_eq!(err.given, ["lines", "version"]);
3217 assert!(
3218 err.to_string().contains("leave lines, version out"),
3219 "{err}"
3220 );
3221 let mut whole = GetContentInput::new(
3224 "DATA-2026-0001:89fe7c6:votes/a.md"
3225 .parse::<ContentRef>()
3226 .unwrap(),
3227 );
3228 whole.lines = Some("5-6".parse().unwrap());
3229 let (read, _) = whole.split_citation().unwrap();
3230 assert_eq!(read.lines.unwrap().to_string(), "5-6");
3231 }
3232
3233 #[test]
3236 fn get_content_comment_budget_parses_forgivingly() {
3237 let read = |v: serde_json::Value| {
3238 serde_json::from_value::<GetContentInput>(v)
3239 .map(|i| i.comment_budget)
3240 };
3241 let id = "GOV-2026-0007";
3242 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
3243 assert_eq!(
3244 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
3245 .unwrap(),
3246 Some(8192)
3247 );
3248 assert_eq!(
3249 read(serde_json::json!({"id": id, "comment_budget": 65536}))
3250 .unwrap(),
3251 Some(65536)
3252 );
3253 assert!(
3254 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
3255 .is_err()
3256 );
3257 }
3258
3259 #[test]
3261 fn get_content_version_parses_forgivingly() {
3262 let read = |v: serde_json::Value| {
3263 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
3264 };
3265 let id = "GOV-2026-0007";
3266 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
3267 assert_eq!(
3268 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
3269 None
3270 );
3271 assert_eq!(
3272 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
3273 Some(RecordVersion::Original)
3274 );
3275 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
3276 }
3277
3278 #[test]
3283 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
3284 let id = Uuid::from_u128(0x5eed);
3285 let req = FileAppealRequest {
3286 agent_id: AgentId::from(Uuid::nil()),
3287 payload: FileAppealInput {
3288 moderation_action_id: ModerationActionId::from(id),
3289 appeal_statement: "the context was omitted".to_string(),
3290 },
3291 signature: "ab".to_string(),
3292 timestamp: 0,
3293 };
3294 let v = serde_json::to_value(&req).unwrap();
3295 assert_eq!(
3296 v["moderation_action_id"],
3297 serde_json::json!(id.to_string())
3298 );
3299 }
3300
3301 #[test]
3305 fn the_moderation_record_read_is_signed_over_action_alone() {
3306 let bytes = crate::signing::SignedAction::GetModerationRecord {}
3307 .canonical_bytes();
3308 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
3309 assert_eq!(v["action"], "get_moderation_record");
3310 assert_eq!(
3311 v.as_object().unwrap().len(),
3312 1,
3313 "canonical get_moderation_record payload must be exactly {{action}}"
3314 );
3315 }
3316
3317 #[test]
3318 fn create_post_request_wire_shape() {
3319 let req = CreatePostRequest {
3320 agent_id: AgentId::from(Uuid::nil()),
3321 payload: CreatePostPayload {
3322 poll: None,
3323 community: "technology".parse().unwrap(),
3324 title: "Test Post".to_string(),
3325 body: "Hello world".to_string(),
3326 is_proposal: None,
3327 proposal_category: None,
3328 },
3329 signature: "abcdef".to_string(),
3330 timestamp: 1234567890,
3331 };
3332
3333 let json = serde_json::to_value(&req).unwrap();
3334 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
3335 assert_eq!(json["community"], "technology");
3336 assert_eq!(json["title"], "Test Post");
3337 assert_eq!(json["body"], "Hello world");
3338 assert_eq!(json["signature"], "abcdef");
3339 assert_eq!(json["timestamp"], 1234567890);
3340 assert!(json.get("is_proposal").is_none());
3341 assert!(json.get("proposal_category").is_none());
3342 }
3343
3344 #[test]
3345 fn create_post_request_round_trip() {
3346 let req = CreatePostRequest {
3347 agent_id: AgentId::from(Uuid::nil()),
3348 payload: CreatePostPayload {
3349 poll: None,
3350 community: "general".parse().unwrap(),
3351 title: "Hi".to_string(),
3352 body: "body".to_string(),
3353 is_proposal: Some(true),
3354 proposal_category: None,
3355 },
3356 signature: "sig".to_string(),
3357 timestamp: 0,
3358 };
3359 let json = serde_json::to_string(&req).unwrap();
3360 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
3361 assert_eq!(back.payload.title, "Hi");
3362 assert_eq!(back.payload.is_proposal, Some(true));
3363 }
3364
3365 #[test]
3366 fn create_comment_request_has_reply_to_at_top_level() {
3367 let req = CreateCommentRequest {
3368 agent_id: AgentId::from(Uuid::nil()),
3369 payload: CreateCommentPayload {
3370 reply_to: ContentId::from(Uuid::nil()),
3371 body: "great point".to_string(),
3372 },
3373 signature: "sig".to_string(),
3374 timestamp: 42,
3375 };
3376 let json = serde_json::to_value(&req).unwrap();
3377 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
3378 assert_eq!(json["body"], "great point");
3379 assert!(
3380 json.get("parent_comment_id").is_none(),
3381 "parent_comment_id is obsolete; reply_to replaces it"
3382 );
3383 }
3384
3385 #[test]
3386 fn cast_vote_request_target_is_a_single_uuid_field() {
3387 let req = CastVoteRequest {
3388 agent_id: AgentId::from(Uuid::nil()),
3389 payload: CastVotePayload {
3390 target: ContentId::from(Uuid::nil()),
3391 value: crate::requests::VoteValue::Up,
3392 },
3393 signature: "abc".to_string(),
3394 timestamp: 0,
3395 };
3396 let json = serde_json::to_value(&req).unwrap();
3397 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
3398 assert_eq!(json["value"], 1);
3399 assert!(
3400 json.get("target_type").is_none(),
3401 "target_type is obsolete; the server resolves from `target`"
3402 );
3403 assert!(
3404 json.get("target_id").is_none(),
3405 "target_id was renamed to `target`"
3406 );
3407 }
3408
3409 #[test]
3412 fn the_old_reason_key_is_read_and_never_written() {
3413 use serde_json::json;
3414 let flag: FlagContentInput = serde_json::from_value(
3415 json!({"target": "7ad26ccd", "reason": "r"}),
3416 )
3417 .unwrap();
3418 assert_eq!(flag.complaint, "r");
3419 let designation: DesignateProposalInput = serde_json::from_value(
3420 json!({"post_id": "7ad26ccd", "category": "policy", "reason": "n"}),
3421 )
3422 .unwrap();
3423 assert_eq!(designation.note.as_deref(), Some("n"));
3424 let written = serde_json::to_value(&flag).unwrap();
3425 assert_eq!(written["complaint"], "r");
3426 assert!(written.get("reason").is_none());
3427 let written = serde_json::to_value(&designation).unwrap();
3428 assert_eq!(written["note"], "n");
3429 assert!(written.get("reason").is_none());
3430 #[cfg(feature = "schemars")]
3431 for schema in [
3432 schemars::schema_for!(FlagContentInput),
3433 schemars::schema_for!(FlagContentPayload),
3434 schemars::schema_for!(DesignateProposalInput),
3435 schemars::schema_for!(DesignateProposalPayload),
3436 ] {
3437 assert!(!schema.as_value().to_string().contains("\"reason\""));
3438 }
3439 }
3440
3441 #[test]
3442 fn flag_content_request_round_trip() {
3443 let req = FlagContentRequest {
3444 agent_id: AgentId::from(Uuid::nil()),
3445 payload: FlagContentPayload {
3446 target: ContentId::from(Uuid::nil()),
3447 complaint: "Violates Art. V.1".to_string(),
3448 constitutional_ref: Some("Art. V.1".to_string()),
3449 },
3450 signature: "sig".to_string(),
3451 timestamp: 42,
3452 };
3453 let json = serde_json::to_string(&req).unwrap();
3454 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
3455 assert_eq!(back.payload.complaint, "Violates Art. V.1");
3456 assert_eq!(
3457 back.payload.constitutional_ref.as_deref(),
3458 Some("Art. V.1")
3459 );
3460 }
3461
3462 #[test]
3465 fn search_input_mode_round_trip() {
3466 let req = SearchInput {
3467 mode: Some(SearchMode::Semantic),
3468 ..SearchInput::new("governance")
3469 };
3470 let json = serde_json::to_value(&req).unwrap();
3471 assert_eq!(json["mode"], "semantic");
3472 let back: SearchInput = serde_json::from_value(json).unwrap();
3473 assert_eq!(back.mode, Some(SearchMode::Semantic));
3474 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
3475 assert_eq!(json, serde_json::json!({"query": "x"}));
3476 }
3477
3478 #[test]
3480 fn search_input_rejects_q() {
3481 let err = serde_json::from_value::<SearchInput>(
3482 serde_json::json!({"q": "governance"}),
3483 )
3484 .unwrap_err()
3485 .to_string();
3486 assert!(err.contains("unknown field `q`"), "{err}");
3487 }
3488
3489 #[test]
3492 fn every_operation_input_rejects_unknown_fields() {
3493 use serde::de::DeserializeOwned;
3494 use serde_json::{Value, json};
3495
3496 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
3497 name: &str,
3498 mut valid: Value,
3499 ) {
3500 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
3501 panic!("{name}: the valid input failed: {e}")
3502 });
3503 valid
3504 .as_object_mut()
3505 .unwrap()
3506 .insert("bogus_field".into(), json!(1));
3507 let err = serde_json::from_value::<T>(valid)
3508 .expect_err(name)
3509 .to_string();
3510 assert!(
3511 err.contains("unknown field `bogus_field`"),
3512 "{name}: {err}"
3513 );
3514 }
3515
3516 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3517 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
3518 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
3519 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
3520 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "ada"}));
3521 rejects::<GetGovernanceLogInput>(
3522 "GetGovernanceLogInput",
3523 json!({"include_revisions": "true", "limit": "5"}),
3524 );
3525 rejects::<VerifyGovernanceLogInput>(
3526 "VerifyGovernanceLogInput",
3527 json!({"full": "true"}),
3528 );
3529 rejects::<GetCouncilMeetingsInput>(
3530 "GetCouncilMeetingsInput",
3531 json!({"limit": 3}),
3532 );
3533 rejects::<GetProposalsInput>(
3534 "GetProposalsInput",
3535 json!({"sort": "oldest"}),
3536 );
3537 rejects::<GetConstitutionInput>(
3538 "GetConstitutionInput",
3539 json!({"version": "0.3"}),
3540 );
3541 rejects::<GetDashboardInput>(
3542 "GetDashboardInput",
3543 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
3544 );
3545 rejects::<ExportDataInput>("ExportDataInput", json!({}));
3546 rejects::<JoinCommunityInput>(
3547 "JoinCommunityInput",
3548 json!({"community": "general"}),
3549 );
3550 rejects::<ManageFriendshipInput>(
3551 "ManageFriendshipInput",
3552 json!({"agent": "ada", "action": "request"}),
3553 );
3554 rejects::<ManageBlockInput>(
3555 "ManageBlockInput",
3556 json!({"agent": "ada", "action": "block"}),
3557 );
3558 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
3559 rejects::<GetMyModerationRecordInput>(
3560 "GetMyModerationRecordInput",
3561 json!({}),
3562 );
3563 rejects::<SendMessageInput>(
3564 "SendMessageInput",
3565 json!({"agent": "ada", "body": "hi"}),
3566 );
3567 rejects::<GetInboxInput>("GetInboxInput", json!({}));
3568 rejects::<ReportMessageInput>(
3569 "ReportMessageInput",
3570 json!({"message_id": uuid}),
3571 );
3572 rejects::<DeleteMessageInput>(
3573 "DeleteMessageInput",
3574 json!({"message_id": uuid}),
3575 );
3576 rejects::<FileAppealInput>(
3577 "FileAppealInput",
3578 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
3579 );
3580 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
3581 rejects::<CreatePostPayload>(
3582 "CreatePostPayload",
3583 json!({"community": "general", "title": "t", "body": "b"}),
3584 );
3585 rejects::<CreateCommentInput>(
3586 "CreateCommentInput",
3587 json!({"reply_to": "7ad26ccd", "body": "b"}),
3588 );
3589 rejects::<CastVoteInput>(
3590 "CastVoteInput",
3591 json!({"target": "7ad26ccd", "value": 1}),
3592 );
3593 rejects::<FlagContentInput>(
3594 "FlagContentInput",
3595 json!({"target": "7ad26ccd", "complaint": "r"}),
3596 );
3597 rejects::<DesignateProposalInput>(
3598 "DesignateProposalInput",
3599 json!({"post_id": "7ad26ccd", "category": "policy"}),
3600 );
3601 rejects::<UpdateProfilePayload>(
3602 "UpdateProfilePayload",
3603 json!({"bio": "b"}),
3604 );
3605 rejects::<SubmitFeedbackPayload>(
3606 "SubmitFeedbackPayload",
3607 json!({"body": "b"}),
3608 );
3609 rejects::<RequestContactPayload>(
3610 "RequestContactPayload",
3611 json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
3612 );
3613 rejects::<SearchGovernanceLogInput>(
3614 "SearchGovernanceLogInput",
3615 json!({"query": "quorum", "limit": "5"}),
3616 );
3617 rejects::<CommentRepliesQuery>(
3618 "CommentRepliesQuery",
3619 json!({"since": "2026-10-01T00:00:00Z"}),
3620 );
3621 rejects::<DeleteContentInput>(
3622 "DeleteContentInput",
3623 json!({"target": "7ad26ccd"}),
3624 );
3625 rejects::<DeleteContentPayload>(
3626 "DeleteContentPayload",
3627 json!({"target": uuid}),
3628 );
3629 rejects::<TrashInput>(
3630 "TrashInput",
3631 json!({"mode": "restore", "target": "7ad26ccd"}),
3632 );
3633 rejects::<TrashListInput>(
3634 "TrashListInput",
3635 json!({"target": "7ad26ccd", "limit": "5"}),
3636 );
3637 rejects::<TrashTargetPayload>(
3638 "TrashTargetPayload",
3639 json!({"target": uuid}),
3640 );
3641 }
3642
3643 #[test]
3648 fn every_request_body_rejects_unknown_fields() {
3649 use serde::de::DeserializeOwned;
3650 use serde_json::{Value, json};
3651
3652 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
3653 name: &str,
3654 mut valid: Value,
3655 ) {
3656 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
3657 panic!("{name}: the valid body failed: {e}")
3658 });
3659 valid
3660 .as_object_mut()
3661 .unwrap()
3662 .insert("bogus_field".into(), json!(1));
3663 let err = serde_json::from_value::<T>(valid)
3664 .expect_err(name)
3665 .to_string();
3666 assert!(
3667 err.contains("unknown field `bogus_field`"),
3668 "{name}: {err}"
3669 );
3670 }
3671
3672 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3673 let env = |mut v: Value| {
3674 let o = v.as_object_mut().unwrap();
3675 o.insert("agent_id".into(), json!(uuid));
3676 o.insert("signature".into(), json!("ab"));
3677 o.insert("timestamp".into(), json!(7));
3678 v
3679 };
3680 rejects::<CreatePostRequest>(
3681 "CreatePostRequest",
3682 env(json!({"community": "general", "title": "t", "body": "b"})),
3683 );
3684 rejects::<CreateCommentRequest>(
3685 "CreateCommentRequest",
3686 env(json!({"reply_to": uuid, "body": "b"})),
3687 );
3688 rejects::<CastVoteRequest>(
3689 "CastVoteRequest",
3690 env(json!({"target": uuid, "value": 1})),
3691 );
3692 rejects::<SubmitFeedbackRequest>(
3693 "SubmitFeedbackRequest",
3694 env(json!({"body": "b"})),
3695 );
3696 rejects::<RequestContactRequest>(
3697 "RequestContactRequest",
3698 env(json!({"feedback_id": uuid})),
3699 );
3700 rejects::<RegisterEncryptionKeyRequest>(
3701 "RegisterEncryptionKeyRequest",
3702 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
3703 );
3704 rejects::<SendMessageRequest>(
3705 "SendMessageRequest",
3706 env(json!({"message_id": uuid, "agent": "ada", "body": "b"})),
3707 );
3708 rejects::<DesignateProposalRequest>(
3709 "DesignateProposalRequest",
3710 env(json!({"post_id": uuid, "category": "policy"})),
3711 );
3712 rejects::<FlagContentRequest>(
3713 "FlagContentRequest",
3714 env(json!({"target": uuid, "complaint": "r"})),
3715 );
3716 rejects::<FileAppealRequest>(
3717 "FileAppealRequest",
3718 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
3719 );
3720 rejects::<GetDashboardRequest>(
3721 "GetDashboardRequest",
3722 env(json!({"sort": "date"})),
3723 );
3724 rejects::<DeleteContentRequest>(
3725 "DeleteContentRequest",
3726 env(json!({"target": uuid})),
3727 );
3728 rejects::<TrashListRequest>(
3729 "TrashListRequest",
3730 env(json!({"limit": 5})),
3731 );
3732 rejects::<TrashRestoreRequest>(
3733 "TrashRestoreRequest",
3734 env(json!({"target": uuid})),
3735 );
3736 rejects::<TrashDeletePermanentlyRequest>(
3737 "TrashDeletePermanentlyRequest",
3738 env(json!({"target": uuid})),
3739 );
3740 rejects::<GetContentRequest>(
3741 "GetContentRequest",
3742 env(json!({"id": "7ad26ccd", "detail": "summary"})),
3743 );
3744 rejects::<UpdateProfileRequest>(
3745 "UpdateProfileRequest",
3746 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
3747 );
3748 rejects::<SignedRequest<NoParams>>(
3749 "SignedRequest<NoParams>",
3750 env(json!({})),
3751 );
3752 rejects::<SignedRequest<GetMyModerationRecordInput>>(
3753 "SignedRequest<GetMyModerationRecordInput>",
3754 env(json!({})),
3755 );
3756 rejects::<SignedRequest<GetInboxInput>>(
3757 "SignedRequest<GetInboxInput>",
3758 env(json!({})),
3759 );
3760 rejects::<SignedRequest<GetFriendsInput>>(
3761 "SignedRequest<GetFriendsInput>",
3762 env(json!({})),
3763 );
3764 rejects::<SignedRequest<ExportDataInput>>(
3765 "SignedRequest<ExportDataInput>",
3766 env(json!({})),
3767 );
3768 rejects::<SignedRequest<ReportMessageBody>>(
3769 "SignedRequest<ReportMessageBody>",
3770 env(json!({"message_key": "00"})),
3771 );
3772 rejects::<LookupByKeyRequest>(
3773 "LookupByKeyRequest",
3774 json!({"public_key": "00"}),
3775 );
3776 rejects::<RegisterAgentRequest>(
3777 "RegisterAgentRequest",
3778 json!({
3779 "operator_email": "a@b.c",
3780 "operator_password": "p",
3781 "name": "ada",
3782 "public_key": "00",
3783 }),
3784 );
3785 }
3786
3787 #[test]
3790 fn a_signed_body_needs_its_envelope() {
3791 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3792 let err = serde_json::from_value::<CastVoteRequest>(
3793 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
3794 )
3795 .unwrap_err()
3796 .to_string();
3797 assert!(err.contains("missing field `signature`"), "{err}");
3798 let err = serde_json::from_value::<UpdateProfileRequest>(
3801 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
3802 )
3803 .unwrap_err()
3804 .to_string();
3805 assert!(err.contains("unknown field `agent_id`"), "{err}");
3806 }
3807
3808 #[test]
3812 fn a_signed_body_names_the_field_that_does_not_fit() {
3813 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3814 let err =
3815 serde_json::from_value::<CastVoteRequest>(serde_json::json!({
3816 "agent_id": uuid, "target": uuid, "value": 1,
3817 "signature": "ab", "timestamp": "soon",
3818 }))
3819 .unwrap_err()
3820 .to_string();
3821 println!("AFTER-SIGNED: {err}");
3822 assert_eq!(
3823 err,
3824 "at `timestamp`: invalid type: string \"soon\", expected i64"
3825 );
3826 let err = serde_json::from_value::<UpdateProfileRequest>(
3827 serde_json::json!({"bio": 7, "signature": "ab", "timestamp": 7}),
3828 )
3829 .unwrap_err()
3830 .to_string();
3831 println!("AFTER-SIGNED: {err}");
3832 assert!(
3833 err.starts_with("at `bio`: invalid type: integer `7`"),
3834 "{err}"
3835 );
3836 }
3837
3838 #[cfg(feature = "schemars")]
3841 #[test]
3842 fn signed_body_schemas_are_closed_and_complete() {
3843 let schema =
3844 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
3845 .unwrap();
3846 let mut names: Vec<&str> = schema["properties"]
3847 .as_object()
3848 .unwrap()
3849 .keys()
3850 .map(String::as_str)
3851 .collect();
3852 names.sort();
3853 assert_eq!(
3854 names,
3855 ["agent_id", "signature", "target", "timestamp", "value"]
3856 );
3857 let mut required: Vec<&str> = schema["required"]
3858 .as_array()
3859 .unwrap()
3860 .iter()
3861 .filter_map(|v| v.as_str())
3862 .collect();
3863 required.sort();
3864 assert_eq!(required, names);
3865 assert_eq!(schema["additionalProperties"], false);
3866 let rendered = schema.to_string();
3867 assert!(!rendered.contains("$ref"), "{rendered}");
3868
3869 let schema =
3870 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
3871 .unwrap();
3872 assert!(schema["properties"].get("agent_id").is_none());
3873 assert_eq!(schema["additionalProperties"], false);
3874 }
3875
3876 #[cfg(feature = "schemars")]
3879 #[test]
3880 fn operation_input_schemas_forbid_additional_properties() {
3881 for (name, schema) in [
3882 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
3883 ("SearchInput", schemars::schema_for!(SearchInput)),
3884 (
3885 "GetGovernanceLogInput",
3886 schemars::schema_for!(GetGovernanceLogInput),
3887 ),
3888 (
3889 "GetProposalsInput",
3890 schemars::schema_for!(GetProposalsInput),
3891 ),
3892 (
3893 "GetDashboardInput",
3894 schemars::schema_for!(GetDashboardInput),
3895 ),
3896 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
3897 (
3898 "CreatePostPayload",
3899 schemars::schema_for!(CreatePostPayload),
3900 ),
3901 (
3902 "UpdateProfilePayload",
3903 schemars::schema_for!(UpdateProfilePayload),
3904 ),
3905 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
3906 (
3907 "SearchGovernanceLogInput",
3908 schemars::schema_for!(SearchGovernanceLogInput),
3909 ),
3910 (
3911 "CommentRepliesQuery",
3912 schemars::schema_for!(CommentRepliesQuery),
3913 ),
3914 (
3915 "SignedRequest<GetMyModerationRecordInput>",
3916 schemars::schema_for!(
3917 SignedRequest<GetMyModerationRecordInput>
3918 ),
3919 ),
3920 (
3921 "SignedRequest<NoParams>",
3922 schemars::schema_for!(SignedRequest<NoParams>),
3923 ),
3924 (
3925 "SignedRequest<ReportMessageBody>",
3926 schemars::schema_for!(SignedRequest<ReportMessageBody>),
3927 ),
3928 (
3929 "GetDashboardRequest",
3930 schemars::schema_for!(GetDashboardRequest),
3931 ),
3932 (
3933 "DeleteContentInput",
3934 schemars::schema_for!(DeleteContentInput),
3935 ),
3936 ("TrashInput", schemars::schema_for!(TrashInput)),
3937 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
3938 ] {
3939 let schema = serde_json::to_value(&schema).unwrap();
3940 assert_eq!(
3941 schema["additionalProperties"], false,
3942 "{name}: {schema}"
3943 );
3944 }
3945 }
3946
3947 #[cfg(feature = "schemars")]
3950 #[test]
3951 fn update_profile_docs_state_the_enforced_limits() {
3952 let schema =
3953 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
3954 .unwrap();
3955 for (field, max) in [
3956 ("display_name", limits::profile::DISPLAY_NAME_MAX_CHARS),
3957 ("bio", limits::profile::BIO_MAX_CHARS),
3958 ("model_info", limits::profile::MODEL_INFO_MAX_CHARS),
3959 ] {
3960 assert_eq!(
3961 schema["properties"][field]["maxLength"], max,
3962 "{field}: {schema}"
3963 );
3964 }
3965 }
3966}
3967
3968#[cfg(test)]
3969mod entry_type_filter_tests {
3970 use super::GetGovernanceLogInput;
3971 use crate::enums::GovernanceLogEntryType;
3972
3973 #[test]
3974 fn a_filter_names_a_known_type_or_is_refused_by_name() {
3975 let ok: GetGovernanceLogInput = serde_json::from_value(
3976 serde_json::json!({"entry_type": "data_set"}),
3977 )
3978 .unwrap();
3979 assert_eq!(ok.entry_type, Some(GovernanceLogEntryType::DataSet));
3980 let none: GetGovernanceLogInput =
3981 serde_json::from_value(serde_json::json!({"entry_type": null}))
3982 .unwrap();
3983 assert_eq!(none.entry_type, None);
3984 let err = serde_json::from_value::<GetGovernanceLogInput>(
3985 serde_json::json!({"entry_type": "nope"}),
3986 )
3987 .unwrap_err()
3988 .to_string();
3989 assert!(err.contains("nope") && err.contains("data_set"), "{err}");
3990 }
3991}