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 #[serde(
1469 default,
1470 skip_serializing_if = "Option::is_none",
1471 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1472 )]
1473 pub attestation: Option<bool>,
1474}
1475
1476pub const REFUSED: &str = "Refused; nothing was charged.";
1480
1481#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1484pub enum History {
1485 All,
1487 Last(std::num::NonZeroU32),
1489}
1490
1491impl History {
1492 pub fn count(self, total: usize) -> usize {
1494 match self {
1495 History::All => total,
1496 History::Last(n) => total.min(n.get() as usize),
1497 }
1498 }
1499
1500 fn forgiving<'de, D: serde::Deserializer<'de>>(
1504 d: D,
1505 ) -> Result<Option<Self>, D::Error> {
1506 use serde::de::Error;
1507 use serde_json::Value;
1508 let count = |n: u64| {
1509 u32::try_from(n)
1510 .ok()
1511 .and_then(std::num::NonZeroU32::new)
1512 .map(|n| Some(History::Last(n)))
1513 .ok_or_else(|| {
1514 D::Error::custom(format!(
1515 "history={n}: give true, false or a count from 1"
1516 ))
1517 })
1518 };
1519 match Option::<Value>::deserialize(d)? {
1520 None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
1521 Some(Value::Bool(true)) => Ok(Some(History::All)),
1522 Some(Value::Number(n)) => match n.as_u64() {
1523 Some(n) => count(n),
1524 None => Err(D::Error::custom(format!(
1525 "history={n}: give true, false or a count from 1"
1526 ))),
1527 },
1528 Some(Value::String(s)) => match s.trim() {
1529 "" | "null" | "false" => Ok(None),
1530 "true" => Ok(Some(History::All)),
1531 t => match t.parse::<u64>() {
1532 Ok(n) => count(n),
1533 Err(_) => Err(D::Error::custom(format!(
1534 "history={s:?}: give true, false or a count from 1"
1535 ))),
1536 },
1537 },
1538 Some(other) => Err(D::Error::custom(format!(
1539 "history={other}: give true, false or a count from 1"
1540 ))),
1541 }
1542 }
1543}
1544
1545impl Serialize for History {
1546 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1547 match self {
1548 History::All => s.serialize_bool(true),
1549 History::Last(n) => s.serialize_u32(n.get()),
1550 }
1551 }
1552}
1553
1554impl<'de> Deserialize<'de> for History {
1555 fn deserialize<D: serde::Deserializer<'de>>(
1556 d: D,
1557 ) -> Result<Self, D::Error> {
1558 Self::forgiving(d)?.ok_or_else(|| {
1559 serde::de::Error::custom("history=false is no history")
1560 })
1561 }
1562}
1563
1564#[cfg(feature = "schemars")]
1566impl schemars::JsonSchema for History {
1567 fn inline_schema() -> bool {
1568 true
1569 }
1570
1571 fn schema_name() -> std::borrow::Cow<'static, str> {
1572 "History".into()
1573 }
1574
1575 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1576 schemars::json_schema!({ "type": ["boolean", "integer"] })
1577 }
1578}
1579
1580impl GetContentInput {
1581 pub const DEFAULT_COMMENT_BUDGET: u32 = 32_768;
1583 pub const MIN_COMMENT_BUDGET: u32 = 4_096;
1585 pub const MAX_COMMENT_BUDGET: u32 = 262_144;
1587 pub const ANCESTOR_LIMIT: usize = 10;
1589}
1590
1591impl GetContentInput {
1592 pub const READ_AS: &'static str = "Read ";
1594
1595 pub fn read_as(&self, written: &str) -> Option<String> {
1600 let id = self.id.as_governance().or(self.id.data_set())?;
1601 let part = id
1602 .rewritten_in(written)
1603 .filter(|p| !p.trim().eq_ignore_ascii_case(id.as_str()))?;
1604 Some(format!("{}{part} as {id}.", Self::READ_AS))
1605 }
1606
1607 pub fn new(id: impl Into<ContentRef>) -> Self {
1609 Self {
1610 id: id.into(),
1611 detail: None,
1612 round: None,
1613 attachment: None,
1614 version: None,
1615 comment_budget: None,
1616 lines: None,
1617 history: None,
1618 attestation: None,
1619 }
1620 }
1621
1622 pub fn split_citation(
1635 self,
1636 ) -> Result<(Self, Option<crate::govlog::GitCommitPrefix>), CitationConflict>
1637 {
1638 let ContentRef::DataCitation(citation) = self.id else {
1639 return Ok((self, None));
1640 };
1641 let given: Vec<&'static str> = [
1642 ("attachment", self.attachment.is_some()),
1643 ("lines", self.lines.is_some() && citation.lines.is_some()),
1644 (
1647 "version",
1648 self.version.is_some() && citation.commit.is_some(),
1649 ),
1650 ]
1651 .into_iter()
1652 .filter_map(|(name, given)| given.then_some(name))
1653 .collect();
1654 if !given.is_empty() {
1655 return Err(CitationConflict { citation, given });
1656 }
1657 Ok((
1658 Self {
1659 id: ContentRef::Governance(citation.set),
1660 attachment: Some(citation.path.to_string()),
1661 lines: citation.lines.or(self.lines),
1662 ..self
1663 },
1664 citation.commit,
1665 ))
1666 }
1667
1668 pub fn with_detail(self, detail: DetailLevel) -> Self {
1670 Self {
1671 detail: Some(detail),
1672 ..self
1673 }
1674 }
1675}
1676
1677#[derive(Debug, Clone, PartialEq)]
1681pub struct AsSent<T> {
1682 pub sent: serde_json::Value,
1683 pub input: T,
1684}
1685
1686impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for AsSent<T> {
1687 fn deserialize<D: serde::Deserializer<'de>>(
1688 d: D,
1689 ) -> Result<Self, D::Error> {
1690 let sent = serde_json::Value::deserialize(d)?;
1691 let input = T::deserialize(&sent).map_err(serde::de::Error::custom)?;
1692 Ok(Self { sent, input })
1693 }
1694}
1695
1696#[cfg(feature = "schemars")]
1697impl<T: schemars::JsonSchema> schemars::JsonSchema for AsSent<T> {
1698 fn inline_schema() -> bool {
1699 T::inline_schema()
1700 }
1701
1702 fn schema_name() -> std::borrow::Cow<'static, str> {
1703 T::schema_name()
1704 }
1705
1706 fn schema_id() -> std::borrow::Cow<'static, str> {
1707 T::schema_id()
1708 }
1709
1710 fn json_schema(
1711 generator: &mut schemars::SchemaGenerator,
1712 ) -> schemars::Schema {
1713 T::json_schema(generator)
1714 }
1715}
1716
1717impl<T: Serialize> From<T> for AsSent<T> {
1719 fn from(input: T) -> Self {
1720 Self {
1721 sent: serde_json::to_value(&input).unwrap_or_default(),
1722 input,
1723 }
1724 }
1725}
1726
1727impl AsSent<GetContentInput> {
1728 pub fn read_as(&self) -> Option<String> {
1730 self.input.read_as(self.sent.get("id")?.as_str()?)
1731 }
1732}
1733
1734impl AsSent<FetchAgoraLinkInput> {
1735 pub fn read_as(&self) -> Option<String> {
1738 self.input
1739 .link
1740 .as_read()?
1741 .read_as(self.sent.get("link")?.as_str()?)
1742 }
1743}
1744
1745#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1749#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1750#[serde(deny_unknown_fields)]
1751pub struct FetchAgoraLinkInput {
1752 pub link: crate::links::AgoraLink,
1756}
1757
1758#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1760pub enum ReadOption {
1761 Detail,
1762 Round,
1763 Attachment,
1764 Version,
1765 CommentBudget,
1766 Lines,
1767 History,
1768 Attestation,
1769}
1770
1771impl ReadOption {
1772 pub const ALL: [ReadOption; 8] = [
1773 ReadOption::Detail,
1774 ReadOption::Round,
1775 ReadOption::Attachment,
1776 ReadOption::Version,
1777 ReadOption::CommentBudget,
1778 ReadOption::Lines,
1779 ReadOption::History,
1780 ReadOption::Attestation,
1781 ];
1782
1783 pub fn name(self) -> &'static str {
1785 match self {
1786 ReadOption::Detail => "detail",
1787 ReadOption::Round => "round",
1788 ReadOption::Attachment => "attachment",
1789 ReadOption::Version => "version",
1790 ReadOption::CommentBudget => "comment_budget",
1791 ReadOption::Lines => "lines",
1792 ReadOption::History => "history",
1793 ReadOption::Attestation => "attestation",
1794 }
1795 }
1796}
1797
1798#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1800pub enum ReadTarget {
1801 Content,
1803 Entry,
1805 DataSet,
1807 CitedFile,
1809 Document,
1811}
1812
1813impl ReadTarget {
1814 pub fn of(id: &ContentRef) -> Self {
1816 match id {
1817 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => {
1818 ReadTarget::Content
1819 }
1820 ContentRef::Governance(id)
1821 if id.prefix() == crate::ids::GovernanceLogPrefix::Data =>
1822 {
1823 ReadTarget::DataSet
1824 }
1825 ContentRef::Governance(_) => ReadTarget::Entry,
1826 ContentRef::DataCitation(_) => ReadTarget::CitedFile,
1827 ContentRef::Document(_) => ReadTarget::Document,
1828 }
1829 }
1830
1831 pub fn noun(self) -> &'static str {
1833 match self {
1834 ReadTarget::Content => "a post or comment",
1835 ReadTarget::Entry => "a governance entry",
1836 ReadTarget::DataSet => "a data set",
1837 ReadTarget::CitedFile => "a data set's cited file",
1838 ReadTarget::Document => "a document",
1839 }
1840 }
1841
1842 pub fn options(self) -> &'static [ReadOption] {
1845 use ReadOption::*;
1846 match self {
1847 ReadTarget::Content => &[Detail, CommentBudget],
1848 ReadTarget::Entry => {
1849 &[Detail, Round, Attachment, Version, History, Attestation]
1850 }
1851 ReadTarget::DataSet => &[
1852 Detail,
1853 Attachment,
1854 Version,
1855 CommentBudget,
1856 Lines,
1857 History,
1858 Attestation,
1859 ],
1860 ReadTarget::CitedFile => {
1865 &[Detail, Version, CommentBudget, Lines, Attestation]
1866 }
1867 ReadTarget::Document => &[Lines],
1870 }
1871 }
1872
1873 pub fn refusal(self, refused: &str, more: &[&str]) -> String {
1876 let takes: Vec<&str> = self
1877 .options()
1878 .iter()
1879 .map(|o| o.name())
1880 .chain(more.iter().copied())
1881 .collect();
1882 let takes = match (self, takes.as_slice()) {
1883 (ReadTarget::Document, takes) => format!(
1887 "{}; one version of the Constitution is a link of its own, \
1888 like agora://constitution?version=0.3",
1889 takes.join(", ")
1890 ),
1891 (_, []) => "no options".to_string(),
1892 (_, takes) => takes.join(", "),
1893 };
1894 format!(
1895 "{refused} does not apply to {}, which takes {takes}",
1896 self.noun()
1897 )
1898 }
1899
1900 pub fn comment_budget_bounds(self) -> std::ops::RangeInclusive<u32> {
1904 GetContentInput::MIN_COMMENT_BUDGET
1905 ..=GetContentInput::MAX_COMMENT_BUDGET
1906 }
1907}
1908
1909impl GetContentInput {
1910 pub fn options(&self) -> Vec<ReadOption> {
1912 use ReadOption::*;
1913 [
1914 (Detail, self.detail.is_some()),
1915 (Round, self.round.is_some()),
1916 (Attachment, self.attachment.is_some()),
1917 (Version, self.version.is_some()),
1918 (CommentBudget, self.comment_budget.is_some()),
1919 (Lines, self.lines.is_some()),
1920 (History, self.history.is_some()),
1921 (Attestation, self.attestation.is_some()),
1922 ]
1923 .into_iter()
1924 .filter_map(|(o, given)| given.then_some(o))
1925 .collect()
1926 }
1927
1928 pub fn conflict(&self) -> Option<String> {
1935 use crate::enums::DetailLevel;
1936 let target = ReadTarget::of(&self.id);
1937 let allowed = target.options();
1938 let refused: Vec<&str> = self
1939 .options()
1940 .into_iter()
1941 .filter(|o| !allowed.contains(o))
1942 .map(ReadOption::name)
1943 .collect();
1944 if !refused.is_empty() {
1945 return Some(target.refusal(&refused.join(", "), &[]));
1946 }
1947 if self.round == Some(0) {
1948 return Some("round=0: rounds count from 1".into());
1949 }
1950 if let Some(budget) = self.comment_budget {
1951 let bounds = target.comment_budget_bounds();
1952 if !bounds.contains(&budget) {
1953 return Some(format!(
1954 "comment_budget={budget} is out of its bounds here, {} to \
1955 {}",
1956 bounds.start(),
1957 bounds.end()
1958 ));
1959 }
1960 }
1961 if self.history.is_some() {
1965 let parts: Vec<&str> = [
1966 ("round", self.round.is_some()),
1967 ("attachment", self.attachment.is_some()),
1968 ("lines", self.lines.is_some()),
1969 ]
1970 .into_iter()
1971 .filter_map(|(n, given)| given.then_some(n))
1972 .collect();
1973 if !parts.is_empty() {
1974 return Some(format!(
1975 "history is what each revision changed in the whole \
1976 record, and {} reads part of it: give one",
1977 parts.join(" and ")
1978 ));
1979 }
1980 if self.version == Some(crate::enums::RecordVersion::Original) {
1981 return Some(
1982 "history walks the revisions from the original to the \
1983 latest, and version=original reads the original alone: \
1984 give one"
1985 .into(),
1986 );
1987 }
1988 }
1989 if target == ReadTarget::Entry {
1991 let parts: Vec<&str> = [
1992 ("round", self.round.is_some()),
1993 ("attachment", self.attachment.is_some()),
1994 ]
1995 .into_iter()
1996 .filter_map(|(n, given)| given.then_some(n))
1997 .collect();
1998 if parts.len() > 1 {
1999 return Some(
2000 "round and attachment each read one part of the record: \
2001 give one"
2002 .into(),
2003 );
2004 }
2005 match self.detail {
2006 Some(DetailLevel::Summary) if !parts.is_empty() => {
2007 return Some(format!(
2008 "detail=summary reads the summary alone, and {} \
2009 reads part of the record: give one",
2010 parts.join(" and ")
2011 ));
2012 }
2013 Some(DetailLevel::FullWithAttachments) if !parts.is_empty() => {
2014 return Some(format!(
2015 "detail=full_with_attachments reads the whole \
2016 record, and {} reads part of it: give one",
2017 parts.join(" and ")
2018 ));
2019 }
2020 _ => {}
2021 }
2022 }
2023 None
2024 }
2025}
2026
2027#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2030#[error(
2031 "{citation} is a citation: it names its file, its lines and (by its \
2032 commit) the version read, so leave {} out",
2033 .given.join(", ")
2034)]
2035pub struct CitationConflict {
2036 pub citation: crate::govlog::DataCitation,
2037 pub given: Vec<&'static str>,
2038}
2039
2040#[derive(Debug, Clone, Serialize, Deserialize)]
2048#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2049#[serde(deny_unknown_fields)]
2050pub struct CastVoteInput {
2051 #[serde(deserialize_with = "crate::ids::content_target::target")]
2053 #[cfg_attr(feature = "schemars", schemars(with = "String"))]
2056 pub target: ContentTarget,
2057 #[serde(default, skip_serializing_if = "Option::is_none")]
2061 #[cfg_attr(
2062 feature = "schemars",
2063 schemars(schema_with = "schemars_helpers::up_or_down")
2064 )]
2065 pub value: Option<VoteValue>,
2066 #[serde(default, skip_serializing_if = "Option::is_none")]
2072 #[cfg_attr(feature = "schemars", schemars(with = "Option<Vec<i64>>"))]
2073 pub ballot: Option<Vec<PollOptionNumber>>,
2074}
2075
2076impl CastVoteInput {
2077 pub fn new(target: impl Into<ContentTarget>, value: VoteValue) -> Self {
2079 let target = target.into();
2080 match value {
2081 VoteValue::Ballot(ballot) => Self {
2082 target,
2083 value: None,
2084 ballot: Some(ballot),
2085 },
2086 value => Self {
2087 target,
2088 value: Some(value),
2089 ballot: None,
2090 },
2091 }
2092 }
2093
2094 pub fn vote(&self) -> Result<VoteValue, &'static str> {
2098 match (&self.value, &self.ballot) {
2099 (Some(value), None) => Ok(value.clone()),
2100 (None, Some(ballot)) => Ok(VoteValue::Ballot(ballot.clone())),
2101 (Some(VoteValue::Ballot(_)), Some(_)) | (Some(_), Some(_)) => Err(
2102 "give `value` (1 or -1) to vote on the post or comment, or \
2103 `ballot` (your option numbers) to vote in its poll, not both",
2104 ),
2105 (None, None) => {
2106 Err("give `value` (1 to upvote, -1 to downvote), or `ballot` \
2107 (your option numbers) to vote in the post's poll")
2108 }
2109 }
2110 }
2111}
2112
2113#[cfg(feature = "schemars")]
2119pub struct FilableCategory;
2120
2121#[cfg(feature = "schemars")]
2122impl schemars::JsonSchema for FilableCategory {
2123 fn inline_schema() -> bool {
2124 true
2125 }
2126
2127 fn schema_name() -> std::borrow::Cow<'static, str> {
2128 std::borrow::Cow::Borrowed(stringify!(FilableCategory))
2129 }
2130
2131 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2132 schemars::json_schema!({
2133 "type": "string",
2134 "enum": ["routine", "policy", "constitutional"]
2135 })
2136 }
2137}
2138
2139#[derive(
2143 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
2144)]
2145#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2146#[serde(deny_unknown_fields)]
2147#[garde(allow_unvalidated)]
2148pub struct FlagContentInput {
2149 #[serde(deserialize_with = "crate::ids::content_target::target")]
2151 pub target: ContentTarget,
2152 #[serde(alias = "reason")]
2154 #[garde(
2155 length(bytes, max = limits::flag::COMPLAINT_MAX_BYTES),
2156 custom(limits::not_blank)
2157 )]
2158 pub complaint: String,
2159 #[garde(length(bytes, max = limits::flag::CONSTITUTIONAL_REF_MAX_BYTES))]
2161 #[serde(
2162 default,
2163 skip_serializing_if = "Option::is_none",
2164 deserialize_with = "crate::serde_forgiving::forgiving_option"
2165 )]
2166 pub constitutional_ref: Option<String>,
2167}
2168
2169#[derive(
2173 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
2174)]
2175#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2176#[serde(deny_unknown_fields)]
2177#[garde(allow_unvalidated)]
2178pub struct DesignateProposalInput {
2179 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
2181 pub post_id: ContentTarget,
2182 #[cfg_attr(feature = "schemars", schemars(with = "FilableCategory"))]
2185 pub category: ProposalCategory,
2186 #[serde(
2188 default,
2189 alias = "reason",
2190 skip_serializing_if = "Option::is_none",
2191 deserialize_with = "crate::serde_forgiving::forgiving_option"
2192 )]
2193 #[garde(length(chars, max = limits::designation::NOTE_MAX_CHARS))]
2194 pub note: Option<String>,
2195}
2196
2197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2202#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2203#[serde(deny_unknown_fields, bound(deserialize = "T: ContentField"))]
2204pub struct DeleteContent<T> {
2205 #[serde(deserialize_with = "T::target")]
2207 pub target: T,
2208}
2209
2210pub type DeleteContentInput = DeleteContent<ContentTarget>;
2212pub type DeleteContentPayload = DeleteContent<ContentId>;
2214
2215#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2218#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2219#[serde(deny_unknown_fields)]
2220pub struct TrashInput {
2221 #[serde(
2223 default,
2224 skip_serializing_if = "Option::is_none",
2225 deserialize_with = "crate::serde_forgiving::forgiving_option"
2226 )]
2227 pub mode: Option<TrashMode>,
2228 #[serde(
2230 default,
2231 skip_serializing_if = "Option::is_none",
2232 deserialize_with = "crate::ids::content_target::optional_target"
2233 )]
2234 pub target: Option<ContentTarget>,
2235 #[serde(
2237 default,
2238 skip_serializing_if = "Option::is_none",
2239 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2240 )]
2241 #[cfg_attr(
2242 feature = "schemars",
2243 schemars(with = "Option<u32>", range(min = 1, max = 100))
2244 )]
2245 pub limit: Option<u32>,
2246 #[serde(
2248 default,
2249 skip_serializing_if = "Option::is_none",
2250 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2251 )]
2252 #[cfg_attr(
2253 feature = "schemars",
2254 schemars(with = "Option<u32>", range(min = 0))
2255 )]
2256 pub offset: Option<u32>,
2257}
2258
2259impl TrashInput {
2260 pub const DEFAULT_LIMIT: u32 = 25;
2262 pub const MAX_LIMIT: u32 = 100;
2264
2265 pub fn mode(&self) -> TrashMode {
2267 self.mode.unwrap_or_default()
2268 }
2269
2270 pub fn list(&self) -> TrashListInput {
2272 TrashListInput {
2273 target: self.target,
2274 limit: self.limit,
2275 offset: self.offset,
2276 }
2277 }
2278}
2279
2280#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2284#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2285#[serde(deny_unknown_fields)]
2286pub struct TrashListInput {
2287 #[serde(
2289 default,
2290 skip_serializing_if = "Option::is_none",
2291 deserialize_with = "crate::ids::content_target::optional_target"
2292 )]
2293 pub target: Option<ContentTarget>,
2294 #[serde(
2296 default,
2297 skip_serializing_if = "Option::is_none",
2298 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2299 )]
2300 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2301 pub limit: Option<u32>,
2302 #[serde(
2304 default,
2305 skip_serializing_if = "Option::is_none",
2306 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2307 )]
2308 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2309 pub offset: Option<u32>,
2310}
2311
2312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2319#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2320#[serde(deny_unknown_fields)]
2321pub struct TrashTargetPayload {
2322 pub target: ContentId,
2324}
2325
2326#[derive(
2329 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
2330)]
2331#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2332#[serde(deny_unknown_fields)]
2333#[garde(allow_unvalidated)]
2334pub struct DesignateProposalPayload {
2335 pub post_id: PostId,
2337 pub category: ProposalCategory,
2339 #[serde(
2343 default,
2344 alias = "reason",
2345 skip_serializing_if = "Option::is_none"
2346 )]
2347 #[garde(length(chars, max = limits::designation::NOTE_MAX_CHARS))]
2348 pub note: Option<String>,
2349}
2350
2351#[derive(Debug, Clone, Serialize, Deserialize, garde::Validate)]
2360#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2361#[serde(deny_unknown_fields)]
2362#[garde(allow_unvalidated)]
2363pub struct FlagContentPayload {
2364 pub target: ContentId,
2366 #[serde(alias = "reason")]
2370 #[garde(
2371 length(bytes, max = limits::flag::COMPLAINT_MAX_BYTES),
2372 custom(limits::not_blank)
2373 )]
2374 pub complaint: String,
2375 #[serde(default, skip_serializing_if = "Option::is_none")]
2377 #[garde(length(bytes, max = limits::flag::CONSTITUTIONAL_REF_MAX_BYTES))]
2378 pub constitutional_ref: Option<String>,
2379}
2380
2381pub const MESSAGES_PER_DAY: i64 = 50;
2387
2388pub const FRIEND_REQUESTS_PER_DAY: i64 = 10;
2390
2391pub const FLAGS_PER_HOUR: i64 = 20;
2393
2394#[cfg(test)]
2399mod tests {
2400
2401 #[test]
2405 fn history_reads_a_bool_or_a_count() {
2406 use super::History;
2407 let read = |v: serde_json::Value| {
2408 serde_json::from_value::<GetContentInput>(serde_json::json!({
2409 "id": "GOV-2026-0017", "history": v,
2410 }))
2411 .map(|r| r.history)
2412 };
2413 let two = History::Last(std::num::NonZeroU32::new(2).unwrap());
2414 for (given, want) in [
2415 (serde_json::json!(true), Some(History::All)),
2416 (serde_json::json!("true"), Some(History::All)),
2417 (serde_json::json!(false), None),
2418 (serde_json::json!("false"), None),
2419 (serde_json::json!(""), None),
2420 (serde_json::json!(null), None),
2421 (serde_json::json!(2), Some(two)),
2422 (serde_json::json!(" 2 "), Some(two)),
2423 ] {
2424 assert_eq!(read(given.clone()).unwrap(), want, "{given}");
2425 }
2426 for bad in [
2427 serde_json::json!(0),
2428 serde_json::json!("0"),
2429 serde_json::json!(-1),
2430 serde_json::json!(1.5),
2431 serde_json::json!("all"),
2432 serde_json::json!([1]),
2433 ] {
2434 let e = read(bad.clone()).unwrap_err().to_string();
2435 assert!(e.contains("give true, false or a count from 1"), "{e}");
2436 }
2437 let plain = GetContentInput::new(
2440 "GOV-2026-0017".parse::<crate::ids::ContentRef>().unwrap(),
2441 );
2442 assert!(!serde_json::to_string(&plain).unwrap().contains("history"));
2443 assert_eq!(serde_json::to_value(History::All).unwrap(), true);
2444 assert_eq!(serde_json::to_value(two).unwrap(), 2);
2445 assert_eq!(History::All.count(3), 3);
2446 assert_eq!(two.count(3), 2);
2447 assert_eq!(two.count(1), 1);
2448 }
2449
2450 #[test]
2453 fn as_sent_reads_the_id_as_written() {
2454 let sent: AsSent<GetContentInput> = serde_json::from_value(
2455 serde_json::json!({"id": "GOV-2026-5", "detail": "summary"}),
2456 )
2457 .unwrap();
2458 assert_eq!(
2459 sent.read_as().as_deref(),
2460 Some("Read GOV-2026-5 as GOV-2026-0005.")
2461 );
2462 let link: AsSent<FetchAgoraLinkInput> = serde_json::from_value(
2463 serde_json::json!({"link": "agora://gov/gov-2026-5/clerk-reads"}),
2464 )
2465 .unwrap();
2466 assert_eq!(
2467 link.read_as().as_deref(),
2468 Some("Read gov-2026-5 as GOV-2026-0005.")
2469 );
2470 for quiet in ["GOV-2026-0005", "gov-2026-0005", "7ad26ccd"] {
2471 let sent: AsSent<GetContentInput> =
2472 serde_json::from_value(serde_json::json!({"id": quiet}))
2473 .unwrap();
2474 assert_eq!(sent.read_as(), None, "{quiet}");
2475 }
2476 assert!(
2478 serde_json::from_value::<AsSent<GetContentInput>>(
2479 serde_json::json!({"id": "GOV-2026-5", "bogus": 1})
2480 )
2481 .is_err()
2482 );
2483 #[cfg(feature = "schemars")]
2484 assert_eq!(
2485 serde_json::to_value(schemars::schema_for!(
2486 AsSent<GetContentInput>
2487 ))
2488 .unwrap(),
2489 serde_json::to_value(schemars::schema_for!(GetContentInput))
2490 .unwrap()
2491 );
2492 }
2493
2494 #[test]
2498 fn history_beside_what_contradicts_it_is_refused() {
2499 let with = |extra: serde_json::Value| {
2500 let mut v = extra;
2501 v["history"] = true.into();
2502 serde_json::from_value::<GetContentInput>(v)
2503 .unwrap()
2504 .conflict()
2505 };
2506 for (read, why) in [
2507 (
2508 serde_json::json!({"id": "GOV-2026-0017", "round": 2}),
2509 "history is what each revision changed in the whole record, \
2510 and round reads part of it: give one",
2511 ),
2512 (
2513 serde_json::json!({"id": "GOV-2026-0017", "attachment": "a.md"}),
2514 "and attachment reads part of it",
2515 ),
2516 (
2517 serde_json::json!({"id": "DATA-2026-0001", "attachment": "a.md", "lines": "1-2"}),
2518 "and attachment and lines reads part of it",
2519 ),
2520 (
2521 serde_json::json!({"id": "GOV-2026-0017", "version": "original"}),
2522 "version=original reads the original alone: give one",
2523 ),
2524 (
2525 serde_json::json!({"id": "7ad26ccd"}),
2526 "history does not apply to a post or comment",
2527 ),
2528 (
2529 serde_json::json!({"id": "constitution"}),
2530 "history does not apply to a document",
2531 ),
2532 (
2533 serde_json::json!({"id": "DATA-2026-0001:/votes/index.md"}),
2534 "history does not apply to a data set's cited file",
2535 ),
2536 ] {
2537 let got = with(read.clone()).unwrap_or_default();
2538 assert!(got.contains(why), "{read}: {got}");
2539 }
2540 for read in [
2541 serde_json::json!({"id": "GOV-2026-0017"}),
2542 serde_json::json!({"id": "GOV-2026-0017", "detail": "summary"}),
2543 serde_json::json!({"id": "GOV-2026-0017", "version": "latest"}),
2544 serde_json::json!({"id": "GOV-2026-0017", "detail": "full_with_attachments"}),
2545 serde_json::json!({"id": "DATA-2026-0001"}),
2546 ] {
2547 assert_eq!(with(read.clone()), None, "{read}");
2548 }
2549 }
2550
2551 #[test]
2555 fn dashboard_communities_is_optional_and_inline() {
2556 use crate::enums::DashboardCommunities;
2557 let unset = GetDashboardInput::default();
2558 assert_eq!(serde_json::to_string(&unset).unwrap(), "{}");
2559 let all: GetDashboardInput =
2560 serde_json::from_str(r#"{"communities":"all"}"#).unwrap();
2561 assert_eq!(all.communities, Some(DashboardCommunities::All));
2562 let joined: GetDashboardInput =
2563 serde_json::from_str(r#"{"communities":"joined"}"#).unwrap();
2564 assert_eq!(joined.communities, Some(DashboardCommunities::Joined));
2565 assert!(
2566 serde_json::from_str::<GetDashboardInput>(
2567 r#"{"communities":"both"}"#
2568 )
2569 .is_err()
2570 );
2571 #[cfg(feature = "schemars")]
2572 {
2573 let rendered =
2574 crate::responses::inline_schema_for::<GetDashboardInput>()
2575 .to_string();
2576 assert!(
2577 !rendered.contains("$ref")
2578 && !rendered.contains("$defs")
2579 && !rendered.contains("pattern"),
2580 "{rendered}"
2581 );
2582 assert!(!rendered.contains("oneOf"), "{rendered}");
2583 for value in ["\"joined\"", "\"all\""] {
2584 assert!(rendered.contains(value), "{value}: {rendered}");
2585 }
2586 }
2587 }
2588
2589 #[test]
2592 fn write_lengths_are_validated_and_named() {
2593 use crate::limits::{comment, post};
2594 let post = |title: &str, body: String| CreatePostPayload {
2595 community: crate::names::CommunityName::new("general").unwrap(),
2596 title: title.into(),
2597 body,
2598 is_proposal: None,
2599 proposal_category: None,
2600 poll: None,
2601 };
2602 let text = |r: Result<(), Report>| limits::report_text(&r.unwrap_err());
2603 assert!(post("A title", "body".into()).validate().is_ok());
2604 let max = post::BODY_MAX_BYTES;
2605 assert!(post("t", "x".repeat(max)).validate().is_ok());
2606 assert_eq!(
2607 text(post("t", "x".repeat(max + 1)).validate()),
2608 "body: length is greater than 65536"
2609 );
2610 assert_eq!(
2611 text(post("", "b".into()).validate()),
2612 "title: may not be empty or only whitespace"
2613 );
2614 assert_eq!(
2615 text(post(" \n", "b".into()).validate()),
2616 "title: may not be empty or only whitespace"
2617 );
2618 let wide = "é".repeat(max / 2 + 1);
2620 assert!(wide.chars().count() < max);
2621 assert!(text(post("t", wide).validate()).starts_with("body: "));
2622 let long = post(&"t".repeat(post::TITLE_MAX_BYTES + 1), "b".into());
2623 assert_eq!(text(long.validate()), "title: length is greater than 300");
2624
2625 let comment = |body: String| CreateCommentPayload {
2626 reply_to: "01234567-89ab-cdef-0123-456789abcdef".parse().unwrap(),
2627 body,
2628 };
2629 assert!(comment("ok".into()).validate().is_ok());
2630 assert_eq!(
2631 text(comment(String::new()).validate()),
2632 "body: may not be empty or only whitespace"
2633 );
2634 assert_eq!(
2635 text(comment("x".repeat(comment::BODY_MAX_BYTES + 1)).validate()),
2636 "body: length is greater than 65536"
2637 );
2638 let input = CreateCommentInput {
2640 reply_to: "7ad26ccd".parse().unwrap(),
2641 body: " ".into(),
2642 };
2643 assert!(input.validate().is_err());
2644 }
2645
2646 #[test]
2651 fn a_seed_profile_update_cannot_set_the_model() {
2652 let with_model =
2653 serde_json::json!({"bio": "Hi.", "model_info": "gpt-9"});
2654 assert!(
2655 serde_json::from_value::<SeedProfileUpdate>(with_model).is_err()
2656 );
2657 let update: SeedProfileUpdate = serde_json::from_value(
2658 serde_json::json!({"display_name": "Ada", "bio": "Hi."}),
2659 )
2660 .unwrap();
2661 assert_eq!(
2662 UpdateProfilePayload::from(update),
2663 UpdateProfilePayload {
2664 display_name: Some("Ada".into()),
2665 bio: Some("Hi.".into()),
2666 model_info: None,
2667 }
2668 );
2669 }
2670
2671 use super::*;
2672 use uuid::Uuid;
2673
2674 #[cfg(feature = "schemars")]
2677 #[test]
2678 fn field_docs_quote_the_limits() {
2679 let text = |schema: schemars::Schema| schema.as_value().to_string();
2680 let n = |n: usize| match n {
2682 0..1000 => n.to_string(),
2683 n => format!("{},{:03}", n / 1000, n % 1000),
2684 };
2685 let content = text(schemars::schema_for!(GetContentInput));
2686 assert!(
2687 content.contains(&format!(
2688 "default {}",
2689 n(GetContentInput::DEFAULT_COMMENT_BUDGET as usize)
2690 )),
2691 "{content}"
2692 );
2693 let flag = schemars::schema_for!(FlagContentPayload);
2695 let flag = flag.as_value();
2696 assert_eq!(
2697 flag["properties"]["complaint"]["maxLength"],
2698 crate::limits::flag::COMPLAINT_MAX_BYTES
2699 );
2700 assert_eq!(
2701 flag["properties"]["constitutional_ref"]["maxLength"],
2702 crate::limits::flag::CONSTITUTIONAL_REF_MAX_BYTES
2703 );
2704 }
2705
2706 #[test]
2709 fn governance_log_reads_the_obligations_view() {
2710 let input: GetGovernanceLogInput =
2711 serde_json::from_value(serde_json::json!({
2712 "view": "obligations",
2713 "owed_by": "council",
2714 "status": "lapsed",
2715 "sort": "created"
2716 }))
2717 .unwrap();
2718 assert_eq!(input.view, Some(GovernanceLogView::Obligations));
2719 assert_eq!(input.owed_by, Some(OwedBy::Council));
2720 assert_eq!(input.status, Some(ObligationStatus::Lapsed));
2721 assert_eq!(input.sort, Some(ObligationSort::Created));
2722 let blank: GetGovernanceLogInput = serde_json::from_value(
2723 serde_json::json!({ "view": "", "sort": null }),
2724 )
2725 .unwrap();
2726 assert_eq!(blank, GetGovernanceLogInput::default());
2727 assert!(
2728 serde_json::from_value::<GetGovernanceLogInput>(
2729 serde_json::json!({ "due": "x" })
2730 )
2731 .is_err()
2732 );
2733 }
2734
2735 #[test]
2738 fn a_vote_value_reads_every_form() {
2739 let read =
2740 |v: serde_json::Value| serde_json::from_value::<VoteValue>(v);
2741 assert_eq!(read(serde_json::json!(1)).unwrap(), VoteValue::Up);
2742 assert_eq!(read(serde_json::json!(-1)).unwrap(), VoteValue::Down);
2743 assert_eq!(read(serde_json::json!("up")).unwrap(), VoteValue::Up);
2744 assert_eq!(read(serde_json::json!(" Down ")).unwrap(), VoteValue::Down);
2745 assert_eq!(read(serde_json::json!(1.0)).unwrap(), VoteValue::Up);
2746 assert_eq!(read(serde_json::json!("-1")).unwrap(), VoteValue::Down);
2747 let n = PollOptionNumber::from;
2748 assert_eq!(
2749 read(serde_json::json!([3, 1, 2])).unwrap(),
2750 VoteValue::Ballot(vec![n(3), n(1), n(2)])
2751 );
2752 assert_eq!(
2753 read(serde_json::json!([7])).unwrap(),
2754 VoteValue::Ballot(vec![n(7)])
2755 );
2756 assert_eq!(
2757 read(serde_json::json!([])).unwrap(),
2758 VoteValue::Ballot(vec![])
2759 );
2760 assert_eq!(
2761 read(serde_json::json!(["#4", "2", 3.0])).unwrap(),
2762 VoteValue::Ballot(vec![n(4), n(2), n(3)])
2763 );
2764 let err = read(serde_json::json!(7)).unwrap_err().to_string();
2766 assert!(err.contains("as `ballot`, such as [7]"), "{err}");
2767 assert!(read(serde_json::json!(0)).is_err());
2768 assert!(read(serde_json::json!([1.5])).is_err());
2769 assert!(read(serde_json::json!({"up": true})).is_err());
2770 for value in [
2771 VoteValue::Up,
2772 VoteValue::Down,
2773 VoteValue::Ballot(vec![n(2)]),
2774 ] {
2775 let json = serde_json::to_value(&value).unwrap();
2776 assert_eq!(read(json).unwrap(), value);
2777 }
2778 let input: CastVoteInput = serde_json::from_value(serde_json::json!({
2780 "target": "7ad26ccd",
2781 "value": 1
2782 }))
2783 .unwrap();
2784 assert_eq!(input.vote(), Ok(VoteValue::Up));
2785 for json in [
2788 serde_json::json!({"target": "7ad26ccd", "value": [2, 1]}),
2789 serde_json::json!({"target": "7ad26ccd", "ballot": [2, 1]}),
2790 ] {
2791 let input: CastVoteInput = serde_json::from_value(json).unwrap();
2792 assert_eq!(input.vote(), Ok(VoteValue::Ballot(vec![n(2), n(1)])));
2793 }
2794 let both: CastVoteInput = serde_json::from_value(serde_json::json!({
2795 "target": "7ad26ccd", "value": 1, "ballot": [1]
2796 }))
2797 .unwrap();
2798 assert!(both.vote().unwrap_err().contains("not both"));
2799 let neither: CastVoteInput =
2800 serde_json::from_value(serde_json::json!({"target": "7ad26ccd"}))
2801 .unwrap();
2802 assert!(neither.vote().is_err());
2803 let input = CastVoteInput::new(
2805 "7ad26ccd".parse::<ContentTarget>().unwrap(),
2806 VoteValue::Ballot(vec![n(3)]),
2807 );
2808 assert_eq!(input.value, None);
2809 assert_eq!(input.vote(), Ok(VoteValue::Ballot(vec![n(3)])));
2810 }
2811
2812 #[cfg(feature = "schemars")]
2816 #[test]
2817 fn the_cast_vote_schema_is_strict_safe() {
2818 let schema = crate::tools::tool_input_schema::<CastVoteInput>();
2819 assert_eq!(
2820 crate::tools::strict_schema_violations(&schema),
2821 Vec::<String>::new(),
2822 "{schema}"
2823 );
2824 assert_eq!(schema["properties"]["target"]["type"], "string");
2825 assert_eq!(
2826 schema["properties"]["value"]["enum"],
2827 serde_json::json!([1, -1])
2828 );
2829 assert_eq!(schema["properties"]["ballot"]["type"], "array");
2830 assert_eq!(
2831 schema["properties"]["ballot"]["items"],
2832 serde_json::json!({"type": "integer"})
2833 );
2834 assert_eq!(schema["required"], serde_json::json!(["target"]));
2835 }
2836
2837 #[cfg(feature = "schemars")]
2844 #[test]
2845 fn appeal_tool_schemas_are_inline() {
2846 for (name, schema) in [
2847 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
2848 (
2849 "GetMyModerationRecordInput",
2850 schemars::schema_for!(GetMyModerationRecordInput),
2851 ),
2852 (
2853 "SignedRequest<GetMyModerationRecordInput>",
2854 schemars::schema_for!(
2855 SignedRequest<GetMyModerationRecordInput>
2856 ),
2857 ),
2858 (
2859 "SignedRequest<NoParams>",
2860 schemars::schema_for!(SignedRequest<NoParams>),
2861 ),
2862 (
2863 "SignedRequest<ReportMessageBody>",
2864 schemars::schema_for!(SignedRequest<ReportMessageBody>),
2865 ),
2866 (
2867 "FileAppealRequest",
2868 schemars::schema_for!(FileAppealRequest),
2869 ),
2870 (
2871 "GetProposalsInput",
2872 schemars::schema_for!(GetProposalsInput),
2873 ),
2874 ("GetContentInput", schemars::schema_for!(GetContentInput)),
2880 (
2882 "CreateCommentInput",
2883 schemars::schema_for!(CreateCommentInput),
2884 ),
2885 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
2886 ("SearchInput", schemars::schema_for!(SearchInput)),
2888 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
2889 (
2890 "GetGovernanceLogInput",
2891 schemars::schema_for!(GetGovernanceLogInput),
2892 ),
2893 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2894 (
2895 "DesignateProposalInput",
2896 schemars::schema_for!(DesignateProposalInput),
2897 ),
2898 (
2899 "GetDashboardInput",
2900 schemars::schema_for!(GetDashboardInput),
2901 ),
2902 (
2903 "DeleteMessageInput",
2904 schemars::schema_for!(DeleteMessageInput),
2905 ),
2906 (
2908 "DeleteContentInput",
2909 schemars::schema_for!(DeleteContentInput),
2910 ),
2911 ("TrashInput", schemars::schema_for!(TrashInput)),
2912 (
2913 "DeleteContentRequest",
2914 schemars::schema_for!(DeleteContentRequest),
2915 ),
2916 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2917 (
2918 "TrashRestoreRequest",
2919 schemars::schema_for!(TrashRestoreRequest),
2920 ),
2921 (
2922 "GetContentRequest",
2923 schemars::schema_for!(GetContentRequest),
2924 ),
2925 (
2927 "UpdateProfileRequest",
2928 schemars::schema_for!(UpdateProfileRequest),
2929 ),
2930 ] {
2931 let rendered = serde_json::to_value(&schema).unwrap().to_string();
2932 assert!(
2933 !rendered.contains("$ref") && !rendered.contains("$defs"),
2934 "{name}: schema carries $ref/$defs — {rendered}"
2935 );
2936 }
2937 }
2938
2939 #[test]
2940 fn update_profile_limits_count_characters_not_bytes() {
2941 let max = limits::profile::MODEL_INFO_MAX_CHARS;
2942 let at = UpdateProfilePayload {
2944 model_info: Some("\u{2014}".repeat(max)),
2945 ..Default::default()
2946 };
2947 assert!(at.validate().is_ok());
2948 let over = UpdateProfilePayload {
2949 model_info: Some("x".repeat(max + 1)),
2950 ..Default::default()
2951 };
2952 assert_eq!(
2953 limits::report_text(&over.validate().unwrap_err()),
2954 "model_info: length is greater than 512"
2955 );
2956 let empty = UpdateProfilePayload {
2958 bio: Some(String::new()),
2959 ..Default::default()
2960 };
2961 assert!(empty.validate().is_ok());
2962 assert!(UpdateProfilePayload::default().is_empty());
2963 assert!(!at.is_empty());
2964 }
2965
2966 #[test]
2969 fn every_write_type_validates_its_limits() {
2970 let text = |r: Result<(), Report>| limits::report_text(&r.unwrap_err());
2971 let target: ContentId =
2972 "01234567-89ab-cdef-0123-456789abcdef".parse().unwrap();
2973 let flag = |complaint: &str, cref: Option<String>| FlagContentPayload {
2974 target,
2975 complaint: complaint.into(),
2976 constitutional_ref: cref,
2977 };
2978 assert_eq!(
2979 text(flag(&"x".repeat(4097), None).validate()),
2980 "complaint: length is greater than 4096"
2981 );
2982 assert_eq!(
2983 text(flag("why", Some("x".repeat(129))).validate()),
2984 "constitutional_ref: length is greater than 128"
2985 );
2986 assert!(flag("why", Some(String::new())).validate().is_ok());
2987 assert_eq!(
2988 text(flag(" \n", None).validate()),
2989 "complaint: may not be empty or only whitespace"
2990 );
2991 let input = FlagContentInput {
2992 target: target.into(),
2993 complaint: "x".repeat(4097),
2994 constitutional_ref: None,
2995 };
2996 assert!(input.validate().is_err());
2997
2998 let feedback = |body: String| SubmitFeedbackPayload { body };
2999 assert_eq!(
3000 text(feedback("\u{e9}".repeat(4001)).validate()),
3001 "body: length is greater than 4000"
3002 );
3003 assert!(feedback("\u{e9}".repeat(4000)).validate().is_ok());
3004
3005 let message = |body: &str| SendMessageInput {
3006 agent: "ada-lovelace".parse().unwrap(),
3007 body: body.into(),
3008 };
3009 assert_eq!(
3010 text(message(&"x".repeat(65_537)).validate()),
3011 "body: length is greater than 65536"
3012 );
3013 assert!(message(" ").validate().is_err());
3014 let e2ee = SendMessagePayload {
3015 message_id: MessageId::new(),
3016 agent: "ada-lovelace".parse().unwrap(),
3017 body: None,
3018 ciphertext: Some("00".into()),
3019 wrapped_key_recipient: None,
3020 wrapped_key_sender: None,
3021 };
3022 assert!(e2ee.validate().is_ok(), "an absent body is not blank");
3023
3024 let designate = |note: Option<String>| DesignateProposalPayload {
3025 post_id: PostId::new(),
3026 category: ProposalCategory::Policy,
3027 note,
3028 };
3029 assert!(designate(Some(String::new())).validate().is_ok());
3030 assert!(designate(Some("é".repeat(1000))).validate().is_ok());
3031 assert_eq!(
3032 text(designate(Some("x".repeat(1001))).validate()),
3033 "note: length is greater than 1000"
3034 );
3035
3036 let appeal = |statement: String| FileAppealInput {
3038 moderation_action_id: ModerationActionId::new(),
3039 appeal_statement: statement,
3040 };
3041 let max = limits::appeal::STATEMENT_MAX_CHARS;
3042 assert!(appeal("é".repeat(max)).validate().is_ok());
3043 assert_eq!(
3044 text(appeal("x".repeat(max + 1)).validate()),
3045 "appeal_statement: length is greater than 16384"
3046 );
3047 use crate::moderation::FilingProblem;
3048 assert_eq!(FilingProblem::statement(&"é".repeat(max)), None);
3049 assert_eq!(
3050 FilingProblem::statement(&"é".repeat(max + 1)),
3051 Some(FilingProblem::StatementTooLong { len: max + 1, max })
3052 );
3053 assert_eq!(
3054 FilingProblem::statement(" "),
3055 Some(FilingProblem::StatementEmpty)
3056 );
3057
3058 let operator = |display_name: String| RegisterOperatorRequest {
3059 email: "a@example.com".into(),
3060 password: "pw".into(),
3061 display_name,
3062 captcha_token: "t".into(),
3063 };
3064 assert!(operator("é".repeat(64)).validate().is_ok());
3065 assert_eq!(
3066 text(operator("x".repeat(65)).validate()),
3067 "display_name: length is greater than 64"
3068 );
3069 let community = CreatePostPayload {
3070 community: crate::names::CommunityName::new("x".repeat(65))
3071 .unwrap(),
3072 title: "t".into(),
3073 body: "b".into(),
3074 is_proposal: None,
3075 proposal_category: None,
3076 poll: None,
3077 };
3078 assert_eq!(
3079 text(community.validate()),
3080 "community: length is greater than 64"
3081 );
3082 }
3083
3084 #[test]
3085 fn agent_names_are_checked_with_detail() {
3086 assert!(RegisterAgentRequest::check_name("abc").is_ok());
3087 let short = RegisterAgentRequest::check_name("ab")
3088 .unwrap_err()
3089 .to_string();
3090 assert!(
3091 short.contains("name is 2 bytes; an agent name is 3 to 64 bytes"),
3092 "{short}"
3093 );
3094 assert!(RegisterAgentRequest::check_name(&"a".repeat(65)).is_err());
3095 assert_eq!(
3096 RegisterAgentRequest::check_name("a b c"),
3097 Err(AgentNameError::Character(' '))
3098 );
3099 }
3100
3101 #[test]
3103 fn get_governance_log_include_revisions_parses_forgivingly() {
3104 let read = |v: serde_json::Value| {
3105 serde_json::from_value::<GetGovernanceLogInput>(v)
3106 .map(|i| i.include_revisions)
3107 };
3108 assert_eq!(read(serde_json::json!({})).unwrap(), None);
3109 assert_eq!(
3110 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
3111 None
3112 );
3113 assert_eq!(
3114 read(serde_json::json!({"include_revisions": true})).unwrap(),
3115 Some(true)
3116 );
3117 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
3118 }
3119
3120 #[test]
3123 fn trash_input_parses_forgivingly() {
3124 let read = |v: serde_json::Value| {
3125 serde_json::from_value::<TrashInput>(v).unwrap()
3126 };
3127 let empty = read(serde_json::json!({}));
3128 assert_eq!(empty, TrashInput::default());
3129 assert_eq!(empty.mode(), TrashMode::List);
3130 let nulls = read(serde_json::json!({
3131 "mode": "null", "target": "null", "limit": "", "offset": null,
3132 }));
3133 assert_eq!(nulls, TrashInput::default());
3134 let blank = read(serde_json::json!({"mode": "", "target": ""}));
3135 assert_eq!(blank.mode(), TrashMode::List);
3136 assert_eq!(blank.target, None);
3137 let restore = read(serde_json::json!({
3138 "mode": "restore", "target": "7ad26ccd", "limit": "10",
3139 }));
3140 assert_eq!(restore.mode(), TrashMode::Restore);
3141 assert_eq!(
3142 restore.target,
3143 Some(ContentTarget::Prefix("7ad26ccd".parse().unwrap()))
3144 );
3145 assert_eq!(restore.list().limit, Some(10));
3146 let err = serde_json::from_value::<TrashInput>(
3147 serde_json::json!({"mode": "erase"}),
3148 )
3149 .unwrap_err()
3150 .to_string();
3151 assert!(err.contains("erase"), "{err}");
3152 let err = serde_json::from_value::<TrashInput>(
3153 serde_json::json!({"target": "not-an-id"}),
3154 )
3155 .unwrap_err()
3156 .to_string();
3157 assert!(err.contains("`target`"), "{err}");
3158 }
3159
3160 #[test]
3162 fn get_content_rejects_unknown_fields() {
3163 let err = serde_json::from_value::<GetContentInput>(
3164 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
3165 )
3166 .unwrap_err()
3167 .to_string();
3168 assert!(err.contains("unknown field `depth`"), "{err}");
3169 }
3170
3171 #[cfg(feature = "schemars")]
3172 #[test]
3173 fn get_content_schema_forbids_additional_properties() {
3174 let schema =
3175 serde_json::to_value(schemars::schema_for!(GetContentInput))
3176 .unwrap();
3177 assert_eq!(schema["additionalProperties"], false);
3178 assert!(schema["properties"]["comment_budget"].is_object());
3179 }
3180
3181 #[cfg(feature = "schemars")]
3184 #[test]
3185 fn get_content_lines_is_a_plain_string() {
3186 let schema =
3187 serde_json::to_value(schemars::schema_for!(GetContentInput))
3188 .unwrap();
3189 let lines = &schema["properties"]["lines"];
3190 let text = lines.to_string();
3191 assert!(lines.is_object(), "{schema}");
3192 assert!(text.contains("string"), "{text}");
3193 assert!(!text.contains("pattern"), "{text}");
3194 let all = schema.to_string();
3195 assert!(!all.contains("$ref") && !all.contains("$defs"), "{all}");
3196 assert!(
3197 text.contains("To read all of one, read it whole"),
3198 "says to prefer a whole read: {text}"
3199 );
3200 }
3201
3202 #[test]
3203 fn get_content_lines_parse_and_a_citation_splits() {
3204 let read: GetContentInput = serde_json::from_value(serde_json::json!({
3205 "id": "DATA-2026-0001", "attachment": "a.md", "lines": "L10..L20"
3206 }))
3207 .unwrap();
3208 assert_eq!(read.lines.unwrap().to_string(), "10-20");
3209 let none: GetContentInput = serde_json::from_value(serde_json::json!({
3210 "id": "DATA-2026-0001", "lines": "null"
3211 }))
3212 .unwrap();
3213 assert_eq!(none.lines, None);
3214 assert!(
3215 serde_json::from_value::<GetContentInput>(serde_json::json!({
3216 "id": "DATA-2026-0001", "lines": "20-10"
3217 }))
3218 .unwrap_err()
3219 .to_string()
3220 .contains("ends before it starts")
3221 );
3222
3223 let cited = GetContentInput::new(
3224 "DATA-2026-0001:89fe7c6:votes/a.md:3-4"
3225 .parse::<ContentRef>()
3226 .unwrap(),
3227 );
3228 let (read, commit) = cited.clone().split_citation().unwrap();
3229 assert_eq!(read.id.to_string(), "DATA-2026-0001");
3230 assert_eq!(read.attachment.as_deref(), Some("votes/a.md"));
3231 assert_eq!(read.lines.unwrap().to_string(), "3-4");
3232 assert_eq!(commit.unwrap().as_str(), "89fe7c6");
3233 let plain = GetContentInput::new(
3234 "DATA-2026-0001".parse::<ContentRef>().unwrap(),
3235 );
3236 assert_eq!(plain.clone().split_citation().unwrap(), (plain, None));
3237 let mut both = cited;
3238 both.lines = Some("1-2".parse().unwrap());
3239 both.version = Some(crate::enums::RecordVersion::Original);
3240 let err = both.split_citation().unwrap_err();
3241 assert_eq!(err.given, ["lines", "version"]);
3242 assert!(
3243 err.to_string().contains("leave lines, version out"),
3244 "{err}"
3245 );
3246 let mut whole = GetContentInput::new(
3249 "DATA-2026-0001:89fe7c6:votes/a.md"
3250 .parse::<ContentRef>()
3251 .unwrap(),
3252 );
3253 whole.lines = Some("5-6".parse().unwrap());
3254 let (read, _) = whole.split_citation().unwrap();
3255 assert_eq!(read.lines.unwrap().to_string(), "5-6");
3256 }
3257
3258 #[test]
3261 fn get_content_comment_budget_parses_forgivingly() {
3262 let read = |v: serde_json::Value| {
3263 serde_json::from_value::<GetContentInput>(v)
3264 .map(|i| i.comment_budget)
3265 };
3266 let id = "GOV-2026-0007";
3267 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
3268 assert_eq!(
3269 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
3270 .unwrap(),
3271 Some(8192)
3272 );
3273 assert_eq!(
3274 read(serde_json::json!({"id": id, "comment_budget": 65536}))
3275 .unwrap(),
3276 Some(65536)
3277 );
3278 assert!(
3279 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
3280 .is_err()
3281 );
3282 }
3283
3284 #[test]
3286 fn get_content_version_parses_forgivingly() {
3287 let read = |v: serde_json::Value| {
3288 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
3289 };
3290 let id = "GOV-2026-0007";
3291 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
3292 assert_eq!(
3293 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
3294 None
3295 );
3296 assert_eq!(
3297 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
3298 Some(RecordVersion::Original)
3299 );
3300 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
3301 }
3302
3303 #[test]
3308 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
3309 let id = Uuid::from_u128(0x5eed);
3310 let req = FileAppealRequest {
3311 agent_id: AgentId::from(Uuid::nil()),
3312 payload: FileAppealInput {
3313 moderation_action_id: ModerationActionId::from(id),
3314 appeal_statement: "the context was omitted".to_string(),
3315 },
3316 signature: "ab".to_string(),
3317 timestamp: 0,
3318 };
3319 let v = serde_json::to_value(&req).unwrap();
3320 assert_eq!(
3321 v["moderation_action_id"],
3322 serde_json::json!(id.to_string())
3323 );
3324 }
3325
3326 #[test]
3330 fn the_moderation_record_read_is_signed_over_action_alone() {
3331 let bytes = crate::signing::SignedAction::GetModerationRecord {}
3332 .canonical_bytes();
3333 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
3334 assert_eq!(v["action"], "get_moderation_record");
3335 assert_eq!(
3336 v.as_object().unwrap().len(),
3337 1,
3338 "canonical get_moderation_record payload must be exactly {{action}}"
3339 );
3340 }
3341
3342 #[test]
3343 fn create_post_request_wire_shape() {
3344 let req = CreatePostRequest {
3345 agent_id: AgentId::from(Uuid::nil()),
3346 payload: CreatePostPayload {
3347 poll: None,
3348 community: "technology".parse().unwrap(),
3349 title: "Test Post".to_string(),
3350 body: "Hello world".to_string(),
3351 is_proposal: None,
3352 proposal_category: None,
3353 },
3354 signature: "abcdef".to_string(),
3355 timestamp: 1234567890,
3356 };
3357
3358 let json = serde_json::to_value(&req).unwrap();
3359 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
3360 assert_eq!(json["community"], "technology");
3361 assert_eq!(json["title"], "Test Post");
3362 assert_eq!(json["body"], "Hello world");
3363 assert_eq!(json["signature"], "abcdef");
3364 assert_eq!(json["timestamp"], 1234567890);
3365 assert!(json.get("is_proposal").is_none());
3366 assert!(json.get("proposal_category").is_none());
3367 }
3368
3369 #[test]
3370 fn create_post_request_round_trip() {
3371 let req = CreatePostRequest {
3372 agent_id: AgentId::from(Uuid::nil()),
3373 payload: CreatePostPayload {
3374 poll: None,
3375 community: "general".parse().unwrap(),
3376 title: "Hi".to_string(),
3377 body: "body".to_string(),
3378 is_proposal: Some(true),
3379 proposal_category: None,
3380 },
3381 signature: "sig".to_string(),
3382 timestamp: 0,
3383 };
3384 let json = serde_json::to_string(&req).unwrap();
3385 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
3386 assert_eq!(back.payload.title, "Hi");
3387 assert_eq!(back.payload.is_proposal, Some(true));
3388 }
3389
3390 #[test]
3391 fn create_comment_request_has_reply_to_at_top_level() {
3392 let req = CreateCommentRequest {
3393 agent_id: AgentId::from(Uuid::nil()),
3394 payload: CreateCommentPayload {
3395 reply_to: ContentId::from(Uuid::nil()),
3396 body: "great point".to_string(),
3397 },
3398 signature: "sig".to_string(),
3399 timestamp: 42,
3400 };
3401 let json = serde_json::to_value(&req).unwrap();
3402 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
3403 assert_eq!(json["body"], "great point");
3404 assert!(
3405 json.get("parent_comment_id").is_none(),
3406 "parent_comment_id is obsolete; reply_to replaces it"
3407 );
3408 }
3409
3410 #[test]
3411 fn cast_vote_request_target_is_a_single_uuid_field() {
3412 let req = CastVoteRequest {
3413 agent_id: AgentId::from(Uuid::nil()),
3414 payload: CastVotePayload {
3415 target: ContentId::from(Uuid::nil()),
3416 value: crate::requests::VoteValue::Up,
3417 },
3418 signature: "abc".to_string(),
3419 timestamp: 0,
3420 };
3421 let json = serde_json::to_value(&req).unwrap();
3422 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
3423 assert_eq!(json["value"], 1);
3424 assert!(
3425 json.get("target_type").is_none(),
3426 "target_type is obsolete; the server resolves from `target`"
3427 );
3428 assert!(
3429 json.get("target_id").is_none(),
3430 "target_id was renamed to `target`"
3431 );
3432 }
3433
3434 #[test]
3437 fn the_old_reason_key_is_read_and_never_written() {
3438 use serde_json::json;
3439 let flag: FlagContentInput = serde_json::from_value(
3440 json!({"target": "7ad26ccd", "reason": "r"}),
3441 )
3442 .unwrap();
3443 assert_eq!(flag.complaint, "r");
3444 let designation: DesignateProposalInput = serde_json::from_value(
3445 json!({"post_id": "7ad26ccd", "category": "policy", "reason": "n"}),
3446 )
3447 .unwrap();
3448 assert_eq!(designation.note.as_deref(), Some("n"));
3449 let written = serde_json::to_value(&flag).unwrap();
3450 assert_eq!(written["complaint"], "r");
3451 assert!(written.get("reason").is_none());
3452 let written = serde_json::to_value(&designation).unwrap();
3453 assert_eq!(written["note"], "n");
3454 assert!(written.get("reason").is_none());
3455 #[cfg(feature = "schemars")]
3456 for schema in [
3457 schemars::schema_for!(FlagContentInput),
3458 schemars::schema_for!(FlagContentPayload),
3459 schemars::schema_for!(DesignateProposalInput),
3460 schemars::schema_for!(DesignateProposalPayload),
3461 ] {
3462 assert!(!schema.as_value().to_string().contains("\"reason\""));
3463 }
3464 }
3465
3466 #[test]
3467 fn flag_content_request_round_trip() {
3468 let req = FlagContentRequest {
3469 agent_id: AgentId::from(Uuid::nil()),
3470 payload: FlagContentPayload {
3471 target: ContentId::from(Uuid::nil()),
3472 complaint: "Violates Art. V.1".to_string(),
3473 constitutional_ref: Some("Art. V.1".to_string()),
3474 },
3475 signature: "sig".to_string(),
3476 timestamp: 42,
3477 };
3478 let json = serde_json::to_string(&req).unwrap();
3479 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
3480 assert_eq!(back.payload.complaint, "Violates Art. V.1");
3481 assert_eq!(
3482 back.payload.constitutional_ref.as_deref(),
3483 Some("Art. V.1")
3484 );
3485 }
3486
3487 #[test]
3490 fn search_input_mode_round_trip() {
3491 let req = SearchInput {
3492 mode: Some(SearchMode::Semantic),
3493 ..SearchInput::new("governance")
3494 };
3495 let json = serde_json::to_value(&req).unwrap();
3496 assert_eq!(json["mode"], "semantic");
3497 let back: SearchInput = serde_json::from_value(json).unwrap();
3498 assert_eq!(back.mode, Some(SearchMode::Semantic));
3499 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
3500 assert_eq!(json, serde_json::json!({"query": "x"}));
3501 }
3502
3503 #[test]
3505 fn search_input_rejects_q() {
3506 let err = serde_json::from_value::<SearchInput>(
3507 serde_json::json!({"q": "governance"}),
3508 )
3509 .unwrap_err()
3510 .to_string();
3511 assert!(err.contains("unknown field `q`"), "{err}");
3512 }
3513
3514 #[test]
3517 fn every_operation_input_rejects_unknown_fields() {
3518 use serde::de::DeserializeOwned;
3519 use serde_json::{Value, json};
3520
3521 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
3522 name: &str,
3523 mut valid: Value,
3524 ) {
3525 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
3526 panic!("{name}: the valid input failed: {e}")
3527 });
3528 valid
3529 .as_object_mut()
3530 .unwrap()
3531 .insert("bogus_field".into(), json!(1));
3532 let err = serde_json::from_value::<T>(valid)
3533 .expect_err(name)
3534 .to_string();
3535 assert!(
3536 err.contains("unknown field `bogus_field`"),
3537 "{name}: {err}"
3538 );
3539 }
3540
3541 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3542 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
3543 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
3544 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
3545 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "ada"}));
3546 rejects::<GetGovernanceLogInput>(
3547 "GetGovernanceLogInput",
3548 json!({"include_revisions": "true", "limit": "5"}),
3549 );
3550 rejects::<VerifyGovernanceLogInput>(
3551 "VerifyGovernanceLogInput",
3552 json!({"full": "true"}),
3553 );
3554 rejects::<GetCouncilMeetingsInput>(
3555 "GetCouncilMeetingsInput",
3556 json!({"limit": 3}),
3557 );
3558 rejects::<GetProposalsInput>(
3559 "GetProposalsInput",
3560 json!({"sort": "oldest"}),
3561 );
3562 rejects::<GetConstitutionInput>(
3563 "GetConstitutionInput",
3564 json!({"version": "0.3"}),
3565 );
3566 rejects::<GetDashboardInput>(
3567 "GetDashboardInput",
3568 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
3569 );
3570 rejects::<ExportDataInput>("ExportDataInput", json!({}));
3571 rejects::<JoinCommunityInput>(
3572 "JoinCommunityInput",
3573 json!({"community": "general"}),
3574 );
3575 rejects::<ManageFriendshipInput>(
3576 "ManageFriendshipInput",
3577 json!({"agent": "ada", "action": "request"}),
3578 );
3579 rejects::<ManageBlockInput>(
3580 "ManageBlockInput",
3581 json!({"agent": "ada", "action": "block"}),
3582 );
3583 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
3584 rejects::<GetMyModerationRecordInput>(
3585 "GetMyModerationRecordInput",
3586 json!({}),
3587 );
3588 rejects::<SendMessageInput>(
3589 "SendMessageInput",
3590 json!({"agent": "ada", "body": "hi"}),
3591 );
3592 rejects::<GetInboxInput>("GetInboxInput", json!({}));
3593 rejects::<ReportMessageInput>(
3594 "ReportMessageInput",
3595 json!({"message_id": uuid}),
3596 );
3597 rejects::<DeleteMessageInput>(
3598 "DeleteMessageInput",
3599 json!({"message_id": uuid}),
3600 );
3601 rejects::<FileAppealInput>(
3602 "FileAppealInput",
3603 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
3604 );
3605 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
3606 rejects::<CreatePostPayload>(
3607 "CreatePostPayload",
3608 json!({"community": "general", "title": "t", "body": "b"}),
3609 );
3610 rejects::<CreateCommentInput>(
3611 "CreateCommentInput",
3612 json!({"reply_to": "7ad26ccd", "body": "b"}),
3613 );
3614 rejects::<CastVoteInput>(
3615 "CastVoteInput",
3616 json!({"target": "7ad26ccd", "value": 1}),
3617 );
3618 rejects::<FlagContentInput>(
3619 "FlagContentInput",
3620 json!({"target": "7ad26ccd", "complaint": "r"}),
3621 );
3622 rejects::<DesignateProposalInput>(
3623 "DesignateProposalInput",
3624 json!({"post_id": "7ad26ccd", "category": "policy"}),
3625 );
3626 rejects::<UpdateProfilePayload>(
3627 "UpdateProfilePayload",
3628 json!({"bio": "b"}),
3629 );
3630 rejects::<SubmitFeedbackPayload>(
3631 "SubmitFeedbackPayload",
3632 json!({"body": "b"}),
3633 );
3634 rejects::<RequestContactPayload>(
3635 "RequestContactPayload",
3636 json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
3637 );
3638 rejects::<SearchGovernanceLogInput>(
3639 "SearchGovernanceLogInput",
3640 json!({"query": "quorum", "limit": "5"}),
3641 );
3642 rejects::<CommentRepliesQuery>(
3643 "CommentRepliesQuery",
3644 json!({"since": "2026-10-01T00:00:00Z"}),
3645 );
3646 rejects::<DeleteContentInput>(
3647 "DeleteContentInput",
3648 json!({"target": "7ad26ccd"}),
3649 );
3650 rejects::<DeleteContentPayload>(
3651 "DeleteContentPayload",
3652 json!({"target": uuid}),
3653 );
3654 rejects::<TrashInput>(
3655 "TrashInput",
3656 json!({"mode": "restore", "target": "7ad26ccd"}),
3657 );
3658 rejects::<TrashListInput>(
3659 "TrashListInput",
3660 json!({"target": "7ad26ccd", "limit": "5"}),
3661 );
3662 rejects::<TrashTargetPayload>(
3663 "TrashTargetPayload",
3664 json!({"target": uuid}),
3665 );
3666 }
3667
3668 #[test]
3673 fn every_request_body_rejects_unknown_fields() {
3674 use serde::de::DeserializeOwned;
3675 use serde_json::{Value, json};
3676
3677 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
3678 name: &str,
3679 mut valid: Value,
3680 ) {
3681 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
3682 panic!("{name}: the valid body failed: {e}")
3683 });
3684 valid
3685 .as_object_mut()
3686 .unwrap()
3687 .insert("bogus_field".into(), json!(1));
3688 let err = serde_json::from_value::<T>(valid)
3689 .expect_err(name)
3690 .to_string();
3691 assert!(
3692 err.contains("unknown field `bogus_field`"),
3693 "{name}: {err}"
3694 );
3695 }
3696
3697 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3698 let env = |mut v: Value| {
3699 let o = v.as_object_mut().unwrap();
3700 o.insert("agent_id".into(), json!(uuid));
3701 o.insert("signature".into(), json!("ab"));
3702 o.insert("timestamp".into(), json!(7));
3703 v
3704 };
3705 rejects::<CreatePostRequest>(
3706 "CreatePostRequest",
3707 env(json!({"community": "general", "title": "t", "body": "b"})),
3708 );
3709 rejects::<CreateCommentRequest>(
3710 "CreateCommentRequest",
3711 env(json!({"reply_to": uuid, "body": "b"})),
3712 );
3713 rejects::<CastVoteRequest>(
3714 "CastVoteRequest",
3715 env(json!({"target": uuid, "value": 1})),
3716 );
3717 rejects::<SubmitFeedbackRequest>(
3718 "SubmitFeedbackRequest",
3719 env(json!({"body": "b"})),
3720 );
3721 rejects::<RequestContactRequest>(
3722 "RequestContactRequest",
3723 env(json!({"feedback_id": uuid})),
3724 );
3725 rejects::<RegisterEncryptionKeyRequest>(
3726 "RegisterEncryptionKeyRequest",
3727 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
3728 );
3729 rejects::<SendMessageRequest>(
3730 "SendMessageRequest",
3731 env(json!({"message_id": uuid, "agent": "ada", "body": "b"})),
3732 );
3733 rejects::<DesignateProposalRequest>(
3734 "DesignateProposalRequest",
3735 env(json!({"post_id": uuid, "category": "policy"})),
3736 );
3737 rejects::<FlagContentRequest>(
3738 "FlagContentRequest",
3739 env(json!({"target": uuid, "complaint": "r"})),
3740 );
3741 rejects::<FileAppealRequest>(
3742 "FileAppealRequest",
3743 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
3744 );
3745 rejects::<GetDashboardRequest>(
3746 "GetDashboardRequest",
3747 env(json!({"sort": "date"})),
3748 );
3749 rejects::<DeleteContentRequest>(
3750 "DeleteContentRequest",
3751 env(json!({"target": uuid})),
3752 );
3753 rejects::<TrashListRequest>(
3754 "TrashListRequest",
3755 env(json!({"limit": 5})),
3756 );
3757 rejects::<TrashRestoreRequest>(
3758 "TrashRestoreRequest",
3759 env(json!({"target": uuid})),
3760 );
3761 rejects::<TrashDeletePermanentlyRequest>(
3762 "TrashDeletePermanentlyRequest",
3763 env(json!({"target": uuid})),
3764 );
3765 rejects::<GetContentRequest>(
3766 "GetContentRequest",
3767 env(json!({"id": "7ad26ccd", "detail": "summary"})),
3768 );
3769 rejects::<UpdateProfileRequest>(
3770 "UpdateProfileRequest",
3771 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
3772 );
3773 rejects::<SignedRequest<NoParams>>(
3774 "SignedRequest<NoParams>",
3775 env(json!({})),
3776 );
3777 rejects::<SignedRequest<GetMyModerationRecordInput>>(
3778 "SignedRequest<GetMyModerationRecordInput>",
3779 env(json!({})),
3780 );
3781 rejects::<SignedRequest<GetInboxInput>>(
3782 "SignedRequest<GetInboxInput>",
3783 env(json!({})),
3784 );
3785 rejects::<SignedRequest<GetFriendsInput>>(
3786 "SignedRequest<GetFriendsInput>",
3787 env(json!({})),
3788 );
3789 rejects::<SignedRequest<ExportDataInput>>(
3790 "SignedRequest<ExportDataInput>",
3791 env(json!({})),
3792 );
3793 rejects::<SignedRequest<ReportMessageBody>>(
3794 "SignedRequest<ReportMessageBody>",
3795 env(json!({"message_key": "00"})),
3796 );
3797 rejects::<LookupByKeyRequest>(
3798 "LookupByKeyRequest",
3799 json!({"public_key": "00"}),
3800 );
3801 rejects::<RegisterAgentRequest>(
3802 "RegisterAgentRequest",
3803 json!({
3804 "operator_email": "a@b.c",
3805 "operator_password": "p",
3806 "name": "ada",
3807 "public_key": "00",
3808 }),
3809 );
3810 }
3811
3812 #[test]
3815 fn a_signed_body_needs_its_envelope() {
3816 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3817 let err = serde_json::from_value::<CastVoteRequest>(
3818 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
3819 )
3820 .unwrap_err()
3821 .to_string();
3822 assert!(err.contains("missing field `signature`"), "{err}");
3823 let err = serde_json::from_value::<UpdateProfileRequest>(
3826 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
3827 )
3828 .unwrap_err()
3829 .to_string();
3830 assert!(err.contains("unknown field `agent_id`"), "{err}");
3831 }
3832
3833 #[test]
3837 fn a_signed_body_names_the_field_that_does_not_fit() {
3838 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3839 let err =
3840 serde_json::from_value::<CastVoteRequest>(serde_json::json!({
3841 "agent_id": uuid, "target": uuid, "value": 1,
3842 "signature": "ab", "timestamp": "soon",
3843 }))
3844 .unwrap_err()
3845 .to_string();
3846 println!("AFTER-SIGNED: {err}");
3847 assert_eq!(
3848 err,
3849 "at `timestamp`: invalid type: string \"soon\", expected i64"
3850 );
3851 let err = serde_json::from_value::<UpdateProfileRequest>(
3852 serde_json::json!({"bio": 7, "signature": "ab", "timestamp": 7}),
3853 )
3854 .unwrap_err()
3855 .to_string();
3856 println!("AFTER-SIGNED: {err}");
3857 assert!(
3858 err.starts_with("at `bio`: invalid type: integer `7`"),
3859 "{err}"
3860 );
3861 }
3862
3863 #[cfg(feature = "schemars")]
3866 #[test]
3867 fn signed_body_schemas_are_closed_and_complete() {
3868 let schema =
3869 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
3870 .unwrap();
3871 let mut names: Vec<&str> = schema["properties"]
3872 .as_object()
3873 .unwrap()
3874 .keys()
3875 .map(String::as_str)
3876 .collect();
3877 names.sort();
3878 assert_eq!(
3879 names,
3880 ["agent_id", "signature", "target", "timestamp", "value"]
3881 );
3882 let mut required: Vec<&str> = schema["required"]
3883 .as_array()
3884 .unwrap()
3885 .iter()
3886 .filter_map(|v| v.as_str())
3887 .collect();
3888 required.sort();
3889 assert_eq!(required, names);
3890 assert_eq!(schema["additionalProperties"], false);
3891 let rendered = schema.to_string();
3892 assert!(!rendered.contains("$ref"), "{rendered}");
3893
3894 let schema =
3895 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
3896 .unwrap();
3897 assert!(schema["properties"].get("agent_id").is_none());
3898 assert_eq!(schema["additionalProperties"], false);
3899 }
3900
3901 #[cfg(feature = "schemars")]
3904 #[test]
3905 fn operation_input_schemas_forbid_additional_properties() {
3906 for (name, schema) in [
3907 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
3908 ("SearchInput", schemars::schema_for!(SearchInput)),
3909 (
3910 "GetGovernanceLogInput",
3911 schemars::schema_for!(GetGovernanceLogInput),
3912 ),
3913 (
3914 "GetProposalsInput",
3915 schemars::schema_for!(GetProposalsInput),
3916 ),
3917 (
3918 "GetDashboardInput",
3919 schemars::schema_for!(GetDashboardInput),
3920 ),
3921 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
3922 (
3923 "CreatePostPayload",
3924 schemars::schema_for!(CreatePostPayload),
3925 ),
3926 (
3927 "UpdateProfilePayload",
3928 schemars::schema_for!(UpdateProfilePayload),
3929 ),
3930 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
3931 (
3932 "SearchGovernanceLogInput",
3933 schemars::schema_for!(SearchGovernanceLogInput),
3934 ),
3935 (
3936 "CommentRepliesQuery",
3937 schemars::schema_for!(CommentRepliesQuery),
3938 ),
3939 (
3940 "SignedRequest<GetMyModerationRecordInput>",
3941 schemars::schema_for!(
3942 SignedRequest<GetMyModerationRecordInput>
3943 ),
3944 ),
3945 (
3946 "SignedRequest<NoParams>",
3947 schemars::schema_for!(SignedRequest<NoParams>),
3948 ),
3949 (
3950 "SignedRequest<ReportMessageBody>",
3951 schemars::schema_for!(SignedRequest<ReportMessageBody>),
3952 ),
3953 (
3954 "GetDashboardRequest",
3955 schemars::schema_for!(GetDashboardRequest),
3956 ),
3957 (
3958 "DeleteContentInput",
3959 schemars::schema_for!(DeleteContentInput),
3960 ),
3961 ("TrashInput", schemars::schema_for!(TrashInput)),
3962 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
3963 ] {
3964 let schema = serde_json::to_value(&schema).unwrap();
3965 assert_eq!(
3966 schema["additionalProperties"], false,
3967 "{name}: {schema}"
3968 );
3969 }
3970 }
3971
3972 #[cfg(feature = "schemars")]
3975 #[test]
3976 fn update_profile_docs_state_the_enforced_limits() {
3977 let schema =
3978 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
3979 .unwrap();
3980 for (field, max) in [
3981 ("display_name", limits::profile::DISPLAY_NAME_MAX_CHARS),
3982 ("bio", limits::profile::BIO_MAX_CHARS),
3983 ("model_info", limits::profile::MODEL_INFO_MAX_CHARS),
3984 ] {
3985 assert_eq!(
3986 schema["properties"][field]["maxLength"], max,
3987 "{field}: {schema}"
3988 );
3989 }
3990 }
3991}
3992
3993#[cfg(test)]
3994mod entry_type_filter_tests {
3995 use super::GetGovernanceLogInput;
3996 use crate::enums::GovernanceLogEntryType;
3997
3998 #[test]
3999 fn a_filter_names_a_known_type_or_is_refused_by_name() {
4000 let ok: GetGovernanceLogInput = serde_json::from_value(
4001 serde_json::json!({"entry_type": "data_set"}),
4002 )
4003 .unwrap();
4004 assert_eq!(ok.entry_type, Some(GovernanceLogEntryType::DataSet));
4005 let none: GetGovernanceLogInput =
4006 serde_json::from_value(serde_json::json!({"entry_type": null}))
4007 .unwrap();
4008 assert_eq!(none.entry_type, None);
4009 let err = serde_json::from_value::<GetGovernanceLogInput>(
4010 serde_json::json!({"entry_type": "nope"}),
4011 )
4012 .unwrap_err()
4013 .to_string();
4014 assert!(err.contains("nope") && err.contains("data_set"), "{err}");
4015 }
4016}