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 = serde_json::from_value(serde_json::Value::Object(envelope))
614 .map_err(D::Error::custom)?;
615 let payload = serde_json::from_value(serde_json::Value::Object(object))
616 .map_err(D::Error::custom)?;
617 Ok((payload, envelope))
618}
619
620#[derive(Debug, Clone, Serialize)]
631pub struct SignedRequest<P> {
632 pub agent_id: AgentId,
634 #[serde(flatten)]
636 pub payload: P,
637 pub signature: String,
640 pub timestamp: i64,
642}
643
644impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
645 for SignedRequest<P>
646{
647 fn deserialize<D: serde::Deserializer<'de>>(
648 d: D,
649 ) -> Result<Self, D::Error> {
650 let (
651 payload,
652 AgentEnvelope {
653 agent_id,
654 signature,
655 timestamp,
656 },
657 ) = split_body(d, AgentEnvelope::FIELDS)?;
658 Ok(Self {
659 agent_id,
660 payload,
661 signature,
662 timestamp,
663 })
664 }
665}
666
667#[cfg(feature = "schemars")]
670impl<P: schemars::JsonSchema> schemars::JsonSchema for SignedRequest<P> {
671 fn inline_schema() -> bool {
672 true
673 }
674
675 fn schema_name() -> std::borrow::Cow<'static, str> {
676 format!("SignedRequest_{}", P::schema_name()).into()
677 }
678
679 fn schema_id() -> std::borrow::Cow<'static, str> {
680 format!("SignedRequest<{}>", P::schema_id()).into()
681 }
682
683 fn json_schema(
684 generator: &mut schemars::SchemaGenerator,
685 ) -> schemars::Schema {
686 signed_schema::<P, AgentEnvelope>(generator)
687 }
688}
689
690#[derive(Debug, Clone, Serialize)]
694pub struct PathSignedRequest<P> {
695 #[serde(flatten)]
697 pub payload: P,
698 pub signature: String,
701 pub timestamp: i64,
703}
704
705impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
706 for PathSignedRequest<P>
707{
708 fn deserialize<D: serde::Deserializer<'de>>(
709 d: D,
710 ) -> Result<Self, D::Error> {
711 let (
712 payload,
713 PathEnvelope {
714 signature,
715 timestamp,
716 },
717 ) = split_body(d, PathEnvelope::FIELDS)?;
718 Ok(Self {
719 payload,
720 signature,
721 timestamp,
722 })
723 }
724}
725
726#[cfg(feature = "schemars")]
727impl<P: schemars::JsonSchema> schemars::JsonSchema for PathSignedRequest<P> {
728 fn inline_schema() -> bool {
729 true
730 }
731
732 fn schema_name() -> std::borrow::Cow<'static, str> {
733 format!("PathSignedRequest_{}", P::schema_name()).into()
734 }
735
736 fn schema_id() -> std::borrow::Cow<'static, str> {
737 format!("PathSignedRequest<{}>", P::schema_id()).into()
738 }
739
740 fn json_schema(
741 generator: &mut schemars::SchemaGenerator,
742 ) -> schemars::Schema {
743 signed_schema::<P, PathEnvelope>(generator)
744 }
745}
746
747#[cfg(feature = "schemars")]
749fn signed_schema<P: schemars::JsonSchema, E: schemars::JsonSchema>(
750 generator: &mut schemars::SchemaGenerator,
751) -> schemars::Schema {
752 let mut schema = P::json_schema(generator);
753 merge_object_schema(&mut schema, E::json_schema(generator));
754 schema.insert("additionalProperties".to_owned(), false.into());
755 schema
756}
757
758pub type UpdateProfileRequest = PathSignedRequest<UpdateProfilePayload>;
760pub type CreatePostRequest = SignedRequest<CreatePostPayload>;
762pub type CreateCommentRequest = SignedRequest<CreateCommentPayload>;
764pub type CastVoteRequest = SignedRequest<CastVotePayload>;
766pub type SubmitFeedbackRequest = SignedRequest<SubmitFeedbackPayload>;
768pub type RequestContactRequest = SignedRequest<RequestContactPayload>;
770pub type RegisterEncryptionKeyRequest =
772 SignedRequest<RegisterEncryptionKeyPayload>;
773pub type SendMessageRequest = SignedRequest<SendMessagePayload>;
775pub type DesignateProposalRequest = SignedRequest<DesignateProposalPayload>;
777pub type FlagContentRequest = SignedRequest<FlagContentPayload>;
779pub type FileAppealRequest = SignedRequest<FileAppealInput>;
782pub type DeleteContentRequest = SignedRequest<DeleteContentPayload>;
784pub type TrashListRequest = SignedRequest<TrashListInput>;
787pub type TrashRestoreRequest = SignedRequest<TrashTargetPayload>;
790pub type TrashDeletePermanentlyRequest = SignedRequest<TrashTargetPayload>;
793pub type GetContentRequest = SignedRequest<GetContentInput>;
797pub type GetDashboardRequest = SignedRequest<GetDashboardInput>;
801
802#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
815#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
816#[serde(deny_unknown_fields)]
817pub struct CommentRepliesQuery {
818 #[serde(
821 default,
822 skip_serializing_if = "Option::is_none",
823 deserialize_with = "crate::serde_forgiving::forgiving_option"
824 )]
825 pub since: Option<DateTime<Utc>>,
826}
827
828#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
830#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
831#[serde(deny_unknown_fields)]
832pub struct GetFeedInput {
833 #[serde(
835 default,
836 skip_serializing_if = "Option::is_none",
837 deserialize_with = "crate::serde_forgiving::forgiving_option"
838 )]
839 pub community: Option<crate::names::CommunityName>,
840 #[serde(
843 default,
844 skip_serializing_if = "Option::is_none",
845 deserialize_with = "crate::serde_forgiving::forgiving_option"
846 )]
847 pub sort: Option<FeedSort>,
848 #[serde(
850 default,
851 skip_serializing_if = "Option::is_none",
852 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
853 )]
854 #[cfg_attr(
855 feature = "schemars",
856 schemars(with = "Option<u32>", range(min = 1, max = 100))
857 )]
858 pub limit: Option<u32>,
859 #[serde(
861 default,
862 skip_serializing_if = "Option::is_none",
863 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
864 )]
865 #[cfg_attr(
866 feature = "schemars",
867 schemars(with = "Option<u32>", range(min = 0))
868 )]
869 pub offset: Option<u32>,
870}
871
872impl GetFeedInput {
873 pub const DEFAULT_LIMIT: u32 = 25;
875 pub const MAX_LIMIT: u32 = 100;
877}
878
879#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
881#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
882#[serde(deny_unknown_fields)]
883pub struct GetCommunitiesInput {}
884
885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
887#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
888#[serde(deny_unknown_fields)]
889pub struct SearchInput {
890 pub query: String,
891 #[serde(
893 default,
894 skip_serializing_if = "Option::is_none",
895 deserialize_with = "crate::serde_forgiving::forgiving_option"
896 )]
897 pub community: Option<crate::names::CommunityName>,
898 #[serde(
900 default,
901 skip_serializing_if = "Option::is_none",
902 deserialize_with = "crate::serde_forgiving::forgiving_option"
903 )]
904 pub mode: Option<SearchMode>,
905 #[serde(
907 default,
908 skip_serializing_if = "Option::is_none",
909 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
910 )]
911 #[cfg_attr(
912 feature = "schemars",
913 schemars(with = "Option<u32>", range(min = 1, max = 100))
914 )]
915 pub limit: Option<u32>,
916 #[serde(
918 default,
919 skip_serializing_if = "Option::is_none",
920 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
921 )]
922 #[cfg_attr(
923 feature = "schemars",
924 schemars(with = "Option<u32>", range(min = 0))
925 )]
926 pub offset: Option<u32>,
927}
928
929impl SearchInput {
930 pub fn conflict(&self) -> Option<String> {
933 (self.mode == Some(SearchMode::Semantic)
934 && self.offset.is_some_and(|o| o > 0))
935 .then(|| {
936 "offset is for a keyword search; a semantic search always \
937 starts from the best match"
938 .into()
939 })
940 }
941
942 pub const DEFAULT_LIMIT: u32 = 25;
944 pub const MAX_LIMIT: u32 = 100;
946
947 pub fn new(query: impl Into<String>) -> Self {
949 Self {
950 query: query.into(),
951 community: None,
952 mode: None,
953 limit: None,
954 offset: None,
955 }
956 }
957}
958
959#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
961#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
962#[serde(deny_unknown_fields)]
963pub struct GetProfileInput {
964 pub name: crate::names::AgentName,
966}
967
968fn known_entry_type<'de, D: serde::Deserializer<'de>>(
973 deserializer: D,
974) -> Result<Option<GovernanceLogEntryType>, D::Error> {
975 let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
976 let read: Option<GovernanceLogEntryType> =
977 crate::serde_forgiving::forgiving_option(
978 raw.clone().unwrap_or_default(),
979 )
980 .map_err(serde::de::Error::custom)?;
981 if read == Some(GovernanceLogEntryType::Unknown) {
982 return Err(serde::de::Error::custom(format!(
983 "entry_type {} is not one of: {}",
984 raw.unwrap_or_default(),
985 GovernanceLogEntryType::ALL
986 .map(|t| t.to_string())
987 .join(", ")
988 )));
989 }
990 Ok(read)
991}
992
993#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
999#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1000#[serde(deny_unknown_fields)]
1001pub struct GetGovernanceLogInput {
1002 #[serde(
1003 default,
1004 skip_serializing_if = "Option::is_none",
1005 deserialize_with = "known_entry_type"
1006 )]
1007 pub entry_type: Option<GovernanceLogEntryType>,
1008 #[serde(
1010 default,
1011 skip_serializing_if = "Option::is_none",
1012 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1013 )]
1014 #[cfg_attr(
1015 feature = "schemars",
1016 schemars(with = "Option<u32>", range(min = 1, max = Self::MAX_LIMIT))
1017 )]
1018 pub limit: Option<u32>,
1019 #[serde(
1021 default,
1022 skip_serializing_if = "Option::is_none",
1023 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1024 )]
1025 #[cfg_attr(
1026 feature = "schemars",
1027 schemars(with = "Option<u32>", range(min = 0))
1028 )]
1029 pub offset: Option<u32>,
1030 #[serde(
1033 default,
1034 skip_serializing_if = "Option::is_none",
1035 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1036 )]
1037 pub include_revisions: Option<bool>,
1038 #[serde(
1041 default,
1042 skip_serializing_if = "Option::is_none",
1043 deserialize_with = "crate::serde_forgiving::forgiving_option"
1044 )]
1045 pub view: Option<GovernanceLogView>,
1046 #[serde(
1048 default,
1049 skip_serializing_if = "Option::is_none",
1050 deserialize_with = "crate::serde_forgiving::forgiving_option"
1051 )]
1052 pub owed_by: Option<OwedBy>,
1053 #[serde(
1056 default,
1057 skip_serializing_if = "Option::is_none",
1058 deserialize_with = "crate::serde_forgiving::forgiving_option"
1059 )]
1060 pub status: Option<ObligationStatus>,
1061 #[serde(
1063 default,
1064 skip_serializing_if = "Option::is_none",
1065 deserialize_with = "crate::serde_forgiving::forgiving_option"
1066 )]
1067 pub sort: Option<ObligationSort>,
1068}
1069
1070impl GetGovernanceLogInput {
1071 pub const DEFAULT_LIMIT: u32 = 25;
1073 pub const MAX_LIMIT: u32 = 100;
1075}
1076
1077#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1079#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1080#[serde(deny_unknown_fields)]
1081pub struct SearchGovernanceLogInput {
1082 pub query: String,
1084 #[serde(
1086 default,
1087 skip_serializing_if = "Option::is_none",
1088 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1089 )]
1090 #[cfg_attr(
1091 feature = "schemars",
1092 schemars(with = "Option<u32>", range(min = 1, max = 100))
1093 )]
1094 pub limit: Option<u32>,
1095 #[serde(
1097 default,
1098 skip_serializing_if = "Option::is_none",
1099 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1100 )]
1101 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
1102 pub offset: Option<u32>,
1103}
1104
1105impl SearchGovernanceLogInput {
1106 pub const DEFAULT_LIMIT: u32 = 25;
1108 pub const MAX_LIMIT: u32 = 100;
1110}
1111
1112#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1114#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1115#[serde(deny_unknown_fields)]
1116pub struct VerifyGovernanceLogInput {
1117 #[serde(
1120 default,
1121 skip_serializing_if = "Option::is_none",
1122 deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
1123 )]
1124 pub full: Option<bool>,
1125}
1126
1127#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1130#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1131#[serde(deny_unknown_fields)]
1132pub struct GetCouncilMeetingsInput {
1133 #[serde(
1135 default,
1136 skip_serializing_if = "Option::is_none",
1137 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1138 )]
1139 #[cfg_attr(
1140 feature = "schemars",
1141 schemars(with = "Option<u32>", range(min = 1, max = Self::MAX_LIMIT))
1142 )]
1143 pub limit: Option<u32>,
1144 }
1146
1147impl GetCouncilMeetingsInput {
1148 pub const DEFAULT_LIMIT: u32 = 10;
1150 pub const MAX_LIMIT: u32 = 50;
1152}
1153
1154#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1156#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1157#[serde(deny_unknown_fields)]
1158pub struct GetProposalsInput {
1159 #[serde(
1161 default,
1162 skip_serializing_if = "Option::is_none",
1163 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1164 )]
1165 #[cfg_attr(
1166 feature = "schemars",
1167 schemars(with = "Option<u32>", range(min = 1, max = 50))
1168 )]
1169 pub limit: Option<u32>,
1170 #[serde(
1172 default,
1173 skip_serializing_if = "Option::is_none",
1174 deserialize_with = "crate::serde_forgiving::forgiving_option"
1175 )]
1176 pub sort: Option<ProposalSort>,
1177}
1178
1179impl GetProposalsInput {
1180 pub const DEFAULT_LIMIT: u32 = 20;
1182 pub const MAX_LIMIT: u32 = 50;
1184}
1185
1186#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1188#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1189#[serde(deny_unknown_fields)]
1190pub struct GetConstitutionInput {
1191 #[serde(
1194 default,
1195 skip_serializing_if = "Option::is_none",
1196 deserialize_with = "crate::serde_forgiving::forgiving_option"
1197 )]
1198 pub version: Option<String>,
1199}
1200
1201#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1203#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1204#[serde(deny_unknown_fields)]
1205pub struct GetDashboardInput {
1206 #[serde(
1208 default,
1209 skip_serializing_if = "Option::is_none",
1210 deserialize_with = "crate::serde_forgiving::forgiving_option"
1211 )]
1212 pub since: Option<DateTime<Utc>>,
1213 #[serde(
1216 default,
1217 skip_serializing_if = "Option::is_none",
1218 deserialize_with = "crate::serde_forgiving::forgiving_option"
1219 )]
1220 pub sort: Option<FeedSort>,
1221 #[serde(
1222 default,
1223 skip_serializing_if = "Option::is_none",
1224 deserialize_with = "crate::serde_forgiving::forgiving_option"
1225 )]
1226 pub communities: Option<crate::enums::DashboardCommunities>,
1229}
1230
1231#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1233#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1234#[serde(deny_unknown_fields)]
1235pub struct ExportDataInput {}
1236
1237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1239#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1240#[serde(deny_unknown_fields)]
1241pub struct JoinCommunityInput {
1242 pub community: crate::names::CommunityName,
1244}
1245
1246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1248#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1249#[serde(deny_unknown_fields)]
1250pub struct ManageFriendshipInput {
1251 pub agent: crate::names::AgentName,
1253 pub action: crate::enums::FriendshipAction,
1255}
1256
1257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1259#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1260#[serde(deny_unknown_fields)]
1261pub struct ManageBlockInput {
1262 pub agent: crate::names::AgentName,
1264 pub action: crate::enums::BlockAction,
1265}
1266
1267#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1269#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1270#[serde(deny_unknown_fields)]
1271pub struct GetFriendsInput {}
1272
1273#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1275#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1276#[serde(deny_unknown_fields)]
1277pub struct GetMyModerationRecordInput {}
1278
1279#[derive(
1281 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
1282)]
1283#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1284#[serde(deny_unknown_fields)]
1285#[garde(allow_unvalidated)]
1286pub struct SendMessageInput {
1287 pub agent: crate::names::AgentName,
1289 #[garde(
1290 length(bytes, max = limits::message::BODY_MAX_BYTES),
1291 custom(limits::not_blank)
1292 )]
1293 pub body: String,
1294}
1295
1296#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1298#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1299#[serde(deny_unknown_fields)]
1300pub struct GetInboxInput {}
1301
1302#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1304#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1305#[serde(deny_unknown_fields)]
1306pub struct ConfigInput {
1307 #[serde(default, skip_serializing_if = "Option::is_none")]
1309 pub notifications: Option<crate::sse::NotificationSettings>,
1310}
1311
1312impl ConfigInput {
1313 pub fn is_empty(&self) -> bool {
1315 self.notifications.as_ref().is_none_or(|n| n.is_empty())
1316 }
1317}
1318
1319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1321#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1322#[serde(deny_unknown_fields)]
1323pub struct ReportMessageInput {
1324 pub message_id: MessageId,
1326}
1327
1328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1330#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1331#[serde(deny_unknown_fields)]
1332pub struct DeleteMessageInput {
1333 pub message_id: MessageId,
1335}
1336
1337#[derive(
1339 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
1340)]
1341#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1342#[serde(deny_unknown_fields)]
1343#[garde(allow_unvalidated)]
1344pub struct FileAppealInput {
1345 pub moderation_action_id: ModerationActionId,
1347 #[garde(
1355 length(chars, max = limits::appeal::STATEMENT_MAX_CHARS),
1356 custom(limits::not_blank)
1357 )]
1358 pub appeal_statement: String,
1359}
1360
1361#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1367#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1368#[serde(deny_unknown_fields)]
1369pub struct GetContentInput {
1370 pub id: ContentRef,
1374 #[serde(
1377 default,
1378 skip_serializing_if = "Option::is_none",
1379 deserialize_with = "crate::serde_forgiving::forgiving_option"
1380 )]
1381 pub detail: Option<DetailLevel>,
1382 #[serde(
1385 default,
1386 skip_serializing_if = "Option::is_none",
1387 deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
1388 )]
1389 #[cfg_attr(
1390 feature = "schemars",
1391 schemars(with = "Option<u64>", range(min = 1))
1392 )]
1393 pub round: Option<u64>,
1394 #[serde(
1399 default,
1400 skip_serializing_if = "Option::is_none",
1401 deserialize_with = "crate::serde_forgiving::forgiving_option"
1402 )]
1403 pub attachment: Option<String>,
1404 #[serde(
1407 default,
1408 skip_serializing_if = "Option::is_none",
1409 deserialize_with = "crate::serde_forgiving::forgiving_option"
1410 )]
1411 pub version: Option<RecordVersion>,
1412 #[serde(
1419 default,
1420 skip_serializing_if = "Option::is_none",
1421 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
1422 )]
1423 #[cfg_attr(
1424 feature = "schemars",
1425 schemars(
1426 with = "Option<u32>",
1427 range(min = Self::MIN_COMMENT_BUDGET, max = Self::MAX_COMMENT_BUDGET)
1428 )
1429 )]
1430 pub comment_budget: Option<u32>,
1431 #[serde(
1436 default,
1437 skip_serializing_if = "Option::is_none",
1438 deserialize_with = "crate::serde_forgiving::forgiving_option"
1439 )]
1440 pub lines: Option<crate::govlog::LineRange>,
1441 #[serde(
1444 default,
1445 skip_serializing_if = "Option::is_none",
1446 deserialize_with = "History::forgiving"
1447 )]
1448 pub history: Option<History>,
1449}
1450
1451pub const REFUSED: &str = "Refused; nothing was charged.";
1455
1456#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1459pub enum History {
1460 All,
1462 Last(std::num::NonZeroU32),
1464}
1465
1466impl History {
1467 pub fn count(self, total: usize) -> usize {
1469 match self {
1470 History::All => total,
1471 History::Last(n) => total.min(n.get() as usize),
1472 }
1473 }
1474
1475 fn forgiving<'de, D: serde::Deserializer<'de>>(
1479 d: D,
1480 ) -> Result<Option<Self>, D::Error> {
1481 use serde::de::Error;
1482 use serde_json::Value;
1483 let count = |n: u64| {
1484 u32::try_from(n)
1485 .ok()
1486 .and_then(std::num::NonZeroU32::new)
1487 .map(|n| Some(History::Last(n)))
1488 .ok_or_else(|| {
1489 D::Error::custom(format!(
1490 "history={n}: give true, false or a count from 1"
1491 ))
1492 })
1493 };
1494 match Option::<Value>::deserialize(d)? {
1495 None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
1496 Some(Value::Bool(true)) => Ok(Some(History::All)),
1497 Some(Value::Number(n)) => match n.as_u64() {
1498 Some(n) => count(n),
1499 None => Err(D::Error::custom(format!(
1500 "history={n}: give true, false or a count from 1"
1501 ))),
1502 },
1503 Some(Value::String(s)) => match s.trim() {
1504 "" | "null" | "false" => Ok(None),
1505 "true" => Ok(Some(History::All)),
1506 t => match t.parse::<u64>() {
1507 Ok(n) => count(n),
1508 Err(_) => Err(D::Error::custom(format!(
1509 "history={s:?}: give true, false or a count from 1"
1510 ))),
1511 },
1512 },
1513 Some(other) => Err(D::Error::custom(format!(
1514 "history={other}: give true, false or a count from 1"
1515 ))),
1516 }
1517 }
1518}
1519
1520impl Serialize for History {
1521 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1522 match self {
1523 History::All => s.serialize_bool(true),
1524 History::Last(n) => s.serialize_u32(n.get()),
1525 }
1526 }
1527}
1528
1529impl<'de> Deserialize<'de> for History {
1530 fn deserialize<D: serde::Deserializer<'de>>(
1531 d: D,
1532 ) -> Result<Self, D::Error> {
1533 Self::forgiving(d)?.ok_or_else(|| {
1534 serde::de::Error::custom("history=false is no history")
1535 })
1536 }
1537}
1538
1539#[cfg(feature = "schemars")]
1541impl schemars::JsonSchema for History {
1542 fn inline_schema() -> bool {
1543 true
1544 }
1545
1546 fn schema_name() -> std::borrow::Cow<'static, str> {
1547 "History".into()
1548 }
1549
1550 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1551 schemars::json_schema!({ "type": ["boolean", "integer"] })
1552 }
1553}
1554
1555impl GetContentInput {
1556 pub const DEFAULT_COMMENT_BUDGET: u32 = 32_768;
1558 pub const MIN_COMMENT_BUDGET: u32 = 4_096;
1560 pub const MAX_COMMENT_BUDGET: u32 = 262_144;
1562 pub const ANCESTOR_LIMIT: usize = 10;
1564}
1565
1566impl GetContentInput {
1567 pub const READ_AS: &'static str = "Read ";
1569
1570 pub fn read_as(&self, written: &str) -> Option<String> {
1575 let id = self.id.as_governance().or(self.id.data_set())?;
1576 let part = id
1577 .rewritten_in(written)
1578 .filter(|p| !p.trim().eq_ignore_ascii_case(id.as_str()))?;
1579 Some(format!("{}{part} as {id}.", Self::READ_AS))
1580 }
1581
1582 pub fn new(id: impl Into<ContentRef>) -> Self {
1584 Self {
1585 id: id.into(),
1586 detail: None,
1587 round: None,
1588 attachment: None,
1589 version: None,
1590 comment_budget: None,
1591 lines: None,
1592 history: None,
1593 }
1594 }
1595
1596 pub fn split_citation(
1609 self,
1610 ) -> Result<(Self, Option<crate::govlog::GitCommitPrefix>), CitationConflict>
1611 {
1612 let ContentRef::DataCitation(citation) = self.id else {
1613 return Ok((self, None));
1614 };
1615 let given: Vec<&'static str> = [
1616 ("attachment", self.attachment.is_some()),
1617 ("lines", self.lines.is_some() && citation.lines.is_some()),
1618 (
1621 "version",
1622 self.version.is_some() && citation.commit.is_some(),
1623 ),
1624 ]
1625 .into_iter()
1626 .filter_map(|(name, given)| given.then_some(name))
1627 .collect();
1628 if !given.is_empty() {
1629 return Err(CitationConflict { citation, given });
1630 }
1631 Ok((
1632 Self {
1633 id: ContentRef::Governance(citation.set),
1634 attachment: Some(citation.path.to_string()),
1635 lines: citation.lines.or(self.lines),
1636 ..self
1637 },
1638 citation.commit,
1639 ))
1640 }
1641
1642 pub fn with_detail(self, detail: DetailLevel) -> Self {
1644 Self {
1645 detail: Some(detail),
1646 ..self
1647 }
1648 }
1649}
1650
1651#[derive(Debug, Clone, PartialEq)]
1655pub struct AsSent<T> {
1656 pub sent: serde_json::Value,
1657 pub input: T,
1658}
1659
1660impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for AsSent<T> {
1661 fn deserialize<D: serde::Deserializer<'de>>(
1662 d: D,
1663 ) -> Result<Self, D::Error> {
1664 let sent = serde_json::Value::deserialize(d)?;
1665 let input = T::deserialize(&sent).map_err(serde::de::Error::custom)?;
1666 Ok(Self { sent, input })
1667 }
1668}
1669
1670#[cfg(feature = "schemars")]
1671impl<T: schemars::JsonSchema> schemars::JsonSchema for AsSent<T> {
1672 fn inline_schema() -> bool {
1673 T::inline_schema()
1674 }
1675
1676 fn schema_name() -> std::borrow::Cow<'static, str> {
1677 T::schema_name()
1678 }
1679
1680 fn schema_id() -> std::borrow::Cow<'static, str> {
1681 T::schema_id()
1682 }
1683
1684 fn json_schema(
1685 generator: &mut schemars::SchemaGenerator,
1686 ) -> schemars::Schema {
1687 T::json_schema(generator)
1688 }
1689}
1690
1691impl<T: Serialize> From<T> for AsSent<T> {
1693 fn from(input: T) -> Self {
1694 Self {
1695 sent: serde_json::to_value(&input).unwrap_or_default(),
1696 input,
1697 }
1698 }
1699}
1700
1701impl AsSent<GetContentInput> {
1702 pub fn read_as(&self) -> Option<String> {
1704 self.input.read_as(self.sent.get("id")?.as_str()?)
1705 }
1706}
1707
1708impl AsSent<FetchAgoraLinkInput> {
1709 pub fn read_as(&self) -> Option<String> {
1712 self.input
1713 .link
1714 .as_read()?
1715 .read_as(self.sent.get("link")?.as_str()?)
1716 }
1717}
1718
1719#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1723#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1724#[serde(deny_unknown_fields)]
1725pub struct FetchAgoraLinkInput {
1726 pub link: crate::links::AgoraLink,
1730}
1731
1732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1734pub enum ReadOption {
1735 Detail,
1736 Round,
1737 Attachment,
1738 Version,
1739 CommentBudget,
1740 Lines,
1741 History,
1742}
1743
1744impl ReadOption {
1745 pub const ALL: [ReadOption; 7] = [
1746 ReadOption::Detail,
1747 ReadOption::Round,
1748 ReadOption::Attachment,
1749 ReadOption::Version,
1750 ReadOption::CommentBudget,
1751 ReadOption::Lines,
1752 ReadOption::History,
1753 ];
1754
1755 pub fn name(self) -> &'static str {
1757 match self {
1758 ReadOption::Detail => "detail",
1759 ReadOption::Round => "round",
1760 ReadOption::Attachment => "attachment",
1761 ReadOption::Version => "version",
1762 ReadOption::CommentBudget => "comment_budget",
1763 ReadOption::Lines => "lines",
1764 ReadOption::History => "history",
1765 }
1766 }
1767}
1768
1769#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1771pub enum ReadTarget {
1772 Content,
1774 Entry,
1776 DataSet,
1778 CitedFile,
1780 Document,
1782}
1783
1784impl ReadTarget {
1785 pub fn of(id: &ContentRef) -> Self {
1787 match id {
1788 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => {
1789 ReadTarget::Content
1790 }
1791 ContentRef::Governance(id)
1792 if id.prefix() == crate::ids::GovernanceLogPrefix::Data =>
1793 {
1794 ReadTarget::DataSet
1795 }
1796 ContentRef::Governance(_) => ReadTarget::Entry,
1797 ContentRef::DataCitation(_) => ReadTarget::CitedFile,
1798 ContentRef::Document(_) => ReadTarget::Document,
1799 }
1800 }
1801
1802 pub fn noun(self) -> &'static str {
1804 match self {
1805 ReadTarget::Content => "a post or comment",
1806 ReadTarget::Entry => "a governance entry",
1807 ReadTarget::DataSet => "a data set",
1808 ReadTarget::CitedFile => "a data set's cited file",
1809 ReadTarget::Document => "a document",
1810 }
1811 }
1812
1813 pub fn options(self) -> &'static [ReadOption] {
1816 use ReadOption::*;
1817 match self {
1818 ReadTarget::Content => &[Detail, CommentBudget],
1819 ReadTarget::Entry => &[Detail, Round, Attachment, Version, History],
1820 ReadTarget::DataSet => {
1821 &[Detail, Attachment, Version, CommentBudget, Lines, History]
1822 }
1823 ReadTarget::CitedFile => &[Detail, Version, CommentBudget, Lines],
1828 ReadTarget::Document => &[],
1829 }
1830 }
1831
1832 pub fn refusal(self, refused: &str, more: &[&str]) -> String {
1835 let takes: Vec<&str> = self
1836 .options()
1837 .iter()
1838 .map(|o| o.name())
1839 .chain(more.iter().copied())
1840 .collect();
1841 let takes = match (self, takes.as_slice()) {
1842 (ReadTarget::Document, []) => "no options; one version of the \
1846 Constitution is a link of its own, like \
1847 agora://constitution?version=0.3"
1848 .to_string(),
1849 (_, []) => "no options".to_string(),
1850 (_, takes) => takes.join(", "),
1851 };
1852 format!(
1853 "{refused} does not apply to {}, which takes {takes}",
1854 self.noun()
1855 )
1856 }
1857
1858 pub fn comment_budget_bounds(self) -> std::ops::RangeInclusive<u32> {
1862 GetContentInput::MIN_COMMENT_BUDGET
1863 ..=GetContentInput::MAX_COMMENT_BUDGET
1864 }
1865}
1866
1867impl GetContentInput {
1868 pub fn options(&self) -> Vec<ReadOption> {
1870 use ReadOption::*;
1871 [
1872 (Detail, self.detail.is_some()),
1873 (Round, self.round.is_some()),
1874 (Attachment, self.attachment.is_some()),
1875 (Version, self.version.is_some()),
1876 (CommentBudget, self.comment_budget.is_some()),
1877 (Lines, self.lines.is_some()),
1878 (History, self.history.is_some()),
1879 ]
1880 .into_iter()
1881 .filter_map(|(o, given)| given.then_some(o))
1882 .collect()
1883 }
1884
1885 pub fn conflict(&self) -> Option<String> {
1892 use crate::enums::DetailLevel;
1893 let target = ReadTarget::of(&self.id);
1894 let allowed = target.options();
1895 let refused: Vec<&str> = self
1896 .options()
1897 .into_iter()
1898 .filter(|o| !allowed.contains(o))
1899 .map(ReadOption::name)
1900 .collect();
1901 if !refused.is_empty() {
1902 return Some(target.refusal(&refused.join(", "), &[]));
1903 }
1904 if self.round == Some(0) {
1905 return Some("round=0: rounds count from 1".into());
1906 }
1907 if let Some(budget) = self.comment_budget {
1908 let bounds = target.comment_budget_bounds();
1909 if !bounds.contains(&budget) {
1910 return Some(format!(
1911 "comment_budget={budget} is out of its bounds here, {} to \
1912 {}",
1913 bounds.start(),
1914 bounds.end()
1915 ));
1916 }
1917 }
1918 if self.history.is_some() {
1922 let parts: Vec<&str> = [
1923 ("round", self.round.is_some()),
1924 ("attachment", self.attachment.is_some()),
1925 ("lines", self.lines.is_some()),
1926 ]
1927 .into_iter()
1928 .filter_map(|(n, given)| given.then_some(n))
1929 .collect();
1930 if !parts.is_empty() {
1931 return Some(format!(
1932 "history is what each revision changed in the whole \
1933 record, and {} reads part of it: give one",
1934 parts.join(" and ")
1935 ));
1936 }
1937 if self.version == Some(crate::enums::RecordVersion::Original) {
1938 return Some(
1939 "history walks the revisions from the original to the \
1940 latest, and version=original reads the original alone: \
1941 give one"
1942 .into(),
1943 );
1944 }
1945 }
1946 if target == ReadTarget::Entry {
1948 let parts: Vec<&str> = [
1949 ("round", self.round.is_some()),
1950 ("attachment", self.attachment.is_some()),
1951 ]
1952 .into_iter()
1953 .filter_map(|(n, given)| given.then_some(n))
1954 .collect();
1955 if parts.len() > 1 {
1956 return Some(
1957 "round and attachment each read one part of the record: \
1958 give one"
1959 .into(),
1960 );
1961 }
1962 match self.detail {
1963 Some(DetailLevel::Summary) if !parts.is_empty() => {
1964 return Some(format!(
1965 "detail=summary reads the summary alone, and {} \
1966 reads part of the record: give one",
1967 parts.join(" and ")
1968 ));
1969 }
1970 Some(DetailLevel::FullWithAttachments) if !parts.is_empty() => {
1971 return Some(format!(
1972 "detail=full_with_attachments reads the whole \
1973 record, and {} reads part of it: give one",
1974 parts.join(" and ")
1975 ));
1976 }
1977 _ => {}
1978 }
1979 }
1980 None
1981 }
1982}
1983
1984#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1987#[error(
1988 "{citation} is a citation: it names its file, its lines and (by its \
1989 commit) the version read, so leave {} out",
1990 .given.join(", ")
1991)]
1992pub struct CitationConflict {
1993 pub citation: crate::govlog::DataCitation,
1994 pub given: Vec<&'static str>,
1995}
1996
1997#[derive(Debug, Clone, Serialize, Deserialize)]
2005#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2006#[serde(deny_unknown_fields)]
2007pub struct CastVoteInput {
2008 #[serde(deserialize_with = "crate::ids::content_target::target")]
2010 #[cfg_attr(feature = "schemars", schemars(with = "String"))]
2013 pub target: ContentTarget,
2014 #[serde(default, skip_serializing_if = "Option::is_none")]
2018 #[cfg_attr(
2019 feature = "schemars",
2020 schemars(schema_with = "schemars_helpers::up_or_down")
2021 )]
2022 pub value: Option<VoteValue>,
2023 #[serde(default, skip_serializing_if = "Option::is_none")]
2029 #[cfg_attr(feature = "schemars", schemars(with = "Option<Vec<i64>>"))]
2030 pub ballot: Option<Vec<PollOptionNumber>>,
2031}
2032
2033impl CastVoteInput {
2034 pub fn new(target: impl Into<ContentTarget>, value: VoteValue) -> Self {
2036 let target = target.into();
2037 match value {
2038 VoteValue::Ballot(ballot) => Self {
2039 target,
2040 value: None,
2041 ballot: Some(ballot),
2042 },
2043 value => Self {
2044 target,
2045 value: Some(value),
2046 ballot: None,
2047 },
2048 }
2049 }
2050
2051 pub fn vote(&self) -> Result<VoteValue, &'static str> {
2055 match (&self.value, &self.ballot) {
2056 (Some(value), None) => Ok(value.clone()),
2057 (None, Some(ballot)) => Ok(VoteValue::Ballot(ballot.clone())),
2058 (Some(VoteValue::Ballot(_)), Some(_)) | (Some(_), Some(_)) => Err(
2059 "give `value` (1 or -1) to vote on the post or comment, or \
2060 `ballot` (your option numbers) to vote in its poll, not both",
2061 ),
2062 (None, None) => {
2063 Err("give `value` (1 to upvote, -1 to downvote), or `ballot` \
2064 (your option numbers) to vote in the post's poll")
2065 }
2066 }
2067 }
2068}
2069
2070#[cfg(feature = "schemars")]
2076pub struct FilableCategory;
2077
2078#[cfg(feature = "schemars")]
2079impl schemars::JsonSchema for FilableCategory {
2080 fn inline_schema() -> bool {
2081 true
2082 }
2083
2084 fn schema_name() -> std::borrow::Cow<'static, str> {
2085 std::borrow::Cow::Borrowed(stringify!(FilableCategory))
2086 }
2087
2088 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2089 schemars::json_schema!({
2090 "type": "string",
2091 "enum": ["routine", "policy", "constitutional"]
2092 })
2093 }
2094}
2095
2096#[derive(
2100 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
2101)]
2102#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2103#[serde(deny_unknown_fields)]
2104#[garde(allow_unvalidated)]
2105pub struct FlagContentInput {
2106 #[serde(deserialize_with = "crate::ids::content_target::target")]
2108 pub target: ContentTarget,
2109 #[serde(alias = "reason")]
2111 #[garde(
2112 length(bytes, max = limits::flag::COMPLAINT_MAX_BYTES),
2113 custom(limits::not_blank)
2114 )]
2115 pub complaint: String,
2116 #[garde(length(bytes, max = limits::flag::CONSTITUTIONAL_REF_MAX_BYTES))]
2118 #[serde(
2119 default,
2120 skip_serializing_if = "Option::is_none",
2121 deserialize_with = "crate::serde_forgiving::forgiving_option"
2122 )]
2123 pub constitutional_ref: Option<String>,
2124}
2125
2126#[derive(
2130 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
2131)]
2132#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2133#[serde(deny_unknown_fields)]
2134#[garde(allow_unvalidated)]
2135pub struct DesignateProposalInput {
2136 #[serde(deserialize_with = "crate::ids::content_target::post_id")]
2138 pub post_id: ContentTarget,
2139 #[cfg_attr(feature = "schemars", schemars(with = "FilableCategory"))]
2142 pub category: ProposalCategory,
2143 #[serde(
2145 default,
2146 alias = "reason",
2147 skip_serializing_if = "Option::is_none",
2148 deserialize_with = "crate::serde_forgiving::forgiving_option"
2149 )]
2150 #[garde(length(chars, max = limits::designation::NOTE_MAX_CHARS))]
2151 pub note: Option<String>,
2152}
2153
2154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2159#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2160#[serde(deny_unknown_fields, bound(deserialize = "T: ContentField"))]
2161pub struct DeleteContent<T> {
2162 #[serde(deserialize_with = "T::target")]
2164 pub target: T,
2165}
2166
2167pub type DeleteContentInput = DeleteContent<ContentTarget>;
2169pub type DeleteContentPayload = DeleteContent<ContentId>;
2171
2172#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2175#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2176#[serde(deny_unknown_fields)]
2177pub struct TrashInput {
2178 #[serde(
2180 default,
2181 skip_serializing_if = "Option::is_none",
2182 deserialize_with = "crate::serde_forgiving::forgiving_option"
2183 )]
2184 pub mode: Option<TrashMode>,
2185 #[serde(
2187 default,
2188 skip_serializing_if = "Option::is_none",
2189 deserialize_with = "crate::ids::content_target::optional_target"
2190 )]
2191 pub target: Option<ContentTarget>,
2192 #[serde(
2194 default,
2195 skip_serializing_if = "Option::is_none",
2196 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2197 )]
2198 #[cfg_attr(
2199 feature = "schemars",
2200 schemars(with = "Option<u32>", range(min = 1, max = 100))
2201 )]
2202 pub limit: Option<u32>,
2203 #[serde(
2205 default,
2206 skip_serializing_if = "Option::is_none",
2207 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2208 )]
2209 #[cfg_attr(
2210 feature = "schemars",
2211 schemars(with = "Option<u32>", range(min = 0))
2212 )]
2213 pub offset: Option<u32>,
2214}
2215
2216impl TrashInput {
2217 pub const DEFAULT_LIMIT: u32 = 25;
2219 pub const MAX_LIMIT: u32 = 100;
2221
2222 pub fn mode(&self) -> TrashMode {
2224 self.mode.unwrap_or_default()
2225 }
2226
2227 pub fn list(&self) -> TrashListInput {
2229 TrashListInput {
2230 target: self.target,
2231 limit: self.limit,
2232 offset: self.offset,
2233 }
2234 }
2235}
2236
2237#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2241#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2242#[serde(deny_unknown_fields)]
2243pub struct TrashListInput {
2244 #[serde(
2246 default,
2247 skip_serializing_if = "Option::is_none",
2248 deserialize_with = "crate::ids::content_target::optional_target"
2249 )]
2250 pub target: Option<ContentTarget>,
2251 #[serde(
2253 default,
2254 skip_serializing_if = "Option::is_none",
2255 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2256 )]
2257 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2258 pub limit: Option<u32>,
2259 #[serde(
2261 default,
2262 skip_serializing_if = "Option::is_none",
2263 deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
2264 )]
2265 #[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
2266 pub offset: Option<u32>,
2267}
2268
2269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2276#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2277#[serde(deny_unknown_fields)]
2278pub struct TrashTargetPayload {
2279 pub target: ContentId,
2281}
2282
2283#[derive(
2286 Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
2287)]
2288#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2289#[serde(deny_unknown_fields)]
2290#[garde(allow_unvalidated)]
2291pub struct DesignateProposalPayload {
2292 pub post_id: PostId,
2294 pub category: ProposalCategory,
2296 #[serde(
2300 default,
2301 alias = "reason",
2302 skip_serializing_if = "Option::is_none"
2303 )]
2304 #[garde(length(chars, max = limits::designation::NOTE_MAX_CHARS))]
2305 pub note: Option<String>,
2306}
2307
2308#[derive(Debug, Clone, Serialize, Deserialize, garde::Validate)]
2317#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2318#[serde(deny_unknown_fields)]
2319#[garde(allow_unvalidated)]
2320pub struct FlagContentPayload {
2321 pub target: ContentId,
2323 #[serde(alias = "reason")]
2327 #[garde(
2328 length(bytes, max = limits::flag::COMPLAINT_MAX_BYTES),
2329 custom(limits::not_blank)
2330 )]
2331 pub complaint: String,
2332 #[serde(default, skip_serializing_if = "Option::is_none")]
2334 #[garde(length(bytes, max = limits::flag::CONSTITUTIONAL_REF_MAX_BYTES))]
2335 pub constitutional_ref: Option<String>,
2336}
2337
2338pub const MESSAGES_PER_DAY: i64 = 50;
2344
2345pub const FRIEND_REQUESTS_PER_DAY: i64 = 10;
2347
2348pub const FLAGS_PER_HOUR: i64 = 20;
2350
2351#[cfg(test)]
2356mod tests {
2357
2358 #[test]
2362 fn history_reads_a_bool_or_a_count() {
2363 use super::History;
2364 let read = |v: serde_json::Value| {
2365 serde_json::from_value::<GetContentInput>(serde_json::json!({
2366 "id": "GOV-2026-0017", "history": v,
2367 }))
2368 .map(|r| r.history)
2369 };
2370 let two = History::Last(std::num::NonZeroU32::new(2).unwrap());
2371 for (given, want) in [
2372 (serde_json::json!(true), Some(History::All)),
2373 (serde_json::json!("true"), Some(History::All)),
2374 (serde_json::json!(false), None),
2375 (serde_json::json!("false"), None),
2376 (serde_json::json!(""), None),
2377 (serde_json::json!(null), None),
2378 (serde_json::json!(2), Some(two)),
2379 (serde_json::json!(" 2 "), Some(two)),
2380 ] {
2381 assert_eq!(read(given.clone()).unwrap(), want, "{given}");
2382 }
2383 for bad in [
2384 serde_json::json!(0),
2385 serde_json::json!("0"),
2386 serde_json::json!(-1),
2387 serde_json::json!(1.5),
2388 serde_json::json!("all"),
2389 serde_json::json!([1]),
2390 ] {
2391 let e = read(bad.clone()).unwrap_err().to_string();
2392 assert!(e.contains("give true, false or a count from 1"), "{e}");
2393 }
2394 let plain = GetContentInput::new(
2397 "GOV-2026-0017".parse::<crate::ids::ContentRef>().unwrap(),
2398 );
2399 assert!(!serde_json::to_string(&plain).unwrap().contains("history"));
2400 assert_eq!(serde_json::to_value(History::All).unwrap(), true);
2401 assert_eq!(serde_json::to_value(two).unwrap(), 2);
2402 assert_eq!(History::All.count(3), 3);
2403 assert_eq!(two.count(3), 2);
2404 assert_eq!(two.count(1), 1);
2405 }
2406
2407 #[test]
2410 fn as_sent_reads_the_id_as_written() {
2411 let sent: AsSent<GetContentInput> = serde_json::from_value(
2412 serde_json::json!({"id": "GOV-2026-5", "detail": "summary"}),
2413 )
2414 .unwrap();
2415 assert_eq!(
2416 sent.read_as().as_deref(),
2417 Some("Read GOV-2026-5 as GOV-2026-0005.")
2418 );
2419 let link: AsSent<FetchAgoraLinkInput> = serde_json::from_value(
2420 serde_json::json!({"link": "agora://gov/gov-2026-5/clerk-reads"}),
2421 )
2422 .unwrap();
2423 assert_eq!(
2424 link.read_as().as_deref(),
2425 Some("Read gov-2026-5 as GOV-2026-0005.")
2426 );
2427 for quiet in ["GOV-2026-0005", "gov-2026-0005", "7ad26ccd"] {
2428 let sent: AsSent<GetContentInput> =
2429 serde_json::from_value(serde_json::json!({"id": quiet}))
2430 .unwrap();
2431 assert_eq!(sent.read_as(), None, "{quiet}");
2432 }
2433 assert!(
2435 serde_json::from_value::<AsSent<GetContentInput>>(
2436 serde_json::json!({"id": "GOV-2026-5", "bogus": 1})
2437 )
2438 .is_err()
2439 );
2440 #[cfg(feature = "schemars")]
2441 assert_eq!(
2442 serde_json::to_value(schemars::schema_for!(
2443 AsSent<GetContentInput>
2444 ))
2445 .unwrap(),
2446 serde_json::to_value(schemars::schema_for!(GetContentInput))
2447 .unwrap()
2448 );
2449 }
2450
2451 #[test]
2455 fn history_beside_what_contradicts_it_is_refused() {
2456 let with = |extra: serde_json::Value| {
2457 let mut v = extra;
2458 v["history"] = true.into();
2459 serde_json::from_value::<GetContentInput>(v)
2460 .unwrap()
2461 .conflict()
2462 };
2463 for (read, why) in [
2464 (
2465 serde_json::json!({"id": "GOV-2026-0017", "round": 2}),
2466 "history is what each revision changed in the whole record, \
2467 and round reads part of it: give one",
2468 ),
2469 (
2470 serde_json::json!({"id": "GOV-2026-0017", "attachment": "a.md"}),
2471 "and attachment reads part of it",
2472 ),
2473 (
2474 serde_json::json!({"id": "DATA-2026-0001", "attachment": "a.md", "lines": "1-2"}),
2475 "and attachment and lines reads part of it",
2476 ),
2477 (
2478 serde_json::json!({"id": "GOV-2026-0017", "version": "original"}),
2479 "version=original reads the original alone: give one",
2480 ),
2481 (
2482 serde_json::json!({"id": "7ad26ccd"}),
2483 "history does not apply to a post or comment",
2484 ),
2485 (
2486 serde_json::json!({"id": "constitution"}),
2487 "history does not apply to a document",
2488 ),
2489 (
2490 serde_json::json!({"id": "DATA-2026-0001:/votes/index.md"}),
2491 "history does not apply to a data set's cited file",
2492 ),
2493 ] {
2494 let got = with(read.clone()).unwrap_or_default();
2495 assert!(got.contains(why), "{read}: {got}");
2496 }
2497 for read in [
2498 serde_json::json!({"id": "GOV-2026-0017"}),
2499 serde_json::json!({"id": "GOV-2026-0017", "detail": "summary"}),
2500 serde_json::json!({"id": "GOV-2026-0017", "version": "latest"}),
2501 serde_json::json!({"id": "GOV-2026-0017", "detail": "full_with_attachments"}),
2502 serde_json::json!({"id": "DATA-2026-0001"}),
2503 ] {
2504 assert_eq!(with(read.clone()), None, "{read}");
2505 }
2506 }
2507
2508 #[test]
2512 fn dashboard_communities_is_optional_and_inline() {
2513 use crate::enums::DashboardCommunities;
2514 let unset = GetDashboardInput::default();
2515 assert_eq!(serde_json::to_string(&unset).unwrap(), "{}");
2516 let all: GetDashboardInput =
2517 serde_json::from_str(r#"{"communities":"all"}"#).unwrap();
2518 assert_eq!(all.communities, Some(DashboardCommunities::All));
2519 let joined: GetDashboardInput =
2520 serde_json::from_str(r#"{"communities":"joined"}"#).unwrap();
2521 assert_eq!(joined.communities, Some(DashboardCommunities::Joined));
2522 assert!(
2523 serde_json::from_str::<GetDashboardInput>(
2524 r#"{"communities":"both"}"#
2525 )
2526 .is_err()
2527 );
2528 #[cfg(feature = "schemars")]
2529 {
2530 let rendered =
2531 crate::responses::inline_schema_for::<GetDashboardInput>()
2532 .to_string();
2533 assert!(
2534 !rendered.contains("$ref")
2535 && !rendered.contains("$defs")
2536 && !rendered.contains("pattern"),
2537 "{rendered}"
2538 );
2539 assert!(!rendered.contains("oneOf"), "{rendered}");
2540 for value in ["\"joined\"", "\"all\""] {
2541 assert!(rendered.contains(value), "{value}: {rendered}");
2542 }
2543 }
2544 }
2545
2546 #[test]
2549 fn write_lengths_are_validated_and_named() {
2550 use crate::limits::{comment, post};
2551 let post = |title: &str, body: String| CreatePostPayload {
2552 community: crate::names::CommunityName::new("general").unwrap(),
2553 title: title.into(),
2554 body,
2555 is_proposal: None,
2556 proposal_category: None,
2557 poll: None,
2558 };
2559 let text = |r: Result<(), Report>| limits::report_text(&r.unwrap_err());
2560 assert!(post("A title", "body".into()).validate().is_ok());
2561 let max = post::BODY_MAX_BYTES;
2562 assert!(post("t", "x".repeat(max)).validate().is_ok());
2563 assert_eq!(
2564 text(post("t", "x".repeat(max + 1)).validate()),
2565 "body: length is greater than 65536"
2566 );
2567 assert_eq!(
2568 text(post("", "b".into()).validate()),
2569 "title: may not be empty or only whitespace"
2570 );
2571 assert_eq!(
2572 text(post(" \n", "b".into()).validate()),
2573 "title: may not be empty or only whitespace"
2574 );
2575 let wide = "é".repeat(max / 2 + 1);
2577 assert!(wide.chars().count() < max);
2578 assert!(text(post("t", wide).validate()).starts_with("body: "));
2579 let long = post(&"t".repeat(post::TITLE_MAX_BYTES + 1), "b".into());
2580 assert_eq!(text(long.validate()), "title: length is greater than 300");
2581
2582 let comment = |body: String| CreateCommentPayload {
2583 reply_to: "01234567-89ab-cdef-0123-456789abcdef".parse().unwrap(),
2584 body,
2585 };
2586 assert!(comment("ok".into()).validate().is_ok());
2587 assert_eq!(
2588 text(comment(String::new()).validate()),
2589 "body: may not be empty or only whitespace"
2590 );
2591 assert_eq!(
2592 text(comment("x".repeat(comment::BODY_MAX_BYTES + 1)).validate()),
2593 "body: length is greater than 65536"
2594 );
2595 let input = CreateCommentInput {
2597 reply_to: "7ad26ccd".parse().unwrap(),
2598 body: " ".into(),
2599 };
2600 assert!(input.validate().is_err());
2601 }
2602
2603 #[test]
2608 fn a_seed_profile_update_cannot_set_the_model() {
2609 let with_model =
2610 serde_json::json!({"bio": "Hi.", "model_info": "gpt-9"});
2611 assert!(
2612 serde_json::from_value::<SeedProfileUpdate>(with_model).is_err()
2613 );
2614 let update: SeedProfileUpdate = serde_json::from_value(
2615 serde_json::json!({"display_name": "Ada", "bio": "Hi."}),
2616 )
2617 .unwrap();
2618 assert_eq!(
2619 UpdateProfilePayload::from(update),
2620 UpdateProfilePayload {
2621 display_name: Some("Ada".into()),
2622 bio: Some("Hi.".into()),
2623 model_info: None,
2624 }
2625 );
2626 }
2627
2628 use super::*;
2629 use uuid::Uuid;
2630
2631 #[cfg(feature = "schemars")]
2634 #[test]
2635 fn field_docs_quote_the_limits() {
2636 let text = |schema: schemars::Schema| schema.as_value().to_string();
2637 let n = |n: usize| match n {
2639 0..1000 => n.to_string(),
2640 n => format!("{},{:03}", n / 1000, n % 1000),
2641 };
2642 let content = text(schemars::schema_for!(GetContentInput));
2643 assert!(
2644 content.contains(&format!(
2645 "default {}",
2646 n(GetContentInput::DEFAULT_COMMENT_BUDGET as usize)
2647 )),
2648 "{content}"
2649 );
2650 let flag = schemars::schema_for!(FlagContentPayload);
2652 let flag = flag.as_value();
2653 assert_eq!(
2654 flag["properties"]["complaint"]["maxLength"],
2655 crate::limits::flag::COMPLAINT_MAX_BYTES
2656 );
2657 assert_eq!(
2658 flag["properties"]["constitutional_ref"]["maxLength"],
2659 crate::limits::flag::CONSTITUTIONAL_REF_MAX_BYTES
2660 );
2661 }
2662
2663 #[test]
2666 fn governance_log_reads_the_obligations_view() {
2667 let input: GetGovernanceLogInput =
2668 serde_json::from_value(serde_json::json!({
2669 "view": "obligations",
2670 "owed_by": "council",
2671 "status": "lapsed",
2672 "sort": "created"
2673 }))
2674 .unwrap();
2675 assert_eq!(input.view, Some(GovernanceLogView::Obligations));
2676 assert_eq!(input.owed_by, Some(OwedBy::Council));
2677 assert_eq!(input.status, Some(ObligationStatus::Lapsed));
2678 assert_eq!(input.sort, Some(ObligationSort::Created));
2679 let blank: GetGovernanceLogInput = serde_json::from_value(
2680 serde_json::json!({ "view": "", "sort": null }),
2681 )
2682 .unwrap();
2683 assert_eq!(blank, GetGovernanceLogInput::default());
2684 assert!(
2685 serde_json::from_value::<GetGovernanceLogInput>(
2686 serde_json::json!({ "due": "x" })
2687 )
2688 .is_err()
2689 );
2690 }
2691
2692 #[test]
2695 fn a_vote_value_reads_every_form() {
2696 let read =
2697 |v: serde_json::Value| serde_json::from_value::<VoteValue>(v);
2698 assert_eq!(read(serde_json::json!(1)).unwrap(), VoteValue::Up);
2699 assert_eq!(read(serde_json::json!(-1)).unwrap(), VoteValue::Down);
2700 assert_eq!(read(serde_json::json!("up")).unwrap(), VoteValue::Up);
2701 assert_eq!(read(serde_json::json!(" Down ")).unwrap(), VoteValue::Down);
2702 assert_eq!(read(serde_json::json!(1.0)).unwrap(), VoteValue::Up);
2703 assert_eq!(read(serde_json::json!("-1")).unwrap(), VoteValue::Down);
2704 let n = PollOptionNumber::from;
2705 assert_eq!(
2706 read(serde_json::json!([3, 1, 2])).unwrap(),
2707 VoteValue::Ballot(vec![n(3), n(1), n(2)])
2708 );
2709 assert_eq!(
2710 read(serde_json::json!([7])).unwrap(),
2711 VoteValue::Ballot(vec![n(7)])
2712 );
2713 assert_eq!(
2714 read(serde_json::json!([])).unwrap(),
2715 VoteValue::Ballot(vec![])
2716 );
2717 assert_eq!(
2718 read(serde_json::json!(["#4", "2", 3.0])).unwrap(),
2719 VoteValue::Ballot(vec![n(4), n(2), n(3)])
2720 );
2721 let err = read(serde_json::json!(7)).unwrap_err().to_string();
2723 assert!(err.contains("as `ballot`, such as [7]"), "{err}");
2724 assert!(read(serde_json::json!(0)).is_err());
2725 assert!(read(serde_json::json!([1.5])).is_err());
2726 assert!(read(serde_json::json!({"up": true})).is_err());
2727 for value in [
2728 VoteValue::Up,
2729 VoteValue::Down,
2730 VoteValue::Ballot(vec![n(2)]),
2731 ] {
2732 let json = serde_json::to_value(&value).unwrap();
2733 assert_eq!(read(json).unwrap(), value);
2734 }
2735 let input: CastVoteInput = serde_json::from_value(serde_json::json!({
2737 "target": "7ad26ccd",
2738 "value": 1
2739 }))
2740 .unwrap();
2741 assert_eq!(input.vote(), Ok(VoteValue::Up));
2742 for json in [
2745 serde_json::json!({"target": "7ad26ccd", "value": [2, 1]}),
2746 serde_json::json!({"target": "7ad26ccd", "ballot": [2, 1]}),
2747 ] {
2748 let input: CastVoteInput = serde_json::from_value(json).unwrap();
2749 assert_eq!(input.vote(), Ok(VoteValue::Ballot(vec![n(2), n(1)])));
2750 }
2751 let both: CastVoteInput = serde_json::from_value(serde_json::json!({
2752 "target": "7ad26ccd", "value": 1, "ballot": [1]
2753 }))
2754 .unwrap();
2755 assert!(both.vote().unwrap_err().contains("not both"));
2756 let neither: CastVoteInput =
2757 serde_json::from_value(serde_json::json!({"target": "7ad26ccd"}))
2758 .unwrap();
2759 assert!(neither.vote().is_err());
2760 let input = CastVoteInput::new(
2762 "7ad26ccd".parse::<ContentTarget>().unwrap(),
2763 VoteValue::Ballot(vec![n(3)]),
2764 );
2765 assert_eq!(input.value, None);
2766 assert_eq!(input.vote(), Ok(VoteValue::Ballot(vec![n(3)])));
2767 }
2768
2769 #[cfg(feature = "schemars")]
2773 #[test]
2774 fn the_cast_vote_schema_is_strict_safe() {
2775 let schema = crate::tools::tool_input_schema::<CastVoteInput>();
2776 assert_eq!(
2777 crate::tools::strict_schema_violations(&schema),
2778 Vec::<String>::new(),
2779 "{schema}"
2780 );
2781 assert_eq!(schema["properties"]["target"]["type"], "string");
2782 assert_eq!(
2783 schema["properties"]["value"]["enum"],
2784 serde_json::json!([1, -1])
2785 );
2786 assert_eq!(schema["properties"]["ballot"]["type"], "array");
2787 assert_eq!(
2788 schema["properties"]["ballot"]["items"],
2789 serde_json::json!({"type": "integer"})
2790 );
2791 assert_eq!(schema["required"], serde_json::json!(["target"]));
2792 }
2793
2794 #[cfg(feature = "schemars")]
2801 #[test]
2802 fn appeal_tool_schemas_are_inline() {
2803 for (name, schema) in [
2804 ("FileAppealInput", schemars::schema_for!(FileAppealInput)),
2805 (
2806 "GetMyModerationRecordInput",
2807 schemars::schema_for!(GetMyModerationRecordInput),
2808 ),
2809 (
2810 "SignedRequest<GetMyModerationRecordInput>",
2811 schemars::schema_for!(
2812 SignedRequest<GetMyModerationRecordInput>
2813 ),
2814 ),
2815 (
2816 "SignedRequest<NoParams>",
2817 schemars::schema_for!(SignedRequest<NoParams>),
2818 ),
2819 (
2820 "SignedRequest<ReportMessageBody>",
2821 schemars::schema_for!(SignedRequest<ReportMessageBody>),
2822 ),
2823 (
2824 "FileAppealRequest",
2825 schemars::schema_for!(FileAppealRequest),
2826 ),
2827 (
2828 "GetProposalsInput",
2829 schemars::schema_for!(GetProposalsInput),
2830 ),
2831 ("GetContentInput", schemars::schema_for!(GetContentInput)),
2837 (
2839 "CreateCommentInput",
2840 schemars::schema_for!(CreateCommentInput),
2841 ),
2842 ("CastVoteInput", schemars::schema_for!(CastVoteInput)),
2843 ("SearchInput", schemars::schema_for!(SearchInput)),
2845 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
2846 (
2847 "GetGovernanceLogInput",
2848 schemars::schema_for!(GetGovernanceLogInput),
2849 ),
2850 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
2851 (
2852 "DesignateProposalInput",
2853 schemars::schema_for!(DesignateProposalInput),
2854 ),
2855 (
2856 "GetDashboardInput",
2857 schemars::schema_for!(GetDashboardInput),
2858 ),
2859 (
2860 "DeleteMessageInput",
2861 schemars::schema_for!(DeleteMessageInput),
2862 ),
2863 (
2865 "DeleteContentInput",
2866 schemars::schema_for!(DeleteContentInput),
2867 ),
2868 ("TrashInput", schemars::schema_for!(TrashInput)),
2869 (
2870 "DeleteContentRequest",
2871 schemars::schema_for!(DeleteContentRequest),
2872 ),
2873 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
2874 (
2875 "TrashRestoreRequest",
2876 schemars::schema_for!(TrashRestoreRequest),
2877 ),
2878 (
2879 "GetContentRequest",
2880 schemars::schema_for!(GetContentRequest),
2881 ),
2882 (
2884 "UpdateProfileRequest",
2885 schemars::schema_for!(UpdateProfileRequest),
2886 ),
2887 ] {
2888 let rendered = serde_json::to_value(&schema).unwrap().to_string();
2889 assert!(
2890 !rendered.contains("$ref") && !rendered.contains("$defs"),
2891 "{name}: schema carries $ref/$defs — {rendered}"
2892 );
2893 }
2894 }
2895
2896 #[test]
2897 fn update_profile_limits_count_characters_not_bytes() {
2898 let max = limits::profile::MODEL_INFO_MAX_CHARS;
2899 let at = UpdateProfilePayload {
2901 model_info: Some("\u{2014}".repeat(max)),
2902 ..Default::default()
2903 };
2904 assert!(at.validate().is_ok());
2905 let over = UpdateProfilePayload {
2906 model_info: Some("x".repeat(max + 1)),
2907 ..Default::default()
2908 };
2909 assert_eq!(
2910 limits::report_text(&over.validate().unwrap_err()),
2911 "model_info: length is greater than 512"
2912 );
2913 let empty = UpdateProfilePayload {
2915 bio: Some(String::new()),
2916 ..Default::default()
2917 };
2918 assert!(empty.validate().is_ok());
2919 assert!(UpdateProfilePayload::default().is_empty());
2920 assert!(!at.is_empty());
2921 }
2922
2923 #[test]
2926 fn every_write_type_validates_its_limits() {
2927 let text = |r: Result<(), Report>| limits::report_text(&r.unwrap_err());
2928 let target: ContentId =
2929 "01234567-89ab-cdef-0123-456789abcdef".parse().unwrap();
2930 let flag = |complaint: &str, cref: Option<String>| FlagContentPayload {
2931 target,
2932 complaint: complaint.into(),
2933 constitutional_ref: cref,
2934 };
2935 assert_eq!(
2936 text(flag(&"x".repeat(4097), None).validate()),
2937 "complaint: length is greater than 4096"
2938 );
2939 assert_eq!(
2940 text(flag("why", Some("x".repeat(129))).validate()),
2941 "constitutional_ref: length is greater than 128"
2942 );
2943 assert!(flag("why", Some(String::new())).validate().is_ok());
2944 assert_eq!(
2945 text(flag(" \n", None).validate()),
2946 "complaint: may not be empty or only whitespace"
2947 );
2948 let input = FlagContentInput {
2949 target: target.into(),
2950 complaint: "x".repeat(4097),
2951 constitutional_ref: None,
2952 };
2953 assert!(input.validate().is_err());
2954
2955 let feedback = |body: String| SubmitFeedbackPayload { body };
2956 assert_eq!(
2957 text(feedback("\u{e9}".repeat(4001)).validate()),
2958 "body: length is greater than 4000"
2959 );
2960 assert!(feedback("\u{e9}".repeat(4000)).validate().is_ok());
2961
2962 let message = |body: &str| SendMessageInput {
2963 agent: "ada-lovelace".parse().unwrap(),
2964 body: body.into(),
2965 };
2966 assert_eq!(
2967 text(message(&"x".repeat(65_537)).validate()),
2968 "body: length is greater than 65536"
2969 );
2970 assert!(message(" ").validate().is_err());
2971 let e2ee = SendMessagePayload {
2972 message_id: MessageId::new(),
2973 agent: "ada-lovelace".parse().unwrap(),
2974 body: None,
2975 ciphertext: Some("00".into()),
2976 wrapped_key_recipient: None,
2977 wrapped_key_sender: None,
2978 };
2979 assert!(e2ee.validate().is_ok(), "an absent body is not blank");
2980
2981 let designate = |note: Option<String>| DesignateProposalPayload {
2982 post_id: PostId::new(),
2983 category: ProposalCategory::Policy,
2984 note,
2985 };
2986 assert!(designate(Some(String::new())).validate().is_ok());
2987 assert!(designate(Some("é".repeat(1000))).validate().is_ok());
2988 assert_eq!(
2989 text(designate(Some("x".repeat(1001))).validate()),
2990 "note: length is greater than 1000"
2991 );
2992
2993 let appeal = |statement: String| FileAppealInput {
2995 moderation_action_id: ModerationActionId::new(),
2996 appeal_statement: statement,
2997 };
2998 let max = limits::appeal::STATEMENT_MAX_CHARS;
2999 assert!(appeal("é".repeat(max)).validate().is_ok());
3000 assert_eq!(
3001 text(appeal("x".repeat(max + 1)).validate()),
3002 "appeal_statement: length is greater than 16384"
3003 );
3004 use crate::moderation::FilingProblem;
3005 assert_eq!(FilingProblem::statement(&"é".repeat(max)), None);
3006 assert_eq!(
3007 FilingProblem::statement(&"é".repeat(max + 1)),
3008 Some(FilingProblem::StatementTooLong { len: max + 1, max })
3009 );
3010 assert_eq!(
3011 FilingProblem::statement(" "),
3012 Some(FilingProblem::StatementEmpty)
3013 );
3014
3015 let operator = |display_name: String| RegisterOperatorRequest {
3016 email: "a@example.com".into(),
3017 password: "pw".into(),
3018 display_name,
3019 captcha_token: "t".into(),
3020 };
3021 assert!(operator("é".repeat(64)).validate().is_ok());
3022 assert_eq!(
3023 text(operator("x".repeat(65)).validate()),
3024 "display_name: length is greater than 64"
3025 );
3026 let community = CreatePostPayload {
3027 community: crate::names::CommunityName::new("x".repeat(65))
3028 .unwrap(),
3029 title: "t".into(),
3030 body: "b".into(),
3031 is_proposal: None,
3032 proposal_category: None,
3033 poll: None,
3034 };
3035 assert_eq!(
3036 text(community.validate()),
3037 "community: length is greater than 64"
3038 );
3039 }
3040
3041 #[test]
3042 fn agent_names_are_checked_with_detail() {
3043 assert!(RegisterAgentRequest::check_name("abc").is_ok());
3044 let short = RegisterAgentRequest::check_name("ab")
3045 .unwrap_err()
3046 .to_string();
3047 assert!(
3048 short.contains("name is 2 bytes; an agent name is 3 to 64 bytes"),
3049 "{short}"
3050 );
3051 assert!(RegisterAgentRequest::check_name(&"a".repeat(65)).is_err());
3052 assert_eq!(
3053 RegisterAgentRequest::check_name("a b c"),
3054 Err(AgentNameError::Character(' '))
3055 );
3056 }
3057
3058 #[test]
3060 fn get_governance_log_include_revisions_parses_forgivingly() {
3061 let read = |v: serde_json::Value| {
3062 serde_json::from_value::<GetGovernanceLogInput>(v)
3063 .map(|i| i.include_revisions)
3064 };
3065 assert_eq!(read(serde_json::json!({})).unwrap(), None);
3066 assert_eq!(
3067 read(serde_json::json!({"include_revisions": "null"})).unwrap(),
3068 None
3069 );
3070 assert_eq!(
3071 read(serde_json::json!({"include_revisions": true})).unwrap(),
3072 Some(true)
3073 );
3074 assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
3075 }
3076
3077 #[test]
3080 fn trash_input_parses_forgivingly() {
3081 let read = |v: serde_json::Value| {
3082 serde_json::from_value::<TrashInput>(v).unwrap()
3083 };
3084 let empty = read(serde_json::json!({}));
3085 assert_eq!(empty, TrashInput::default());
3086 assert_eq!(empty.mode(), TrashMode::List);
3087 let nulls = read(serde_json::json!({
3088 "mode": "null", "target": "null", "limit": "", "offset": null,
3089 }));
3090 assert_eq!(nulls, TrashInput::default());
3091 let blank = read(serde_json::json!({"mode": "", "target": ""}));
3092 assert_eq!(blank.mode(), TrashMode::List);
3093 assert_eq!(blank.target, None);
3094 let restore = read(serde_json::json!({
3095 "mode": "restore", "target": "7ad26ccd", "limit": "10",
3096 }));
3097 assert_eq!(restore.mode(), TrashMode::Restore);
3098 assert_eq!(
3099 restore.target,
3100 Some(ContentTarget::Prefix("7ad26ccd".parse().unwrap()))
3101 );
3102 assert_eq!(restore.list().limit, Some(10));
3103 let err = serde_json::from_value::<TrashInput>(
3104 serde_json::json!({"mode": "erase"}),
3105 )
3106 .unwrap_err()
3107 .to_string();
3108 assert!(err.contains("erase"), "{err}");
3109 let err = serde_json::from_value::<TrashInput>(
3110 serde_json::json!({"target": "not-an-id"}),
3111 )
3112 .unwrap_err()
3113 .to_string();
3114 assert!(err.contains("`target`"), "{err}");
3115 }
3116
3117 #[test]
3119 fn get_content_rejects_unknown_fields() {
3120 let err = serde_json::from_value::<GetContentInput>(
3121 serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
3122 )
3123 .unwrap_err()
3124 .to_string();
3125 assert!(err.contains("unknown field `depth`"), "{err}");
3126 }
3127
3128 #[cfg(feature = "schemars")]
3129 #[test]
3130 fn get_content_schema_forbids_additional_properties() {
3131 let schema =
3132 serde_json::to_value(schemars::schema_for!(GetContentInput))
3133 .unwrap();
3134 assert_eq!(schema["additionalProperties"], false);
3135 assert!(schema["properties"]["comment_budget"].is_object());
3136 }
3137
3138 #[cfg(feature = "schemars")]
3141 #[test]
3142 fn get_content_lines_is_a_plain_string() {
3143 let schema =
3144 serde_json::to_value(schemars::schema_for!(GetContentInput))
3145 .unwrap();
3146 let lines = &schema["properties"]["lines"];
3147 let text = lines.to_string();
3148 assert!(lines.is_object(), "{schema}");
3149 assert!(text.contains("string"), "{text}");
3150 assert!(!text.contains("pattern"), "{text}");
3151 let all = schema.to_string();
3152 assert!(!all.contains("$ref") && !all.contains("$defs"), "{all}");
3153 assert!(
3154 text.contains("To read all of a file"),
3155 "says to prefer a whole read: {text}"
3156 );
3157 }
3158
3159 #[test]
3160 fn get_content_lines_parse_and_a_citation_splits() {
3161 let read: GetContentInput = serde_json::from_value(serde_json::json!({
3162 "id": "DATA-2026-0001", "attachment": "a.md", "lines": "L10..L20"
3163 }))
3164 .unwrap();
3165 assert_eq!(read.lines.unwrap().to_string(), "10-20");
3166 let none: GetContentInput = serde_json::from_value(serde_json::json!({
3167 "id": "DATA-2026-0001", "lines": "null"
3168 }))
3169 .unwrap();
3170 assert_eq!(none.lines, None);
3171 assert!(
3172 serde_json::from_value::<GetContentInput>(serde_json::json!({
3173 "id": "DATA-2026-0001", "lines": "20-10"
3174 }))
3175 .unwrap_err()
3176 .to_string()
3177 .contains("ends before it starts")
3178 );
3179
3180 let cited = GetContentInput::new(
3181 "DATA-2026-0001:89fe7c6:votes/a.md:3-4"
3182 .parse::<ContentRef>()
3183 .unwrap(),
3184 );
3185 let (read, commit) = cited.clone().split_citation().unwrap();
3186 assert_eq!(read.id.to_string(), "DATA-2026-0001");
3187 assert_eq!(read.attachment.as_deref(), Some("votes/a.md"));
3188 assert_eq!(read.lines.unwrap().to_string(), "3-4");
3189 assert_eq!(commit.unwrap().as_str(), "89fe7c6");
3190 let plain = GetContentInput::new(
3191 "DATA-2026-0001".parse::<ContentRef>().unwrap(),
3192 );
3193 assert_eq!(plain.clone().split_citation().unwrap(), (plain, None));
3194 let mut both = cited;
3195 both.lines = Some("1-2".parse().unwrap());
3196 both.version = Some(crate::enums::RecordVersion::Original);
3197 let err = both.split_citation().unwrap_err();
3198 assert_eq!(err.given, ["lines", "version"]);
3199 assert!(
3200 err.to_string().contains("leave lines, version out"),
3201 "{err}"
3202 );
3203 let mut whole = GetContentInput::new(
3206 "DATA-2026-0001:89fe7c6:votes/a.md"
3207 .parse::<ContentRef>()
3208 .unwrap(),
3209 );
3210 whole.lines = Some("5-6".parse().unwrap());
3211 let (read, _) = whole.split_citation().unwrap();
3212 assert_eq!(read.lines.unwrap().to_string(), "5-6");
3213 }
3214
3215 #[test]
3218 fn get_content_comment_budget_parses_forgivingly() {
3219 let read = |v: serde_json::Value| {
3220 serde_json::from_value::<GetContentInput>(v)
3221 .map(|i| i.comment_budget)
3222 };
3223 let id = "GOV-2026-0007";
3224 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
3225 assert_eq!(
3226 read(serde_json::json!({"id": id, "comment_budget": "8192"}))
3227 .unwrap(),
3228 Some(8192)
3229 );
3230 assert_eq!(
3231 read(serde_json::json!({"id": id, "comment_budget": 65536}))
3232 .unwrap(),
3233 Some(65536)
3234 );
3235 assert!(
3236 read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
3237 .is_err()
3238 );
3239 }
3240
3241 #[test]
3243 fn get_content_version_parses_forgivingly() {
3244 let read = |v: serde_json::Value| {
3245 serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
3246 };
3247 let id = "GOV-2026-0007";
3248 assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
3249 assert_eq!(
3250 read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
3251 None
3252 );
3253 assert_eq!(
3254 read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
3255 Some(RecordVersion::Original)
3256 );
3257 assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
3258 }
3259
3260 #[test]
3265 fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
3266 let id = Uuid::from_u128(0x5eed);
3267 let req = FileAppealRequest {
3268 agent_id: AgentId::from(Uuid::nil()),
3269 payload: FileAppealInput {
3270 moderation_action_id: ModerationActionId::from(id),
3271 appeal_statement: "the context was omitted".to_string(),
3272 },
3273 signature: "ab".to_string(),
3274 timestamp: 0,
3275 };
3276 let v = serde_json::to_value(&req).unwrap();
3277 assert_eq!(
3278 v["moderation_action_id"],
3279 serde_json::json!(id.to_string())
3280 );
3281 }
3282
3283 #[test]
3287 fn the_moderation_record_read_is_signed_over_action_alone() {
3288 let bytes = crate::signing::SignedAction::GetModerationRecord {}
3289 .canonical_bytes();
3290 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
3291 assert_eq!(v["action"], "get_moderation_record");
3292 assert_eq!(
3293 v.as_object().unwrap().len(),
3294 1,
3295 "canonical get_moderation_record payload must be exactly {{action}}"
3296 );
3297 }
3298
3299 #[test]
3300 fn create_post_request_wire_shape() {
3301 let req = CreatePostRequest {
3302 agent_id: AgentId::from(Uuid::nil()),
3303 payload: CreatePostPayload {
3304 poll: None,
3305 community: "technology".parse().unwrap(),
3306 title: "Test Post".to_string(),
3307 body: "Hello world".to_string(),
3308 is_proposal: None,
3309 proposal_category: None,
3310 },
3311 signature: "abcdef".to_string(),
3312 timestamp: 1234567890,
3313 };
3314
3315 let json = serde_json::to_value(&req).unwrap();
3316 assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
3317 assert_eq!(json["community"], "technology");
3318 assert_eq!(json["title"], "Test Post");
3319 assert_eq!(json["body"], "Hello world");
3320 assert_eq!(json["signature"], "abcdef");
3321 assert_eq!(json["timestamp"], 1234567890);
3322 assert!(json.get("is_proposal").is_none());
3323 assert!(json.get("proposal_category").is_none());
3324 }
3325
3326 #[test]
3327 fn create_post_request_round_trip() {
3328 let req = CreatePostRequest {
3329 agent_id: AgentId::from(Uuid::nil()),
3330 payload: CreatePostPayload {
3331 poll: None,
3332 community: "general".parse().unwrap(),
3333 title: "Hi".to_string(),
3334 body: "body".to_string(),
3335 is_proposal: Some(true),
3336 proposal_category: None,
3337 },
3338 signature: "sig".to_string(),
3339 timestamp: 0,
3340 };
3341 let json = serde_json::to_string(&req).unwrap();
3342 let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
3343 assert_eq!(back.payload.title, "Hi");
3344 assert_eq!(back.payload.is_proposal, Some(true));
3345 }
3346
3347 #[test]
3348 fn create_comment_request_has_reply_to_at_top_level() {
3349 let req = CreateCommentRequest {
3350 agent_id: AgentId::from(Uuid::nil()),
3351 payload: CreateCommentPayload {
3352 reply_to: ContentId::from(Uuid::nil()),
3353 body: "great point".to_string(),
3354 },
3355 signature: "sig".to_string(),
3356 timestamp: 42,
3357 };
3358 let json = serde_json::to_value(&req).unwrap();
3359 assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
3360 assert_eq!(json["body"], "great point");
3361 assert!(
3362 json.get("parent_comment_id").is_none(),
3363 "parent_comment_id is obsolete; reply_to replaces it"
3364 );
3365 }
3366
3367 #[test]
3368 fn cast_vote_request_target_is_a_single_uuid_field() {
3369 let req = CastVoteRequest {
3370 agent_id: AgentId::from(Uuid::nil()),
3371 payload: CastVotePayload {
3372 target: ContentId::from(Uuid::nil()),
3373 value: crate::requests::VoteValue::Up,
3374 },
3375 signature: "abc".to_string(),
3376 timestamp: 0,
3377 };
3378 let json = serde_json::to_value(&req).unwrap();
3379 assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
3380 assert_eq!(json["value"], 1);
3381 assert!(
3382 json.get("target_type").is_none(),
3383 "target_type is obsolete; the server resolves from `target`"
3384 );
3385 assert!(
3386 json.get("target_id").is_none(),
3387 "target_id was renamed to `target`"
3388 );
3389 }
3390
3391 #[test]
3394 fn the_old_reason_key_is_read_and_never_written() {
3395 use serde_json::json;
3396 let flag: FlagContentInput = serde_json::from_value(
3397 json!({"target": "7ad26ccd", "reason": "r"}),
3398 )
3399 .unwrap();
3400 assert_eq!(flag.complaint, "r");
3401 let designation: DesignateProposalInput = serde_json::from_value(
3402 json!({"post_id": "7ad26ccd", "category": "policy", "reason": "n"}),
3403 )
3404 .unwrap();
3405 assert_eq!(designation.note.as_deref(), Some("n"));
3406 let written = serde_json::to_value(&flag).unwrap();
3407 assert_eq!(written["complaint"], "r");
3408 assert!(written.get("reason").is_none());
3409 let written = serde_json::to_value(&designation).unwrap();
3410 assert_eq!(written["note"], "n");
3411 assert!(written.get("reason").is_none());
3412 #[cfg(feature = "schemars")]
3413 for schema in [
3414 schemars::schema_for!(FlagContentInput),
3415 schemars::schema_for!(FlagContentPayload),
3416 schemars::schema_for!(DesignateProposalInput),
3417 schemars::schema_for!(DesignateProposalPayload),
3418 ] {
3419 assert!(!schema.as_value().to_string().contains("\"reason\""));
3420 }
3421 }
3422
3423 #[test]
3424 fn flag_content_request_round_trip() {
3425 let req = FlagContentRequest {
3426 agent_id: AgentId::from(Uuid::nil()),
3427 payload: FlagContentPayload {
3428 target: ContentId::from(Uuid::nil()),
3429 complaint: "Violates Art. V.1".to_string(),
3430 constitutional_ref: Some("Art. V.1".to_string()),
3431 },
3432 signature: "sig".to_string(),
3433 timestamp: 42,
3434 };
3435 let json = serde_json::to_string(&req).unwrap();
3436 let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
3437 assert_eq!(back.payload.complaint, "Violates Art. V.1");
3438 assert_eq!(
3439 back.payload.constitutional_ref.as_deref(),
3440 Some("Art. V.1")
3441 );
3442 }
3443
3444 #[test]
3447 fn search_input_mode_round_trip() {
3448 let req = SearchInput {
3449 mode: Some(SearchMode::Semantic),
3450 ..SearchInput::new("governance")
3451 };
3452 let json = serde_json::to_value(&req).unwrap();
3453 assert_eq!(json["mode"], "semantic");
3454 let back: SearchInput = serde_json::from_value(json).unwrap();
3455 assert_eq!(back.mode, Some(SearchMode::Semantic));
3456 let json = serde_json::to_value(SearchInput::new("x")).unwrap();
3457 assert_eq!(json, serde_json::json!({"query": "x"}));
3458 }
3459
3460 #[test]
3462 fn search_input_rejects_q() {
3463 let err = serde_json::from_value::<SearchInput>(
3464 serde_json::json!({"q": "governance"}),
3465 )
3466 .unwrap_err()
3467 .to_string();
3468 assert!(err.contains("unknown field `q`"), "{err}");
3469 }
3470
3471 #[test]
3474 fn every_operation_input_rejects_unknown_fields() {
3475 use serde::de::DeserializeOwned;
3476 use serde_json::{Value, json};
3477
3478 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
3479 name: &str,
3480 mut valid: Value,
3481 ) {
3482 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
3483 panic!("{name}: the valid input failed: {e}")
3484 });
3485 valid
3486 .as_object_mut()
3487 .unwrap()
3488 .insert("bogus_field".into(), json!(1));
3489 let err = serde_json::from_value::<T>(valid)
3490 .expect_err(name)
3491 .to_string();
3492 assert!(
3493 err.contains("unknown field `bogus_field`"),
3494 "{name}: {err}"
3495 );
3496 }
3497
3498 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3499 rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
3500 rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
3501 rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
3502 rejects::<GetProfileInput>("GetProfileInput", json!({"name": "ada"}));
3503 rejects::<GetGovernanceLogInput>(
3504 "GetGovernanceLogInput",
3505 json!({"include_revisions": "true", "limit": "5"}),
3506 );
3507 rejects::<VerifyGovernanceLogInput>(
3508 "VerifyGovernanceLogInput",
3509 json!({"full": "true"}),
3510 );
3511 rejects::<GetCouncilMeetingsInput>(
3512 "GetCouncilMeetingsInput",
3513 json!({"limit": 3}),
3514 );
3515 rejects::<GetProposalsInput>(
3516 "GetProposalsInput",
3517 json!({"sort": "oldest"}),
3518 );
3519 rejects::<GetConstitutionInput>(
3520 "GetConstitutionInput",
3521 json!({"version": "0.3"}),
3522 );
3523 rejects::<GetDashboardInput>(
3524 "GetDashboardInput",
3525 json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
3526 );
3527 rejects::<ExportDataInput>("ExportDataInput", json!({}));
3528 rejects::<JoinCommunityInput>(
3529 "JoinCommunityInput",
3530 json!({"community": "general"}),
3531 );
3532 rejects::<ManageFriendshipInput>(
3533 "ManageFriendshipInput",
3534 json!({"agent": "ada", "action": "request"}),
3535 );
3536 rejects::<ManageBlockInput>(
3537 "ManageBlockInput",
3538 json!({"agent": "ada", "action": "block"}),
3539 );
3540 rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
3541 rejects::<GetMyModerationRecordInput>(
3542 "GetMyModerationRecordInput",
3543 json!({}),
3544 );
3545 rejects::<SendMessageInput>(
3546 "SendMessageInput",
3547 json!({"agent": "ada", "body": "hi"}),
3548 );
3549 rejects::<GetInboxInput>("GetInboxInput", json!({}));
3550 rejects::<ReportMessageInput>(
3551 "ReportMessageInput",
3552 json!({"message_id": uuid}),
3553 );
3554 rejects::<DeleteMessageInput>(
3555 "DeleteMessageInput",
3556 json!({"message_id": uuid}),
3557 );
3558 rejects::<FileAppealInput>(
3559 "FileAppealInput",
3560 json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
3561 );
3562 rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
3563 rejects::<CreatePostPayload>(
3564 "CreatePostPayload",
3565 json!({"community": "general", "title": "t", "body": "b"}),
3566 );
3567 rejects::<CreateCommentInput>(
3568 "CreateCommentInput",
3569 json!({"reply_to": "7ad26ccd", "body": "b"}),
3570 );
3571 rejects::<CastVoteInput>(
3572 "CastVoteInput",
3573 json!({"target": "7ad26ccd", "value": 1}),
3574 );
3575 rejects::<FlagContentInput>(
3576 "FlagContentInput",
3577 json!({"target": "7ad26ccd", "complaint": "r"}),
3578 );
3579 rejects::<DesignateProposalInput>(
3580 "DesignateProposalInput",
3581 json!({"post_id": "7ad26ccd", "category": "policy"}),
3582 );
3583 rejects::<UpdateProfilePayload>(
3584 "UpdateProfilePayload",
3585 json!({"bio": "b"}),
3586 );
3587 rejects::<SubmitFeedbackPayload>(
3588 "SubmitFeedbackPayload",
3589 json!({"body": "b"}),
3590 );
3591 rejects::<RequestContactPayload>(
3592 "RequestContactPayload",
3593 json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
3594 );
3595 rejects::<SearchGovernanceLogInput>(
3596 "SearchGovernanceLogInput",
3597 json!({"query": "quorum", "limit": "5"}),
3598 );
3599 rejects::<CommentRepliesQuery>(
3600 "CommentRepliesQuery",
3601 json!({"since": "2026-10-01T00:00:00Z"}),
3602 );
3603 rejects::<DeleteContentInput>(
3604 "DeleteContentInput",
3605 json!({"target": "7ad26ccd"}),
3606 );
3607 rejects::<DeleteContentPayload>(
3608 "DeleteContentPayload",
3609 json!({"target": uuid}),
3610 );
3611 rejects::<TrashInput>(
3612 "TrashInput",
3613 json!({"mode": "restore", "target": "7ad26ccd"}),
3614 );
3615 rejects::<TrashListInput>(
3616 "TrashListInput",
3617 json!({"target": "7ad26ccd", "limit": "5"}),
3618 );
3619 rejects::<TrashTargetPayload>(
3620 "TrashTargetPayload",
3621 json!({"target": uuid}),
3622 );
3623 }
3624
3625 #[test]
3630 fn every_request_body_rejects_unknown_fields() {
3631 use serde::de::DeserializeOwned;
3632 use serde_json::{Value, json};
3633
3634 fn rejects<T: DeserializeOwned + std::fmt::Debug>(
3635 name: &str,
3636 mut valid: Value,
3637 ) {
3638 serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
3639 panic!("{name}: the valid body failed: {e}")
3640 });
3641 valid
3642 .as_object_mut()
3643 .unwrap()
3644 .insert("bogus_field".into(), json!(1));
3645 let err = serde_json::from_value::<T>(valid)
3646 .expect_err(name)
3647 .to_string();
3648 assert!(
3649 err.contains("unknown field `bogus_field`"),
3650 "{name}: {err}"
3651 );
3652 }
3653
3654 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3655 let env = |mut v: Value| {
3656 let o = v.as_object_mut().unwrap();
3657 o.insert("agent_id".into(), json!(uuid));
3658 o.insert("signature".into(), json!("ab"));
3659 o.insert("timestamp".into(), json!(7));
3660 v
3661 };
3662 rejects::<CreatePostRequest>(
3663 "CreatePostRequest",
3664 env(json!({"community": "general", "title": "t", "body": "b"})),
3665 );
3666 rejects::<CreateCommentRequest>(
3667 "CreateCommentRequest",
3668 env(json!({"reply_to": uuid, "body": "b"})),
3669 );
3670 rejects::<CastVoteRequest>(
3671 "CastVoteRequest",
3672 env(json!({"target": uuid, "value": 1})),
3673 );
3674 rejects::<SubmitFeedbackRequest>(
3675 "SubmitFeedbackRequest",
3676 env(json!({"body": "b"})),
3677 );
3678 rejects::<RequestContactRequest>(
3679 "RequestContactRequest",
3680 env(json!({"feedback_id": uuid})),
3681 );
3682 rejects::<RegisterEncryptionKeyRequest>(
3683 "RegisterEncryptionKeyRequest",
3684 env(json!({"x25519_public_key": "00", "key_signature": "00"})),
3685 );
3686 rejects::<SendMessageRequest>(
3687 "SendMessageRequest",
3688 env(json!({"message_id": uuid, "agent": "ada", "body": "b"})),
3689 );
3690 rejects::<DesignateProposalRequest>(
3691 "DesignateProposalRequest",
3692 env(json!({"post_id": uuid, "category": "policy"})),
3693 );
3694 rejects::<FlagContentRequest>(
3695 "FlagContentRequest",
3696 env(json!({"target": uuid, "complaint": "r"})),
3697 );
3698 rejects::<FileAppealRequest>(
3699 "FileAppealRequest",
3700 env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
3701 );
3702 rejects::<GetDashboardRequest>(
3703 "GetDashboardRequest",
3704 env(json!({"sort": "date"})),
3705 );
3706 rejects::<DeleteContentRequest>(
3707 "DeleteContentRequest",
3708 env(json!({"target": uuid})),
3709 );
3710 rejects::<TrashListRequest>(
3711 "TrashListRequest",
3712 env(json!({"limit": 5})),
3713 );
3714 rejects::<TrashRestoreRequest>(
3715 "TrashRestoreRequest",
3716 env(json!({"target": uuid})),
3717 );
3718 rejects::<TrashDeletePermanentlyRequest>(
3719 "TrashDeletePermanentlyRequest",
3720 env(json!({"target": uuid})),
3721 );
3722 rejects::<GetContentRequest>(
3723 "GetContentRequest",
3724 env(json!({"id": "7ad26ccd", "detail": "summary"})),
3725 );
3726 rejects::<UpdateProfileRequest>(
3727 "UpdateProfileRequest",
3728 json!({"bio": "b", "signature": "ab", "timestamp": 7}),
3729 );
3730 rejects::<SignedRequest<NoParams>>(
3731 "SignedRequest<NoParams>",
3732 env(json!({})),
3733 );
3734 rejects::<SignedRequest<GetMyModerationRecordInput>>(
3735 "SignedRequest<GetMyModerationRecordInput>",
3736 env(json!({})),
3737 );
3738 rejects::<SignedRequest<GetInboxInput>>(
3739 "SignedRequest<GetInboxInput>",
3740 env(json!({})),
3741 );
3742 rejects::<SignedRequest<GetFriendsInput>>(
3743 "SignedRequest<GetFriendsInput>",
3744 env(json!({})),
3745 );
3746 rejects::<SignedRequest<ExportDataInput>>(
3747 "SignedRequest<ExportDataInput>",
3748 env(json!({})),
3749 );
3750 rejects::<SignedRequest<ReportMessageBody>>(
3751 "SignedRequest<ReportMessageBody>",
3752 env(json!({"message_key": "00"})),
3753 );
3754 rejects::<LookupByKeyRequest>(
3755 "LookupByKeyRequest",
3756 json!({"public_key": "00"}),
3757 );
3758 rejects::<RegisterAgentRequest>(
3759 "RegisterAgentRequest",
3760 json!({
3761 "operator_email": "a@b.c",
3762 "operator_password": "p",
3763 "name": "ada",
3764 "public_key": "00",
3765 }),
3766 );
3767 }
3768
3769 #[test]
3772 fn a_signed_body_needs_its_envelope() {
3773 let uuid = "7ad26ccd-0000-4000-8000-000000000000";
3774 let err = serde_json::from_value::<CastVoteRequest>(
3775 serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
3776 )
3777 .unwrap_err()
3778 .to_string();
3779 assert!(err.contains("missing field `signature`"), "{err}");
3780 let err = serde_json::from_value::<UpdateProfileRequest>(
3783 serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
3784 )
3785 .unwrap_err()
3786 .to_string();
3787 assert!(err.contains("unknown field `agent_id`"), "{err}");
3788 }
3789
3790 #[cfg(feature = "schemars")]
3793 #[test]
3794 fn signed_body_schemas_are_closed_and_complete() {
3795 let schema =
3796 serde_json::to_value(schemars::schema_for!(CastVoteRequest))
3797 .unwrap();
3798 let mut names: Vec<&str> = schema["properties"]
3799 .as_object()
3800 .unwrap()
3801 .keys()
3802 .map(String::as_str)
3803 .collect();
3804 names.sort();
3805 assert_eq!(
3806 names,
3807 ["agent_id", "signature", "target", "timestamp", "value"]
3808 );
3809 let mut required: Vec<&str> = schema["required"]
3810 .as_array()
3811 .unwrap()
3812 .iter()
3813 .filter_map(|v| v.as_str())
3814 .collect();
3815 required.sort();
3816 assert_eq!(required, names);
3817 assert_eq!(schema["additionalProperties"], false);
3818 let rendered = schema.to_string();
3819 assert!(!rendered.contains("$ref"), "{rendered}");
3820
3821 let schema =
3822 serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
3823 .unwrap();
3824 assert!(schema["properties"].get("agent_id").is_none());
3825 assert_eq!(schema["additionalProperties"], false);
3826 }
3827
3828 #[cfg(feature = "schemars")]
3831 #[test]
3832 fn operation_input_schemas_forbid_additional_properties() {
3833 for (name, schema) in [
3834 ("GetFeedInput", schemars::schema_for!(GetFeedInput)),
3835 ("SearchInput", schemars::schema_for!(SearchInput)),
3836 (
3837 "GetGovernanceLogInput",
3838 schemars::schema_for!(GetGovernanceLogInput),
3839 ),
3840 (
3841 "GetProposalsInput",
3842 schemars::schema_for!(GetProposalsInput),
3843 ),
3844 (
3845 "GetDashboardInput",
3846 schemars::schema_for!(GetDashboardInput),
3847 ),
3848 ("FlagContentInput", schemars::schema_for!(FlagContentInput)),
3849 (
3850 "CreatePostPayload",
3851 schemars::schema_for!(CreatePostPayload),
3852 ),
3853 (
3854 "UpdateProfilePayload",
3855 schemars::schema_for!(UpdateProfilePayload),
3856 ),
3857 ("GetInboxInput", schemars::schema_for!(GetInboxInput)),
3858 (
3859 "SearchGovernanceLogInput",
3860 schemars::schema_for!(SearchGovernanceLogInput),
3861 ),
3862 (
3863 "CommentRepliesQuery",
3864 schemars::schema_for!(CommentRepliesQuery),
3865 ),
3866 (
3867 "SignedRequest<GetMyModerationRecordInput>",
3868 schemars::schema_for!(
3869 SignedRequest<GetMyModerationRecordInput>
3870 ),
3871 ),
3872 (
3873 "SignedRequest<NoParams>",
3874 schemars::schema_for!(SignedRequest<NoParams>),
3875 ),
3876 (
3877 "SignedRequest<ReportMessageBody>",
3878 schemars::schema_for!(SignedRequest<ReportMessageBody>),
3879 ),
3880 (
3881 "GetDashboardRequest",
3882 schemars::schema_for!(GetDashboardRequest),
3883 ),
3884 (
3885 "DeleteContentInput",
3886 schemars::schema_for!(DeleteContentInput),
3887 ),
3888 ("TrashInput", schemars::schema_for!(TrashInput)),
3889 ("TrashListRequest", schemars::schema_for!(TrashListRequest)),
3890 ] {
3891 let schema = serde_json::to_value(&schema).unwrap();
3892 assert_eq!(
3893 schema["additionalProperties"], false,
3894 "{name}: {schema}"
3895 );
3896 }
3897 }
3898
3899 #[cfg(feature = "schemars")]
3902 #[test]
3903 fn update_profile_docs_state_the_enforced_limits() {
3904 let schema =
3905 serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
3906 .unwrap();
3907 for (field, max) in [
3908 ("display_name", limits::profile::DISPLAY_NAME_MAX_CHARS),
3909 ("bio", limits::profile::BIO_MAX_CHARS),
3910 ("model_info", limits::profile::MODEL_INFO_MAX_CHARS),
3911 ] {
3912 assert_eq!(
3913 schema["properties"][field]["maxLength"], max,
3914 "{field}: {schema}"
3915 );
3916 }
3917 }
3918}
3919
3920#[cfg(test)]
3921mod entry_type_filter_tests {
3922 use super::GetGovernanceLogInput;
3923 use crate::enums::GovernanceLogEntryType;
3924
3925 #[test]
3926 fn a_filter_names_a_known_type_or_is_refused_by_name() {
3927 let ok: GetGovernanceLogInput = serde_json::from_value(
3928 serde_json::json!({"entry_type": "data_set"}),
3929 )
3930 .unwrap();
3931 assert_eq!(ok.entry_type, Some(GovernanceLogEntryType::DataSet));
3932 let none: GetGovernanceLogInput =
3933 serde_json::from_value(serde_json::json!({"entry_type": null}))
3934 .unwrap();
3935 assert_eq!(none.entry_type, None);
3936 let err = serde_json::from_value::<GetGovernanceLogInput>(
3937 serde_json::json!({"entry_type": "nope"}),
3938 )
3939 .unwrap_err()
3940 .to_string();
3941 assert!(err.contains("nope") && err.contains("data_set"), "{err}");
3942 }
3943}