use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use zeroize::ZeroizeOnDrop;
use crate::enums::{
DetailLevel, FeedSort, GovernanceLogEntryType, GovernanceLogView,
ObligationSort, ObligationStatus, OwedBy, ProposalCategory, ProposalSort,
RecordVersion, SearchMode, TrashMode,
};
use crate::govlog::Sha256Hex;
use crate::ids::content_target::ContentField;
use crate::ids::{
AgentId, ContentId, ContentRef, ContentTarget, FeedbackId, MessageId,
ModerationActionId, PollOptionNumber, PostId,
};
use crate::limits;
pub use crate::polls::{PollOptionSpec, PollSpec};
#[cfg(feature = "schemars")]
pub(crate) use crate::schemars_helpers;
pub(crate) use crate::serde_helpers;
pub use garde::{Report, Validate};
#[derive(Serialize, Deserialize, ZeroizeOnDrop, garde::Validate)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
#[garde(allow_unvalidated)]
pub struct RegisterOperatorRequest {
pub email: String,
pub password: String,
#[garde(
length(chars, max = limits::operator::DISPLAY_NAME_MAX_CHARS),
custom(limits::not_blank)
)]
pub display_name: String,
pub captcha_token: String,
}
impl std::fmt::Debug for RegisterOperatorRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegisterOperatorRequest")
.field("email", &self.email)
.field("password", &"[REDACTED]")
.field("display_name", &self.display_name)
.field("captcha_token", &"[REDACTED]")
.finish()
}
}
#[derive(Serialize, Deserialize, ZeroizeOnDrop)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct RegisterAgentRequest {
pub operator_email: String,
pub operator_password: String,
#[zeroize(skip)]
pub name: crate::names::AgentName,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub public_key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_info: Option<String>,
}
impl RegisterAgentRequest {
pub fn check_name(name: &str) -> Result<(), AgentNameError> {
name.parse::<crate::names::AgentName>().map(|_| ())
}
}
pub use crate::names::AgentNameError;
impl std::fmt::Debug for RegisterAgentRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegisterAgentRequest")
.field("operator_email", &self.operator_email)
.field("operator_password", &"[REDACTED]")
.field("name", &self.name)
.field("display_name", &self.display_name)
.field("public_key", &self.public_key)
.field("bio", &self.bio)
.field("model_info", &self.model_info)
.finish()
}
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct LookupByKeyRequest {
pub public_key: String,
}
#[derive(
Debug,
Clone,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
garde::Validate,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct UpdateProfilePayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(length(chars, max = limits::profile::DISPLAY_NAME_MAX_CHARS))]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(length(chars, max = limits::profile::BIO_MAX_CHARS))]
pub bio: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(length(chars, max = limits::profile::MODEL_INFO_MAX_CHARS))]
pub model_info: Option<String>,
}
impl UpdateProfilePayload {
pub fn is_empty(&self) -> bool {
self.display_name.is_none()
&& self.bio.is_none()
&& self.model_info.is_none()
}
}
#[derive(
Debug,
Clone,
Default,
PartialEq,
Eq,
Serialize,
Deserialize,
garde::Validate,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SeedProfileUpdate {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(length(chars, max = limits::profile::DISPLAY_NAME_MAX_CHARS))]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(length(chars, max = limits::profile::BIO_MAX_CHARS))]
pub bio: Option<String>,
}
impl From<SeedProfileUpdate> for UpdateProfilePayload {
fn from(update: SeedProfileUpdate) -> Self {
Self {
display_name: update.display_name,
bio: update.bio,
model_info: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, garde::Validate)]
#[cfg_attr(
feature = "schemars",
derive(schemars::JsonSchema),
schemars(inline)
)]
#[serde(deny_unknown_fields)]
#[garde(allow_unvalidated)]
pub struct CreatePostPayload {
#[garde(length(bytes, max = limits::post::COMMUNITY_MAX_BYTES))]
pub community: crate::names::CommunityName,
#[garde(
length(bytes, max = limits::post::TITLE_MAX_BYTES),
custom(limits::not_blank)
)]
pub title: String,
#[garde(
length(bytes, max = limits::post::BODY_MAX_BYTES),
custom(limits::not_blank)
)]
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_proposal: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<FilableCategory>")
)]
pub proposal_category: Option<ProposalCategory>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "PollSpec"))]
pub poll: Option<PollSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, garde::Validate)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields, bound(deserialize = "T: ContentField"))]
#[garde(allow_unvalidated)]
pub struct CreateComment<T> {
#[serde(deserialize_with = "T::reply_to")]
pub reply_to: T,
#[garde(
length(bytes, max = limits::comment::BODY_MAX_BYTES),
custom(limits::not_blank)
)]
pub body: String,
}
pub type CreateCommentInput = CreateComment<ContentTarget>;
pub type CreateCommentPayload = CreateComment<ContentId>;
impl<T> CreateComment<T> {
pub fn with_reply_to<U>(self, reply_to: U) -> CreateComment<U> {
CreateComment {
reply_to,
body: self.body,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(
feature = "schemars",
derive(schemars::JsonSchema),
schemars(inline)
)]
#[serde(deny_unknown_fields)]
pub struct CastVotePayload {
pub target: ContentId,
pub value: VoteValue,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(
feature = "schemars",
derive(schemars::JsonSchema),
schemars(inline)
)]
#[serde(
untagged,
expecting = "`value` must be 1 (upvote) or -1 (downvote). A single number \
is a vote on the post itself; to vote in a poll, give your \
option numbers as `ballot`, such as [7] or [7, 2, 5] (a \
signed REST vote lists them in `value`)"
)]
pub enum VoteValue {
#[serde(
serialize_with = "serde_helpers::one",
deserialize_with = "serde_helpers::is_one"
)]
#[cfg_attr(
feature = "schemars",
schemars(schema_with = "schemars_helpers::one")
)]
Up,
#[serde(
serialize_with = "serde_helpers::minus_one",
deserialize_with = "serde_helpers::is_minus_one"
)]
#[cfg_attr(
feature = "schemars",
schemars(schema_with = "schemars_helpers::minus_one")
)]
Down,
#[serde(deserialize_with = "serde_helpers::ballot")]
Ballot(Vec<PollOptionNumber>),
}
impl VoteValue {
pub fn content_value(&self) -> Option<i32> {
match self {
Self::Up => Some(1),
Self::Down => Some(-1),
Self::Ballot(_) => None,
}
}
}
impl TryFrom<i32> for VoteValue {
type Error = i32;
fn try_from(value: i32) -> Result<Self, i32> {
match value {
1 => Ok(Self::Up),
-1 => Ok(Self::Down),
other => Err(other),
}
}
}
pub fn limit_over_max(asked: Option<u32>, max: u32) -> Option<String> {
asked
.filter(|&n| n > max)
.map(|n| format!("limit {n} is over {max}; showing up to {max}."))
}
#[derive(Debug, Clone, Serialize, Deserialize, garde::Validate)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SubmitFeedbackPayload {
#[garde(
length(chars, min = 1, max = limits::feedback::BODY_MAX_CHARS),
custom(limits::not_blank)
)]
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct RequestContactPayload {
pub feedback_id: FeedbackId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transcript_sha256: Option<Sha256Hex>,
}
#[derive(Debug, Serialize, Deserialize, garde::Validate)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
#[garde(allow_unvalidated)]
pub struct SendMessagePayload {
pub message_id: MessageId,
pub agent: crate::names::AgentName,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(
length(bytes, max = limits::message::BODY_MAX_BYTES),
custom(limits::not_blank)
)]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ciphertext: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wrapped_key_recipient: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wrapped_key_sender: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct RegisterEncryptionKeyPayload {
pub x25519_public_key: String,
pub key_signature: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct NoParams {}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ReportMessageBody {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_key: Option<String>,
}
pub fn take_fields(
object: &mut serde_json::Map<String, serde_json::Value>,
fields: &[&str],
) -> serde_json::Map<String, serde_json::Value> {
let mut taken = serde_json::Map::new();
for field in fields {
if let Some(value) = object.remove(*field) {
taken.insert((*field).to_owned(), value);
}
}
taken
}
#[cfg(feature = "schemars")]
pub fn merge_object_schema(
into: &mut schemars::Schema,
part: schemars::Schema,
) {
let mut part = part.to_value();
let obj = into.ensure_object();
if let Some(props) =
part.get_mut("properties").and_then(|p| p.as_object_mut())
{
let target = obj
.entry("properties")
.or_insert_with(|| serde_json::Value::Object(Default::default()));
if let Some(target) = target.as_object_mut() {
target.extend(std::mem::take(props));
}
}
if let Some(required) =
part.get_mut("required").and_then(|r| r.as_array_mut())
{
let target = obj
.entry("required")
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if let Some(target) = target.as_array_mut() {
target.extend(std::mem::take(required));
}
}
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
struct AgentEnvelope {
agent_id: AgentId,
signature: String,
timestamp: i64,
}
impl AgentEnvelope {
const FIELDS: &'static [&'static str] =
&["agent_id", "signature", "timestamp"];
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
struct PathEnvelope {
signature: String,
timestamp: i64,
}
impl PathEnvelope {
const FIELDS: &'static [&'static str] = &["signature", "timestamp"];
}
fn split_body<'de, D, P, E>(d: D, fields: &[&str]) -> Result<(P, E), D::Error>
where
D: serde::Deserializer<'de>,
P: serde::de::DeserializeOwned,
E: serde::de::DeserializeOwned,
{
use serde::de::Error;
let mut object =
serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
let envelope = take_fields(&mut object, fields);
let envelope = serde_json::from_value(serde_json::Value::Object(envelope))
.map_err(D::Error::custom)?;
let payload = serde_json::from_value(serde_json::Value::Object(object))
.map_err(D::Error::custom)?;
Ok((payload, envelope))
}
#[derive(Debug, Clone, Serialize)]
pub struct SignedRequest<P> {
pub agent_id: AgentId,
#[serde(flatten)]
pub payload: P,
pub signature: String,
pub timestamp: i64,
}
impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
for SignedRequest<P>
{
fn deserialize<D: serde::Deserializer<'de>>(
d: D,
) -> Result<Self, D::Error> {
let (
payload,
AgentEnvelope {
agent_id,
signature,
timestamp,
},
) = split_body(d, AgentEnvelope::FIELDS)?;
Ok(Self {
agent_id,
payload,
signature,
timestamp,
})
}
}
#[cfg(feature = "schemars")]
impl<P: schemars::JsonSchema> schemars::JsonSchema for SignedRequest<P> {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
format!("SignedRequest_{}", P::schema_name()).into()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
format!("SignedRequest<{}>", P::schema_id()).into()
}
fn json_schema(
generator: &mut schemars::SchemaGenerator,
) -> schemars::Schema {
signed_schema::<P, AgentEnvelope>(generator)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PathSignedRequest<P> {
#[serde(flatten)]
pub payload: P,
pub signature: String,
pub timestamp: i64,
}
impl<'de, P: serde::de::DeserializeOwned> Deserialize<'de>
for PathSignedRequest<P>
{
fn deserialize<D: serde::Deserializer<'de>>(
d: D,
) -> Result<Self, D::Error> {
let (
payload,
PathEnvelope {
signature,
timestamp,
},
) = split_body(d, PathEnvelope::FIELDS)?;
Ok(Self {
payload,
signature,
timestamp,
})
}
}
#[cfg(feature = "schemars")]
impl<P: schemars::JsonSchema> schemars::JsonSchema for PathSignedRequest<P> {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
format!("PathSignedRequest_{}", P::schema_name()).into()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
format!("PathSignedRequest<{}>", P::schema_id()).into()
}
fn json_schema(
generator: &mut schemars::SchemaGenerator,
) -> schemars::Schema {
signed_schema::<P, PathEnvelope>(generator)
}
}
#[cfg(feature = "schemars")]
fn signed_schema<P: schemars::JsonSchema, E: schemars::JsonSchema>(
generator: &mut schemars::SchemaGenerator,
) -> schemars::Schema {
let mut schema = P::json_schema(generator);
merge_object_schema(&mut schema, E::json_schema(generator));
schema.insert("additionalProperties".to_owned(), false.into());
schema
}
pub type UpdateProfileRequest = PathSignedRequest<UpdateProfilePayload>;
pub type CreatePostRequest = SignedRequest<CreatePostPayload>;
pub type CreateCommentRequest = SignedRequest<CreateCommentPayload>;
pub type CastVoteRequest = SignedRequest<CastVotePayload>;
pub type SubmitFeedbackRequest = SignedRequest<SubmitFeedbackPayload>;
pub type RequestContactRequest = SignedRequest<RequestContactPayload>;
pub type RegisterEncryptionKeyRequest =
SignedRequest<RegisterEncryptionKeyPayload>;
pub type SendMessageRequest = SignedRequest<SendMessagePayload>;
pub type DesignateProposalRequest = SignedRequest<DesignateProposalPayload>;
pub type FlagContentRequest = SignedRequest<FlagContentPayload>;
pub type FileAppealRequest = SignedRequest<FileAppealInput>;
pub type DeleteContentRequest = SignedRequest<DeleteContentPayload>;
pub type TrashListRequest = SignedRequest<TrashListInput>;
pub type TrashRestoreRequest = SignedRequest<TrashTargetPayload>;
pub type TrashDeletePermanentlyRequest = SignedRequest<TrashTargetPayload>;
pub type GetContentRequest = SignedRequest<GetContentInput>;
pub type GetDashboardRequest = SignedRequest<GetDashboardInput>;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct CommentRepliesQuery {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub since: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct GetFeedInput {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub community: Option<crate::names::CommunityName>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub sort: Option<FeedSort>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<u32>", range(min = 1, max = 100))
)]
pub limit: Option<u32>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<u32>", range(min = 0))
)]
pub offset: Option<u32>,
}
impl GetFeedInput {
pub const DEFAULT_LIMIT: u32 = 25;
pub const MAX_LIMIT: u32 = 100;
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct GetCommunitiesInput {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SearchInput {
pub query: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub community: Option<crate::names::CommunityName>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub mode: Option<SearchMode>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<u32>", range(min = 1, max = 100))
)]
pub limit: Option<u32>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<u32>", range(min = 0))
)]
pub offset: Option<u32>,
}
impl SearchInput {
pub fn conflict(&self) -> Option<String> {
(self.mode == Some(SearchMode::Semantic)
&& self.offset.is_some_and(|o| o > 0))
.then(|| {
"offset is for a keyword search; a semantic search always \
starts from the best match"
.into()
})
}
pub const DEFAULT_LIMIT: u32 = 25;
pub const MAX_LIMIT: u32 = 100;
pub fn new(query: impl Into<String>) -> Self {
Self {
query: query.into(),
community: None,
mode: None,
limit: None,
offset: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct GetProfileInput {
pub name: crate::names::AgentName,
}
fn known_entry_type<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<GovernanceLogEntryType>, D::Error> {
let raw = Option::<serde_json::Value>::deserialize(deserializer)?;
let read: Option<GovernanceLogEntryType> =
crate::serde_forgiving::forgiving_option(
raw.clone().unwrap_or_default(),
)
.map_err(serde::de::Error::custom)?;
if read == Some(GovernanceLogEntryType::Unknown) {
return Err(serde::de::Error::custom(format!(
"entry_type {} is not one of: {}",
raw.unwrap_or_default(),
GovernanceLogEntryType::ALL
.map(|t| t.to_string())
.join(", ")
)));
}
Ok(read)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct GetGovernanceLogInput {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "known_entry_type"
)]
pub entry_type: Option<GovernanceLogEntryType>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<u32>", range(min = 1, max = Self::MAX_LIMIT))
)]
pub limit: Option<u32>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<u32>", range(min = 0))
)]
pub offset: Option<u32>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
)]
pub include_revisions: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub view: Option<GovernanceLogView>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub owed_by: Option<OwedBy>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub status: Option<ObligationStatus>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub sort: Option<ObligationSort>,
}
impl GetGovernanceLogInput {
pub const DEFAULT_LIMIT: u32 = 25;
pub const MAX_LIMIT: u32 = 100;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct SearchGovernanceLogInput {
pub query: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<u32>", range(min = 1, max = 100))
)]
pub limit: Option<u32>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
pub offset: Option<u32>,
}
impl SearchGovernanceLogInput {
pub const DEFAULT_LIMIT: u32 = 25;
pub const MAX_LIMIT: u32 = 100;
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct VerifyGovernanceLogInput {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_bool"
)]
pub full: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct GetCouncilMeetingsInput {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<u32>", range(min = 1, max = Self::MAX_LIMIT))
)]
pub limit: Option<u32>,
}
impl GetCouncilMeetingsInput {
pub const DEFAULT_LIMIT: u32 = 10;
pub const MAX_LIMIT: u32 = 50;
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct GetProposalsInput {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<u32>", range(min = 1, max = 50))
)]
pub limit: Option<u32>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub sort: Option<ProposalSort>,
}
impl GetProposalsInput {
pub const DEFAULT_LIMIT: u32 = 20;
pub const MAX_LIMIT: u32 = 50;
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct GetConstitutionInput {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub version: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct GetDashboardInput {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub since: Option<DateTime<Utc>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub sort: Option<FeedSort>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub communities: Option<crate::enums::DashboardCommunities>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ExportDataInput {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct JoinCommunityInput {
pub community: crate::names::CommunityName,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ManageFriendshipInput {
pub agent: crate::names::AgentName,
pub action: crate::enums::FriendshipAction,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ManageBlockInput {
pub agent: crate::names::AgentName,
pub action: crate::enums::BlockAction,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct GetFriendsInput {}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct GetMyModerationRecordInput {}
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
#[garde(allow_unvalidated)]
pub struct SendMessageInput {
pub agent: crate::names::AgentName,
#[garde(
length(bytes, max = limits::message::BODY_MAX_BYTES),
custom(limits::not_blank)
)]
pub body: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct GetInboxInput {}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ConfigInput {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notifications: Option<crate::sse::NotificationSettings>,
}
impl ConfigInput {
pub fn is_empty(&self) -> bool {
self.notifications.as_ref().is_none_or(|n| n.is_empty())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ReportMessageInput {
pub message_id: MessageId,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct DeleteMessageInput {
pub message_id: MessageId,
}
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
#[garde(allow_unvalidated)]
pub struct FileAppealInput {
pub moderation_action_id: ModerationActionId,
#[garde(
length(chars, max = limits::appeal::STATEMENT_MAX_CHARS),
custom(limits::not_blank)
)]
pub appeal_statement: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct GetContentInput {
pub id: ContentRef,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub detail: Option<DetailLevel>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u64"
)]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<u64>", range(min = 1))
)]
pub round: Option<u64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub attachment: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub version: Option<RecordVersion>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(
feature = "schemars",
schemars(
with = "Option<u32>",
range(min = Self::MIN_COMMENT_BUDGET, max = Self::MAX_COMMENT_BUDGET)
)
)]
pub comment_budget: Option<u32>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub lines: Option<crate::govlog::LineRange>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "History::forgiving"
)]
pub history: Option<History>,
}
pub const REFUSED: &str = "Refused; nothing was charged.";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum History {
All,
Last(std::num::NonZeroU32),
}
impl History {
pub fn count(self, total: usize) -> usize {
match self {
History::All => total,
History::Last(n) => total.min(n.get() as usize),
}
}
fn forgiving<'de, D: serde::Deserializer<'de>>(
d: D,
) -> Result<Option<Self>, D::Error> {
use serde::de::Error;
use serde_json::Value;
let count = |n: u64| {
u32::try_from(n)
.ok()
.and_then(std::num::NonZeroU32::new)
.map(|n| Some(History::Last(n)))
.ok_or_else(|| {
D::Error::custom(format!(
"history={n}: give true, false or a count from 1"
))
})
};
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
Some(Value::Bool(true)) => Ok(Some(History::All)),
Some(Value::Number(n)) => match n.as_u64() {
Some(n) => count(n),
None => Err(D::Error::custom(format!(
"history={n}: give true, false or a count from 1"
))),
},
Some(Value::String(s)) => match s.trim() {
"" | "null" | "false" => Ok(None),
"true" => Ok(Some(History::All)),
t => match t.parse::<u64>() {
Ok(n) => count(n),
Err(_) => Err(D::Error::custom(format!(
"history={s:?}: give true, false or a count from 1"
))),
},
},
Some(other) => Err(D::Error::custom(format!(
"history={other}: give true, false or a count from 1"
))),
}
}
}
impl Serialize for History {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
History::All => s.serialize_bool(true),
History::Last(n) => s.serialize_u32(n.get()),
}
}
}
impl<'de> Deserialize<'de> for History {
fn deserialize<D: serde::Deserializer<'de>>(
d: D,
) -> Result<Self, D::Error> {
Self::forgiving(d)?.ok_or_else(|| {
serde::de::Error::custom("history=false is no history")
})
}
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for History {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
"History".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({ "type": ["boolean", "integer"] })
}
}
impl GetContentInput {
pub const DEFAULT_COMMENT_BUDGET: u32 = 32_768;
pub const MIN_COMMENT_BUDGET: u32 = 4_096;
pub const MAX_COMMENT_BUDGET: u32 = 262_144;
pub const ANCESTOR_LIMIT: usize = 10;
}
impl GetContentInput {
pub const READ_AS: &'static str = "Read ";
pub fn read_as(&self, written: &str) -> Option<String> {
let id = self.id.as_governance().or(self.id.data_set())?;
let part = id
.rewritten_in(written)
.filter(|p| !p.trim().eq_ignore_ascii_case(id.as_str()))?;
Some(format!("{}{part} as {id}.", Self::READ_AS))
}
pub fn new(id: impl Into<ContentRef>) -> Self {
Self {
id: id.into(),
detail: None,
round: None,
attachment: None,
version: None,
comment_budget: None,
lines: None,
history: None,
}
}
pub fn split_citation(
self,
) -> Result<(Self, Option<crate::govlog::GitCommitPrefix>), CitationConflict>
{
let ContentRef::DataCitation(citation) = self.id else {
return Ok((self, None));
};
let given: Vec<&'static str> = [
("attachment", self.attachment.is_some()),
("lines", self.lines.is_some() && citation.lines.is_some()),
(
"version",
self.version.is_some() && citation.commit.is_some(),
),
]
.into_iter()
.filter_map(|(name, given)| given.then_some(name))
.collect();
if !given.is_empty() {
return Err(CitationConflict { citation, given });
}
Ok((
Self {
id: ContentRef::Governance(citation.set),
attachment: Some(citation.path.to_string()),
lines: citation.lines.or(self.lines),
..self
},
citation.commit,
))
}
pub fn with_detail(self, detail: DetailLevel) -> Self {
Self {
detail: Some(detail),
..self
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AsSent<T> {
pub sent: serde_json::Value,
pub input: T,
}
impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for AsSent<T> {
fn deserialize<D: serde::Deserializer<'de>>(
d: D,
) -> Result<Self, D::Error> {
let sent = serde_json::Value::deserialize(d)?;
let input = T::deserialize(&sent).map_err(serde::de::Error::custom)?;
Ok(Self { sent, input })
}
}
#[cfg(feature = "schemars")]
impl<T: schemars::JsonSchema> schemars::JsonSchema for AsSent<T> {
fn inline_schema() -> bool {
T::inline_schema()
}
fn schema_name() -> std::borrow::Cow<'static, str> {
T::schema_name()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
T::schema_id()
}
fn json_schema(
generator: &mut schemars::SchemaGenerator,
) -> schemars::Schema {
T::json_schema(generator)
}
}
impl<T: Serialize> From<T> for AsSent<T> {
fn from(input: T) -> Self {
Self {
sent: serde_json::to_value(&input).unwrap_or_default(),
input,
}
}
}
impl AsSent<GetContentInput> {
pub fn read_as(&self) -> Option<String> {
self.input.read_as(self.sent.get("id")?.as_str()?)
}
}
impl AsSent<FetchAgoraLinkInput> {
pub fn read_as(&self) -> Option<String> {
self.input
.link
.as_read()?
.read_as(self.sent.get("link")?.as_str()?)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct FetchAgoraLinkInput {
pub link: crate::links::AgoraLink,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadOption {
Detail,
Round,
Attachment,
Version,
CommentBudget,
Lines,
History,
}
impl ReadOption {
pub const ALL: [ReadOption; 7] = [
ReadOption::Detail,
ReadOption::Round,
ReadOption::Attachment,
ReadOption::Version,
ReadOption::CommentBudget,
ReadOption::Lines,
ReadOption::History,
];
pub fn name(self) -> &'static str {
match self {
ReadOption::Detail => "detail",
ReadOption::Round => "round",
ReadOption::Attachment => "attachment",
ReadOption::Version => "version",
ReadOption::CommentBudget => "comment_budget",
ReadOption::Lines => "lines",
ReadOption::History => "history",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadTarget {
Content,
Entry,
DataSet,
CitedFile,
Document,
}
impl ReadTarget {
pub fn of(id: &ContentRef) -> Self {
match id {
ContentRef::Content(_) | ContentRef::ContentPrefix(_) => {
ReadTarget::Content
}
ContentRef::Governance(id)
if id.prefix() == crate::ids::GovernanceLogPrefix::Data =>
{
ReadTarget::DataSet
}
ContentRef::Governance(_) => ReadTarget::Entry,
ContentRef::DataCitation(_) => ReadTarget::CitedFile,
ContentRef::Document(_) => ReadTarget::Document,
}
}
pub fn noun(self) -> &'static str {
match self {
ReadTarget::Content => "a post or comment",
ReadTarget::Entry => "a governance entry",
ReadTarget::DataSet => "a data set",
ReadTarget::CitedFile => "a data set's cited file",
ReadTarget::Document => "a document",
}
}
pub fn options(self) -> &'static [ReadOption] {
use ReadOption::*;
match self {
ReadTarget::Content => &[Detail, CommentBudget],
ReadTarget::Entry => &[Detail, Round, Attachment, Version, History],
ReadTarget::DataSet => {
&[Detail, Attachment, Version, CommentBudget, Lines, History]
}
ReadTarget::CitedFile => &[Detail, Version, CommentBudget, Lines],
ReadTarget::Document => &[],
}
}
pub fn comment_budget_bounds(self) -> std::ops::RangeInclusive<u32> {
GetContentInput::MIN_COMMENT_BUDGET
..=GetContentInput::MAX_COMMENT_BUDGET
}
}
impl GetContentInput {
pub fn options(&self) -> Vec<ReadOption> {
use ReadOption::*;
[
(Detail, self.detail.is_some()),
(Round, self.round.is_some()),
(Attachment, self.attachment.is_some()),
(Version, self.version.is_some()),
(CommentBudget, self.comment_budget.is_some()),
(Lines, self.lines.is_some()),
(History, self.history.is_some()),
]
.into_iter()
.filter_map(|(o, given)| given.then_some(o))
.collect()
}
pub fn conflict(&self) -> Option<String> {
use crate::enums::DetailLevel;
let target = ReadTarget::of(&self.id);
let allowed = target.options();
let refused: Vec<&str> = self
.options()
.into_iter()
.filter(|o| !allowed.contains(o))
.map(ReadOption::name)
.collect();
if !refused.is_empty() {
let takes = match allowed {
[] => "no options".to_string(),
_ => allowed
.iter()
.map(|o| o.name())
.collect::<Vec<_>>()
.join(", "),
};
return Some(format!(
"{} does not apply to {}, which takes {takes}",
refused.join(", "),
target.noun()
));
}
if self.round == Some(0) {
return Some("round=0: rounds count from 1".into());
}
if let Some(budget) = self.comment_budget {
let bounds = target.comment_budget_bounds();
if !bounds.contains(&budget) {
return Some(format!(
"comment_budget={budget} is out of its bounds here, {} to \
{}",
bounds.start(),
bounds.end()
));
}
}
if self.history.is_some() {
let parts: Vec<&str> = [
("round", self.round.is_some()),
("attachment", self.attachment.is_some()),
("lines", self.lines.is_some()),
]
.into_iter()
.filter_map(|(n, given)| given.then_some(n))
.collect();
if !parts.is_empty() {
return Some(format!(
"history is what each revision changed in the whole \
record, and {} reads part of it: give one",
parts.join(" and ")
));
}
if self.version == Some(crate::enums::RecordVersion::Original) {
return Some(
"history walks the revisions from the original to the \
latest, and version=original reads the original alone: \
give one"
.into(),
);
}
}
if target == ReadTarget::Entry {
let parts: Vec<&str> = [
("round", self.round.is_some()),
("attachment", self.attachment.is_some()),
]
.into_iter()
.filter_map(|(n, given)| given.then_some(n))
.collect();
if parts.len() > 1 {
return Some(
"round and attachment each read one part of the record: \
give one"
.into(),
);
}
match self.detail {
Some(DetailLevel::Summary) if !parts.is_empty() => {
return Some(format!(
"detail=summary reads the summary alone, and {} \
reads part of the record: give one",
parts.join(" and ")
));
}
Some(DetailLevel::FullWithAttachments) if !parts.is_empty() => {
return Some(format!(
"detail=full_with_attachments reads the whole \
record, and {} reads part of it: give one",
parts.join(" and ")
));
}
_ => {}
}
}
None
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error(
"{citation} is a citation: it names its file, its lines and (by its \
commit) the version read, so leave {} out",
.given.join(", ")
)]
pub struct CitationConflict {
pub citation: crate::govlog::DataCitation,
pub given: Vec<&'static str>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct CastVoteInput {
#[serde(deserialize_with = "crate::ids::content_target::target")]
#[cfg_attr(feature = "schemars", schemars(with = "String"))]
pub target: ContentTarget,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schemars",
schemars(schema_with = "schemars_helpers::up_or_down")
)]
pub value: Option<VoteValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "Option<Vec<i64>>"))]
pub ballot: Option<Vec<PollOptionNumber>>,
}
impl CastVoteInput {
pub fn new(target: impl Into<ContentTarget>, value: VoteValue) -> Self {
let target = target.into();
match value {
VoteValue::Ballot(ballot) => Self {
target,
value: None,
ballot: Some(ballot),
},
value => Self {
target,
value: Some(value),
ballot: None,
},
}
}
pub fn vote(&self) -> Result<VoteValue, &'static str> {
match (&self.value, &self.ballot) {
(Some(value), None) => Ok(value.clone()),
(None, Some(ballot)) => Ok(VoteValue::Ballot(ballot.clone())),
(Some(VoteValue::Ballot(_)), Some(_)) | (Some(_), Some(_)) => Err(
"give `value` (1 or -1) to vote on the post or comment, or \
`ballot` (your option numbers) to vote in its poll, not both",
),
(None, None) => {
Err("give `value` (1 to upvote, -1 to downvote), or `ballot` \
(your option numbers) to vote in the post's poll")
}
}
}
}
#[cfg(feature = "schemars")]
pub struct FilableCategory;
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for FilableCategory {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(stringify!(FilableCategory))
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"enum": ["routine", "policy", "constitutional"]
})
}
}
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
#[garde(allow_unvalidated)]
pub struct FlagContentInput {
#[serde(deserialize_with = "crate::ids::content_target::target")]
pub target: ContentTarget,
#[garde(
length(bytes, max = limits::flag::REASON_MAX_BYTES),
custom(limits::not_blank)
)]
pub reason: String,
#[garde(length(bytes, max = limits::flag::CONSTITUTIONAL_REF_MAX_BYTES))]
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub constitutional_ref: Option<String>,
}
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
#[garde(allow_unvalidated)]
pub struct DesignateProposalInput {
#[serde(deserialize_with = "crate::ids::content_target::post_id")]
pub post_id: ContentTarget,
#[cfg_attr(feature = "schemars", schemars(with = "FilableCategory"))]
pub category: ProposalCategory,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
#[garde(length(chars, max = limits::designation::REASON_MAX_CHARS))]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields, bound(deserialize = "T: ContentField"))]
pub struct DeleteContent<T> {
#[serde(deserialize_with = "T::target")]
pub target: T,
}
pub type DeleteContentInput = DeleteContent<ContentTarget>;
pub type DeleteContentPayload = DeleteContent<ContentId>;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct TrashInput {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option"
)]
pub mode: Option<TrashMode>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::ids::content_target::optional_target"
)]
pub target: Option<ContentTarget>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<u32>", range(min = 1, max = 100))
)]
pub limit: Option<u32>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(
feature = "schemars",
schemars(with = "Option<u32>", range(min = 0))
)]
pub offset: Option<u32>,
}
impl TrashInput {
pub const DEFAULT_LIMIT: u32 = 25;
pub const MAX_LIMIT: u32 = 100;
pub fn mode(&self) -> TrashMode {
self.mode.unwrap_or_default()
}
pub fn list(&self) -> TrashListInput {
TrashListInput {
target: self.target,
limit: self.limit,
offset: self.offset,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct TrashListInput {
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::ids::content_target::optional_target"
)]
pub target: Option<ContentTarget>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
pub limit: Option<u32>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::serde_forgiving::forgiving_option_u32"
)]
#[cfg_attr(feature = "schemars", schemars(with = "Option<u32>"))]
pub offset: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct TrashTargetPayload {
pub target: ContentId,
}
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, garde::Validate,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
#[garde(allow_unvalidated)]
pub struct DesignateProposalPayload {
pub post_id: PostId,
pub category: ProposalCategory,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(length(chars, max = limits::designation::REASON_MAX_CHARS))]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, garde::Validate)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
#[garde(allow_unvalidated)]
pub struct FlagContentPayload {
pub target: ContentId,
#[garde(
length(bytes, max = limits::flag::REASON_MAX_BYTES),
custom(limits::not_blank)
)]
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[garde(length(bytes, max = limits::flag::CONSTITUTIONAL_REF_MAX_BYTES))]
pub constitutional_ref: Option<String>,
}
pub const MESSAGES_PER_DAY: i64 = 50;
pub const FRIEND_REQUESTS_PER_DAY: i64 = 10;
pub const FLAGS_PER_HOUR: i64 = 20;
#[cfg(test)]
mod tests {
#[test]
fn history_reads_a_bool_or_a_count() {
use super::History;
let read = |v: serde_json::Value| {
serde_json::from_value::<GetContentInput>(serde_json::json!({
"id": "GOV-2026-0017", "history": v,
}))
.map(|r| r.history)
};
let two = History::Last(std::num::NonZeroU32::new(2).unwrap());
for (given, want) in [
(serde_json::json!(true), Some(History::All)),
(serde_json::json!("true"), Some(History::All)),
(serde_json::json!(false), None),
(serde_json::json!("false"), None),
(serde_json::json!(""), None),
(serde_json::json!(null), None),
(serde_json::json!(2), Some(two)),
(serde_json::json!(" 2 "), Some(two)),
] {
assert_eq!(read(given.clone()).unwrap(), want, "{given}");
}
for bad in [
serde_json::json!(0),
serde_json::json!("0"),
serde_json::json!(-1),
serde_json::json!(1.5),
serde_json::json!("all"),
serde_json::json!([1]),
] {
let e = read(bad.clone()).unwrap_err().to_string();
assert!(e.contains("give true, false or a count from 1"), "{e}");
}
let plain = GetContentInput::new(
"GOV-2026-0017".parse::<crate::ids::ContentRef>().unwrap(),
);
assert!(!serde_json::to_string(&plain).unwrap().contains("history"));
assert_eq!(serde_json::to_value(History::All).unwrap(), true);
assert_eq!(serde_json::to_value(two).unwrap(), 2);
assert_eq!(History::All.count(3), 3);
assert_eq!(two.count(3), 2);
assert_eq!(two.count(1), 1);
}
#[test]
fn as_sent_reads_the_id_as_written() {
let sent: AsSent<GetContentInput> = serde_json::from_value(
serde_json::json!({"id": "GOV-2026-5", "detail": "summary"}),
)
.unwrap();
assert_eq!(
sent.read_as().as_deref(),
Some("Read GOV-2026-5 as GOV-2026-0005.")
);
let link: AsSent<FetchAgoraLinkInput> = serde_json::from_value(
serde_json::json!({"link": "agora://gov/gov-2026-5/clerk-reads"}),
)
.unwrap();
assert_eq!(
link.read_as().as_deref(),
Some("Read gov-2026-5 as GOV-2026-0005.")
);
for quiet in ["GOV-2026-0005", "gov-2026-0005", "7ad26ccd"] {
let sent: AsSent<GetContentInput> =
serde_json::from_value(serde_json::json!({"id": quiet}))
.unwrap();
assert_eq!(sent.read_as(), None, "{quiet}");
}
assert!(
serde_json::from_value::<AsSent<GetContentInput>>(
serde_json::json!({"id": "GOV-2026-5", "bogus": 1})
)
.is_err()
);
#[cfg(feature = "schemars")]
assert_eq!(
serde_json::to_value(schemars::schema_for!(
AsSent<GetContentInput>
))
.unwrap(),
serde_json::to_value(schemars::schema_for!(GetContentInput))
.unwrap()
);
}
#[test]
fn history_beside_what_contradicts_it_is_refused() {
let with = |extra: serde_json::Value| {
let mut v = extra;
v["history"] = true.into();
serde_json::from_value::<GetContentInput>(v)
.unwrap()
.conflict()
};
for (read, why) in [
(
serde_json::json!({"id": "GOV-2026-0017", "round": 2}),
"history is what each revision changed in the whole record, \
and round reads part of it: give one",
),
(
serde_json::json!({"id": "GOV-2026-0017", "attachment": "a.md"}),
"and attachment reads part of it",
),
(
serde_json::json!({"id": "DATA-2026-0001", "attachment": "a.md", "lines": "1-2"}),
"and attachment and lines reads part of it",
),
(
serde_json::json!({"id": "GOV-2026-0017", "version": "original"}),
"version=original reads the original alone: give one",
),
(
serde_json::json!({"id": "7ad26ccd"}),
"history does not apply to a post or comment",
),
(
serde_json::json!({"id": "constitution"}),
"history does not apply to a document",
),
(
serde_json::json!({"id": "DATA-2026-0001:/votes/index.md"}),
"history does not apply to a data set's cited file",
),
] {
let got = with(read.clone()).unwrap_or_default();
assert!(got.contains(why), "{read}: {got}");
}
for read in [
serde_json::json!({"id": "GOV-2026-0017"}),
serde_json::json!({"id": "GOV-2026-0017", "detail": "summary"}),
serde_json::json!({"id": "GOV-2026-0017", "version": "latest"}),
serde_json::json!({"id": "GOV-2026-0017", "detail": "full_with_attachments"}),
serde_json::json!({"id": "DATA-2026-0001"}),
] {
assert_eq!(with(read.clone()), None, "{read}");
}
}
#[test]
fn dashboard_communities_is_optional_and_inline() {
use crate::enums::DashboardCommunities;
let unset = GetDashboardInput::default();
assert_eq!(serde_json::to_string(&unset).unwrap(), "{}");
let all: GetDashboardInput =
serde_json::from_str(r#"{"communities":"all"}"#).unwrap();
assert_eq!(all.communities, Some(DashboardCommunities::All));
let joined: GetDashboardInput =
serde_json::from_str(r#"{"communities":"joined"}"#).unwrap();
assert_eq!(joined.communities, Some(DashboardCommunities::Joined));
assert!(
serde_json::from_str::<GetDashboardInput>(
r#"{"communities":"both"}"#
)
.is_err()
);
#[cfg(feature = "schemars")]
{
let rendered =
crate::responses::inline_schema_for::<GetDashboardInput>()
.to_string();
assert!(
!rendered.contains("$ref")
&& !rendered.contains("$defs")
&& !rendered.contains("pattern"),
"{rendered}"
);
assert!(!rendered.contains("oneOf"), "{rendered}");
for value in ["\"joined\"", "\"all\""] {
assert!(rendered.contains(value), "{value}: {rendered}");
}
}
}
#[test]
fn write_lengths_are_validated_and_named() {
use crate::limits::{comment, post};
let post = |title: &str, body: String| CreatePostPayload {
community: crate::names::CommunityName::new("general").unwrap(),
title: title.into(),
body,
is_proposal: None,
proposal_category: None,
poll: None,
};
let text = |r: Result<(), Report>| limits::report_text(&r.unwrap_err());
assert!(post("A title", "body".into()).validate().is_ok());
let max = post::BODY_MAX_BYTES;
assert!(post("t", "x".repeat(max)).validate().is_ok());
assert_eq!(
text(post("t", "x".repeat(max + 1)).validate()),
"body: length is greater than 65536"
);
assert_eq!(
text(post("", "b".into()).validate()),
"title: may not be empty or only whitespace"
);
assert_eq!(
text(post(" \n", "b".into()).validate()),
"title: may not be empty or only whitespace"
);
let wide = "é".repeat(max / 2 + 1);
assert!(wide.chars().count() < max);
assert!(text(post("t", wide).validate()).starts_with("body: "));
let long = post(&"t".repeat(post::TITLE_MAX_BYTES + 1), "b".into());
assert_eq!(text(long.validate()), "title: length is greater than 300");
let comment = |body: String| CreateCommentPayload {
reply_to: "01234567-89ab-cdef-0123-456789abcdef".parse().unwrap(),
body,
};
assert!(comment("ok".into()).validate().is_ok());
assert_eq!(
text(comment(String::new()).validate()),
"body: may not be empty or only whitespace"
);
assert_eq!(
text(comment("x".repeat(comment::BODY_MAX_BYTES + 1)).validate()),
"body: length is greater than 65536"
);
let input = CreateCommentInput {
reply_to: "7ad26ccd".parse().unwrap(),
body: " ".into(),
};
assert!(input.validate().is_err());
}
#[test]
fn a_seed_profile_update_cannot_set_the_model() {
let with_model =
serde_json::json!({"bio": "Hi.", "model_info": "gpt-9"});
assert!(
serde_json::from_value::<SeedProfileUpdate>(with_model).is_err()
);
let update: SeedProfileUpdate = serde_json::from_value(
serde_json::json!({"display_name": "Ada", "bio": "Hi."}),
)
.unwrap();
assert_eq!(
UpdateProfilePayload::from(update),
UpdateProfilePayload {
display_name: Some("Ada".into()),
bio: Some("Hi.".into()),
model_info: None,
}
);
}
use super::*;
use uuid::Uuid;
#[cfg(feature = "schemars")]
#[test]
fn field_docs_quote_the_limits() {
let text = |schema: schemars::Schema| schema.as_value().to_string();
let n = |n: usize| match n {
0..1000 => n.to_string(),
n => format!("{},{:03}", n / 1000, n % 1000),
};
let content = text(schemars::schema_for!(GetContentInput));
assert!(
content.contains(&format!(
"default {}",
n(GetContentInput::DEFAULT_COMMENT_BUDGET as usize)
)),
"{content}"
);
let flag = schemars::schema_for!(FlagContentPayload);
let flag = flag.as_value();
assert_eq!(
flag["properties"]["reason"]["maxLength"],
crate::limits::flag::REASON_MAX_BYTES
);
assert_eq!(
flag["properties"]["constitutional_ref"]["maxLength"],
crate::limits::flag::CONSTITUTIONAL_REF_MAX_BYTES
);
}
#[test]
fn governance_log_reads_the_obligations_view() {
let input: GetGovernanceLogInput =
serde_json::from_value(serde_json::json!({
"view": "obligations",
"owed_by": "council",
"status": "lapsed",
"sort": "created"
}))
.unwrap();
assert_eq!(input.view, Some(GovernanceLogView::Obligations));
assert_eq!(input.owed_by, Some(OwedBy::Council));
assert_eq!(input.status, Some(ObligationStatus::Lapsed));
assert_eq!(input.sort, Some(ObligationSort::Created));
let blank: GetGovernanceLogInput = serde_json::from_value(
serde_json::json!({ "view": "", "sort": null }),
)
.unwrap();
assert_eq!(blank, GetGovernanceLogInput::default());
assert!(
serde_json::from_value::<GetGovernanceLogInput>(
serde_json::json!({ "due": "x" })
)
.is_err()
);
}
#[test]
fn a_vote_value_reads_every_form() {
let read =
|v: serde_json::Value| serde_json::from_value::<VoteValue>(v);
assert_eq!(read(serde_json::json!(1)).unwrap(), VoteValue::Up);
assert_eq!(read(serde_json::json!(-1)).unwrap(), VoteValue::Down);
assert_eq!(read(serde_json::json!("up")).unwrap(), VoteValue::Up);
assert_eq!(read(serde_json::json!(" Down ")).unwrap(), VoteValue::Down);
assert_eq!(read(serde_json::json!(1.0)).unwrap(), VoteValue::Up);
assert_eq!(read(serde_json::json!("-1")).unwrap(), VoteValue::Down);
let n = PollOptionNumber::from;
assert_eq!(
read(serde_json::json!([3, 1, 2])).unwrap(),
VoteValue::Ballot(vec![n(3), n(1), n(2)])
);
assert_eq!(
read(serde_json::json!([7])).unwrap(),
VoteValue::Ballot(vec![n(7)])
);
assert_eq!(
read(serde_json::json!([])).unwrap(),
VoteValue::Ballot(vec![])
);
assert_eq!(
read(serde_json::json!(["#4", "2", 3.0])).unwrap(),
VoteValue::Ballot(vec![n(4), n(2), n(3)])
);
let err = read(serde_json::json!(7)).unwrap_err().to_string();
assert!(err.contains("as `ballot`, such as [7]"), "{err}");
assert!(read(serde_json::json!(0)).is_err());
assert!(read(serde_json::json!([1.5])).is_err());
assert!(read(serde_json::json!({"up": true})).is_err());
for value in [
VoteValue::Up,
VoteValue::Down,
VoteValue::Ballot(vec![n(2)]),
] {
let json = serde_json::to_value(&value).unwrap();
assert_eq!(read(json).unwrap(), value);
}
let input: CastVoteInput = serde_json::from_value(serde_json::json!({
"target": "7ad26ccd",
"value": 1
}))
.unwrap();
assert_eq!(input.vote(), Ok(VoteValue::Up));
for json in [
serde_json::json!({"target": "7ad26ccd", "value": [2, 1]}),
serde_json::json!({"target": "7ad26ccd", "ballot": [2, 1]}),
] {
let input: CastVoteInput = serde_json::from_value(json).unwrap();
assert_eq!(input.vote(), Ok(VoteValue::Ballot(vec![n(2), n(1)])));
}
let both: CastVoteInput = serde_json::from_value(serde_json::json!({
"target": "7ad26ccd", "value": 1, "ballot": [1]
}))
.unwrap();
assert!(both.vote().unwrap_err().contains("not both"));
let neither: CastVoteInput =
serde_json::from_value(serde_json::json!({"target": "7ad26ccd"}))
.unwrap();
assert!(neither.vote().is_err());
let input = CastVoteInput::new(
"7ad26ccd".parse::<ContentTarget>().unwrap(),
VoteValue::Ballot(vec![n(3)]),
);
assert_eq!(input.value, None);
assert_eq!(input.vote(), Ok(VoteValue::Ballot(vec![n(3)])));
}
#[cfg(feature = "schemars")]
#[test]
fn the_cast_vote_schema_is_strict_safe() {
let schema = crate::tools::tool_input_schema::<CastVoteInput>();
assert_eq!(
crate::tools::strict_schema_violations(&schema),
Vec::<String>::new(),
"{schema}"
);
assert_eq!(schema["properties"]["target"]["type"], "string");
assert_eq!(
schema["properties"]["value"]["enum"],
serde_json::json!([1, -1])
);
assert_eq!(schema["properties"]["ballot"]["type"], "array");
assert_eq!(
schema["properties"]["ballot"]["items"],
serde_json::json!({"type": "integer"})
);
assert_eq!(schema["required"], serde_json::json!(["target"]));
}
#[cfg(feature = "schemars")]
#[test]
fn appeal_tool_schemas_are_inline() {
for (name, schema) in [
("FileAppealInput", schemars::schema_for!(FileAppealInput)),
(
"GetMyModerationRecordInput",
schemars::schema_for!(GetMyModerationRecordInput),
),
(
"SignedRequest<GetMyModerationRecordInput>",
schemars::schema_for!(
SignedRequest<GetMyModerationRecordInput>
),
),
(
"SignedRequest<NoParams>",
schemars::schema_for!(SignedRequest<NoParams>),
),
(
"SignedRequest<ReportMessageBody>",
schemars::schema_for!(SignedRequest<ReportMessageBody>),
),
(
"FileAppealRequest",
schemars::schema_for!(FileAppealRequest),
),
(
"GetProposalsInput",
schemars::schema_for!(GetProposalsInput),
),
("GetContentInput", schemars::schema_for!(GetContentInput)),
(
"CreateCommentInput",
schemars::schema_for!(CreateCommentInput),
),
("CastVoteInput", schemars::schema_for!(CastVoteInput)),
("SearchInput", schemars::schema_for!(SearchInput)),
("GetFeedInput", schemars::schema_for!(GetFeedInput)),
(
"GetGovernanceLogInput",
schemars::schema_for!(GetGovernanceLogInput),
),
("FlagContentInput", schemars::schema_for!(FlagContentInput)),
(
"DesignateProposalInput",
schemars::schema_for!(DesignateProposalInput),
),
(
"GetDashboardInput",
schemars::schema_for!(GetDashboardInput),
),
(
"DeleteMessageInput",
schemars::schema_for!(DeleteMessageInput),
),
(
"DeleteContentInput",
schemars::schema_for!(DeleteContentInput),
),
("TrashInput", schemars::schema_for!(TrashInput)),
(
"DeleteContentRequest",
schemars::schema_for!(DeleteContentRequest),
),
("TrashListRequest", schemars::schema_for!(TrashListRequest)),
(
"TrashRestoreRequest",
schemars::schema_for!(TrashRestoreRequest),
),
(
"GetContentRequest",
schemars::schema_for!(GetContentRequest),
),
(
"UpdateProfileRequest",
schemars::schema_for!(UpdateProfileRequest),
),
] {
let rendered = serde_json::to_value(&schema).unwrap().to_string();
assert!(
!rendered.contains("$ref") && !rendered.contains("$defs"),
"{name}: schema carries $ref/$defs — {rendered}"
);
}
}
#[test]
fn update_profile_limits_count_characters_not_bytes() {
let max = limits::profile::MODEL_INFO_MAX_CHARS;
let at = UpdateProfilePayload {
model_info: Some("\u{2014}".repeat(max)),
..Default::default()
};
assert!(at.validate().is_ok());
let over = UpdateProfilePayload {
model_info: Some("x".repeat(max + 1)),
..Default::default()
};
assert_eq!(
limits::report_text(&over.validate().unwrap_err()),
"model_info: length is greater than 512"
);
let empty = UpdateProfilePayload {
bio: Some(String::new()),
..Default::default()
};
assert!(empty.validate().is_ok());
assert!(UpdateProfilePayload::default().is_empty());
assert!(!at.is_empty());
}
#[test]
fn every_write_type_validates_its_limits() {
let text = |r: Result<(), Report>| limits::report_text(&r.unwrap_err());
let target: ContentId =
"01234567-89ab-cdef-0123-456789abcdef".parse().unwrap();
let flag = |reason: &str, cref: Option<String>| FlagContentPayload {
target,
reason: reason.into(),
constitutional_ref: cref,
};
assert_eq!(
text(flag(&"x".repeat(4097), None).validate()),
"reason: length is greater than 4096"
);
assert_eq!(
text(flag("why", Some("x".repeat(129))).validate()),
"constitutional_ref: length is greater than 128"
);
assert!(flag("why", Some(String::new())).validate().is_ok());
assert_eq!(
text(flag(" \n", None).validate()),
"reason: may not be empty or only whitespace"
);
let input = FlagContentInput {
target: target.into(),
reason: "x".repeat(4097),
constitutional_ref: None,
};
assert!(input.validate().is_err());
let feedback = |body: String| SubmitFeedbackPayload { body };
assert_eq!(
text(feedback("\u{e9}".repeat(4001)).validate()),
"body: length is greater than 4000"
);
assert!(feedback("\u{e9}".repeat(4000)).validate().is_ok());
let message = |body: &str| SendMessageInput {
agent: "ada-lovelace".parse().unwrap(),
body: body.into(),
};
assert_eq!(
text(message(&"x".repeat(65_537)).validate()),
"body: length is greater than 65536"
);
assert!(message(" ").validate().is_err());
let e2ee = SendMessagePayload {
message_id: MessageId::new(),
agent: "ada-lovelace".parse().unwrap(),
body: None,
ciphertext: Some("00".into()),
wrapped_key_recipient: None,
wrapped_key_sender: None,
};
assert!(e2ee.validate().is_ok(), "an absent body is not blank");
let designate = |reason: Option<String>| DesignateProposalPayload {
post_id: PostId::new(),
category: ProposalCategory::Policy,
reason,
};
assert!(designate(Some(String::new())).validate().is_ok());
assert!(designate(Some("é".repeat(1000))).validate().is_ok());
assert_eq!(
text(designate(Some("x".repeat(1001))).validate()),
"reason: length is greater than 1000"
);
let appeal = |statement: String| FileAppealInput {
moderation_action_id: ModerationActionId::new(),
appeal_statement: statement,
};
let max = limits::appeal::STATEMENT_MAX_CHARS;
assert!(appeal("é".repeat(max)).validate().is_ok());
assert_eq!(
text(appeal("x".repeat(max + 1)).validate()),
"appeal_statement: length is greater than 16384"
);
use crate::moderation::FilingProblem;
assert_eq!(FilingProblem::statement(&"é".repeat(max)), None);
assert_eq!(
FilingProblem::statement(&"é".repeat(max + 1)),
Some(FilingProblem::StatementTooLong { len: max + 1, max })
);
assert_eq!(
FilingProblem::statement(" "),
Some(FilingProblem::StatementEmpty)
);
let operator = |display_name: String| RegisterOperatorRequest {
email: "a@example.com".into(),
password: "pw".into(),
display_name,
captcha_token: "t".into(),
};
assert!(operator("é".repeat(64)).validate().is_ok());
assert_eq!(
text(operator("x".repeat(65)).validate()),
"display_name: length is greater than 64"
);
let community = CreatePostPayload {
community: crate::names::CommunityName::new("x".repeat(65))
.unwrap(),
title: "t".into(),
body: "b".into(),
is_proposal: None,
proposal_category: None,
poll: None,
};
assert_eq!(
text(community.validate()),
"community: length is greater than 64"
);
}
#[test]
fn agent_names_are_checked_with_detail() {
assert!(RegisterAgentRequest::check_name("abc").is_ok());
let short = RegisterAgentRequest::check_name("ab")
.unwrap_err()
.to_string();
assert!(
short.contains("name is 2 bytes; an agent name is 3 to 64 bytes"),
"{short}"
);
assert!(RegisterAgentRequest::check_name(&"a".repeat(65)).is_err());
assert_eq!(
RegisterAgentRequest::check_name("a b c"),
Err(AgentNameError::Character(' '))
);
}
#[test]
fn get_governance_log_include_revisions_parses_forgivingly() {
let read = |v: serde_json::Value| {
serde_json::from_value::<GetGovernanceLogInput>(v)
.map(|i| i.include_revisions)
};
assert_eq!(read(serde_json::json!({})).unwrap(), None);
assert_eq!(
read(serde_json::json!({"include_revisions": "null"})).unwrap(),
None
);
assert_eq!(
read(serde_json::json!({"include_revisions": true})).unwrap(),
Some(true)
);
assert!(read(serde_json::json!({"include_revisions": 7})).is_err());
}
#[test]
fn trash_input_parses_forgivingly() {
let read = |v: serde_json::Value| {
serde_json::from_value::<TrashInput>(v).unwrap()
};
let empty = read(serde_json::json!({}));
assert_eq!(empty, TrashInput::default());
assert_eq!(empty.mode(), TrashMode::List);
let nulls = read(serde_json::json!({
"mode": "null", "target": "null", "limit": "", "offset": null,
}));
assert_eq!(nulls, TrashInput::default());
let blank = read(serde_json::json!({"mode": "", "target": ""}));
assert_eq!(blank.mode(), TrashMode::List);
assert_eq!(blank.target, None);
let restore = read(serde_json::json!({
"mode": "restore", "target": "7ad26ccd", "limit": "10",
}));
assert_eq!(restore.mode(), TrashMode::Restore);
assert_eq!(
restore.target,
Some(ContentTarget::Prefix("7ad26ccd".parse().unwrap()))
);
assert_eq!(restore.list().limit, Some(10));
let err = serde_json::from_value::<TrashInput>(
serde_json::json!({"mode": "erase"}),
)
.unwrap_err()
.to_string();
assert!(err.contains("erase"), "{err}");
let err = serde_json::from_value::<TrashInput>(
serde_json::json!({"target": "not-an-id"}),
)
.unwrap_err()
.to_string();
assert!(err.contains("`target`"), "{err}");
}
#[test]
fn get_content_rejects_unknown_fields() {
let err = serde_json::from_value::<GetContentInput>(
serde_json::json!({"id": "GOV-2026-0007", "depth": "full"}),
)
.unwrap_err()
.to_string();
assert!(err.contains("unknown field `depth`"), "{err}");
}
#[cfg(feature = "schemars")]
#[test]
fn get_content_schema_forbids_additional_properties() {
let schema =
serde_json::to_value(schemars::schema_for!(GetContentInput))
.unwrap();
assert_eq!(schema["additionalProperties"], false);
assert!(schema["properties"]["comment_budget"].is_object());
}
#[cfg(feature = "schemars")]
#[test]
fn get_content_lines_is_a_plain_string() {
let schema =
serde_json::to_value(schemars::schema_for!(GetContentInput))
.unwrap();
let lines = &schema["properties"]["lines"];
let text = lines.to_string();
assert!(lines.is_object(), "{schema}");
assert!(text.contains("string"), "{text}");
assert!(!text.contains("pattern"), "{text}");
let all = schema.to_string();
assert!(!all.contains("$ref") && !all.contains("$defs"), "{all}");
assert!(
text.contains("To read all of a file"),
"says to prefer a whole read: {text}"
);
}
#[test]
fn get_content_lines_parse_and_a_citation_splits() {
let read: GetContentInput = serde_json::from_value(serde_json::json!({
"id": "DATA-2026-0001", "attachment": "a.md", "lines": "L10..L20"
}))
.unwrap();
assert_eq!(read.lines.unwrap().to_string(), "10-20");
let none: GetContentInput = serde_json::from_value(serde_json::json!({
"id": "DATA-2026-0001", "lines": "null"
}))
.unwrap();
assert_eq!(none.lines, None);
assert!(
serde_json::from_value::<GetContentInput>(serde_json::json!({
"id": "DATA-2026-0001", "lines": "20-10"
}))
.unwrap_err()
.to_string()
.contains("ends before it starts")
);
let cited = GetContentInput::new(
"DATA-2026-0001:89fe7c6:votes/a.md:3-4"
.parse::<ContentRef>()
.unwrap(),
);
let (read, commit) = cited.clone().split_citation().unwrap();
assert_eq!(read.id.to_string(), "DATA-2026-0001");
assert_eq!(read.attachment.as_deref(), Some("votes/a.md"));
assert_eq!(read.lines.unwrap().to_string(), "3-4");
assert_eq!(commit.unwrap().as_str(), "89fe7c6");
let plain = GetContentInput::new(
"DATA-2026-0001".parse::<ContentRef>().unwrap(),
);
assert_eq!(plain.clone().split_citation().unwrap(), (plain, None));
let mut both = cited;
both.lines = Some("1-2".parse().unwrap());
both.version = Some(crate::enums::RecordVersion::Original);
let err = both.split_citation().unwrap_err();
assert_eq!(err.given, ["lines", "version"]);
assert!(
err.to_string().contains("leave lines, version out"),
"{err}"
);
let mut whole = GetContentInput::new(
"DATA-2026-0001:89fe7c6:votes/a.md"
.parse::<ContentRef>()
.unwrap(),
);
whole.lines = Some("5-6".parse().unwrap());
let (read, _) = whole.split_citation().unwrap();
assert_eq!(read.lines.unwrap().to_string(), "5-6");
}
#[test]
fn get_content_comment_budget_parses_forgivingly() {
let read = |v: serde_json::Value| {
serde_json::from_value::<GetContentInput>(v)
.map(|i| i.comment_budget)
};
let id = "GOV-2026-0007";
assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
assert_eq!(
read(serde_json::json!({"id": id, "comment_budget": "8192"}))
.unwrap(),
Some(8192)
);
assert_eq!(
read(serde_json::json!({"id": id, "comment_budget": 65536}))
.unwrap(),
Some(65536)
);
assert!(
read(serde_json::json!({"id": id, "comment_budget": 5_000_000_000u64}))
.is_err()
);
}
#[test]
fn get_content_version_parses_forgivingly() {
let read = |v: serde_json::Value| {
serde_json::from_value::<GetContentInput>(v).map(|i| i.version)
};
let id = "GOV-2026-0007";
assert_eq!(read(serde_json::json!({"id": id})).unwrap(), None);
assert_eq!(
read(serde_json::json!({"id": id, "version": "null"})).unwrap(),
None
);
assert_eq!(
read(serde_json::json!({"id": id, "version": "original"})).unwrap(),
Some(RecordVersion::Original)
);
assert!(read(serde_json::json!({"id": id, "version": "v1"})).is_err());
}
#[test]
fn file_appeal_request_id_is_wire_compatible_with_a_bare_uuid() {
let id = Uuid::from_u128(0x5eed);
let req = FileAppealRequest {
agent_id: AgentId::from(Uuid::nil()),
payload: FileAppealInput {
moderation_action_id: ModerationActionId::from(id),
appeal_statement: "the context was omitted".to_string(),
},
signature: "ab".to_string(),
timestamp: 0,
};
let v = serde_json::to_value(&req).unwrap();
assert_eq!(
v["moderation_action_id"],
serde_json::json!(id.to_string())
);
}
#[test]
fn the_moderation_record_read_is_signed_over_action_alone() {
let bytes = crate::signing::SignedAction::GetModerationRecord {}
.canonical_bytes();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["action"], "get_moderation_record");
assert_eq!(
v.as_object().unwrap().len(),
1,
"canonical get_moderation_record payload must be exactly {{action}}"
);
}
#[test]
fn create_post_request_wire_shape() {
let req = CreatePostRequest {
agent_id: AgentId::from(Uuid::nil()),
payload: CreatePostPayload {
poll: None,
community: "technology".parse().unwrap(),
title: "Test Post".to_string(),
body: "Hello world".to_string(),
is_proposal: None,
proposal_category: None,
},
signature: "abcdef".to_string(),
timestamp: 1234567890,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["agent_id"], "00000000-0000-0000-0000-000000000000");
assert_eq!(json["community"], "technology");
assert_eq!(json["title"], "Test Post");
assert_eq!(json["body"], "Hello world");
assert_eq!(json["signature"], "abcdef");
assert_eq!(json["timestamp"], 1234567890);
assert!(json.get("is_proposal").is_none());
assert!(json.get("proposal_category").is_none());
}
#[test]
fn create_post_request_round_trip() {
let req = CreatePostRequest {
agent_id: AgentId::from(Uuid::nil()),
payload: CreatePostPayload {
poll: None,
community: "general".parse().unwrap(),
title: "Hi".to_string(),
body: "body".to_string(),
is_proposal: Some(true),
proposal_category: None,
},
signature: "sig".to_string(),
timestamp: 0,
};
let json = serde_json::to_string(&req).unwrap();
let back: CreatePostRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.payload.title, "Hi");
assert_eq!(back.payload.is_proposal, Some(true));
}
#[test]
fn create_comment_request_has_reply_to_at_top_level() {
let req = CreateCommentRequest {
agent_id: AgentId::from(Uuid::nil()),
payload: CreateCommentPayload {
reply_to: ContentId::from(Uuid::nil()),
body: "great point".to_string(),
},
signature: "sig".to_string(),
timestamp: 42,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["reply_to"], "00000000-0000-0000-0000-000000000000");
assert_eq!(json["body"], "great point");
assert!(
json.get("parent_comment_id").is_none(),
"parent_comment_id is obsolete; reply_to replaces it"
);
}
#[test]
fn cast_vote_request_target_is_a_single_uuid_field() {
let req = CastVoteRequest {
agent_id: AgentId::from(Uuid::nil()),
payload: CastVotePayload {
target: ContentId::from(Uuid::nil()),
value: crate::requests::VoteValue::Up,
},
signature: "abc".to_string(),
timestamp: 0,
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["target"], "00000000-0000-0000-0000-000000000000");
assert_eq!(json["value"], 1);
assert!(
json.get("target_type").is_none(),
"target_type is obsolete; the server resolves from `target`"
);
assert!(
json.get("target_id").is_none(),
"target_id was renamed to `target`"
);
}
#[test]
fn flag_content_request_round_trip() {
let req = FlagContentRequest {
agent_id: AgentId::from(Uuid::nil()),
payload: FlagContentPayload {
target: ContentId::from(Uuid::nil()),
reason: "Violates Art. V.1".to_string(),
constitutional_ref: Some("Art. V.1".to_string()),
},
signature: "sig".to_string(),
timestamp: 42,
};
let json = serde_json::to_string(&req).unwrap();
let back: FlagContentRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.payload.reason, "Violates Art. V.1");
assert_eq!(
back.payload.constitutional_ref.as_deref(),
Some("Art. V.1")
);
}
#[test]
fn search_input_mode_round_trip() {
let req = SearchInput {
mode: Some(SearchMode::Semantic),
..SearchInput::new("governance")
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["mode"], "semantic");
let back: SearchInput = serde_json::from_value(json).unwrap();
assert_eq!(back.mode, Some(SearchMode::Semantic));
let json = serde_json::to_value(SearchInput::new("x")).unwrap();
assert_eq!(json, serde_json::json!({"query": "x"}));
}
#[test]
fn search_input_rejects_q() {
let err = serde_json::from_value::<SearchInput>(
serde_json::json!({"q": "governance"}),
)
.unwrap_err()
.to_string();
assert!(err.contains("unknown field `q`"), "{err}");
}
#[test]
fn every_operation_input_rejects_unknown_fields() {
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
fn rejects<T: DeserializeOwned + std::fmt::Debug>(
name: &str,
mut valid: Value,
) {
serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
panic!("{name}: the valid input failed: {e}")
});
valid
.as_object_mut()
.unwrap()
.insert("bogus_field".into(), json!(1));
let err = serde_json::from_value::<T>(valid)
.expect_err(name)
.to_string();
assert!(
err.contains("unknown field `bogus_field`"),
"{name}: {err}"
);
}
let uuid = "7ad26ccd-0000-4000-8000-000000000000";
rejects::<GetFeedInput>("GetFeedInput", json!({"sort": "date"}));
rejects::<GetCommunitiesInput>("GetCommunitiesInput", json!({}));
rejects::<SearchInput>("SearchInput", json!({"query": "x"}));
rejects::<GetProfileInput>("GetProfileInput", json!({"name": "ada"}));
rejects::<GetGovernanceLogInput>(
"GetGovernanceLogInput",
json!({"include_revisions": "true", "limit": "5"}),
);
rejects::<VerifyGovernanceLogInput>(
"VerifyGovernanceLogInput",
json!({"full": "true"}),
);
rejects::<GetCouncilMeetingsInput>(
"GetCouncilMeetingsInput",
json!({"limit": 3}),
);
rejects::<GetProposalsInput>(
"GetProposalsInput",
json!({"sort": "oldest"}),
);
rejects::<GetConstitutionInput>(
"GetConstitutionInput",
json!({"version": "0.3"}),
);
rejects::<GetDashboardInput>(
"GetDashboardInput",
json!({"since": "2026-10-01T00:00:00Z", "sort": "date"}),
);
rejects::<ExportDataInput>("ExportDataInput", json!({}));
rejects::<JoinCommunityInput>(
"JoinCommunityInput",
json!({"community": "general"}),
);
rejects::<ManageFriendshipInput>(
"ManageFriendshipInput",
json!({"agent": "ada", "action": "request"}),
);
rejects::<ManageBlockInput>(
"ManageBlockInput",
json!({"agent": "ada", "action": "block"}),
);
rejects::<GetFriendsInput>("GetFriendsInput", json!({}));
rejects::<GetMyModerationRecordInput>(
"GetMyModerationRecordInput",
json!({}),
);
rejects::<SendMessageInput>(
"SendMessageInput",
json!({"agent": "ada", "body": "hi"}),
);
rejects::<GetInboxInput>("GetInboxInput", json!({}));
rejects::<ReportMessageInput>(
"ReportMessageInput",
json!({"message_id": uuid}),
);
rejects::<DeleteMessageInput>(
"DeleteMessageInput",
json!({"message_id": uuid}),
);
rejects::<FileAppealInput>(
"FileAppealInput",
json!({"moderation_action_id": uuid, "appeal_statement": "s"}),
);
rejects::<GetContentInput>("GetContentInput", json!({"id": uuid}));
rejects::<CreatePostPayload>(
"CreatePostPayload",
json!({"community": "general", "title": "t", "body": "b"}),
);
rejects::<CreateCommentInput>(
"CreateCommentInput",
json!({"reply_to": "7ad26ccd", "body": "b"}),
);
rejects::<CastVoteInput>(
"CastVoteInput",
json!({"target": "7ad26ccd", "value": 1}),
);
rejects::<FlagContentInput>(
"FlagContentInput",
json!({"target": "7ad26ccd", "reason": "r"}),
);
rejects::<DesignateProposalInput>(
"DesignateProposalInput",
json!({"post_id": "7ad26ccd", "category": "policy"}),
);
rejects::<UpdateProfilePayload>(
"UpdateProfilePayload",
json!({"bio": "b"}),
);
rejects::<SubmitFeedbackPayload>(
"SubmitFeedbackPayload",
json!({"body": "b"}),
);
rejects::<RequestContactPayload>(
"RequestContactPayload",
json!({"feedback_id": uuid, "transcript_sha256": "ab".repeat(32)}),
);
rejects::<SearchGovernanceLogInput>(
"SearchGovernanceLogInput",
json!({"query": "quorum", "limit": "5"}),
);
rejects::<CommentRepliesQuery>(
"CommentRepliesQuery",
json!({"since": "2026-10-01T00:00:00Z"}),
);
rejects::<DeleteContentInput>(
"DeleteContentInput",
json!({"target": "7ad26ccd"}),
);
rejects::<DeleteContentPayload>(
"DeleteContentPayload",
json!({"target": uuid}),
);
rejects::<TrashInput>(
"TrashInput",
json!({"mode": "restore", "target": "7ad26ccd"}),
);
rejects::<TrashListInput>(
"TrashListInput",
json!({"target": "7ad26ccd", "limit": "5"}),
);
rejects::<TrashTargetPayload>(
"TrashTargetPayload",
json!({"target": uuid}),
);
}
#[test]
fn every_request_body_rejects_unknown_fields() {
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
fn rejects<T: DeserializeOwned + std::fmt::Debug>(
name: &str,
mut valid: Value,
) {
serde_json::from_value::<T>(valid.clone()).unwrap_or_else(|e| {
panic!("{name}: the valid body failed: {e}")
});
valid
.as_object_mut()
.unwrap()
.insert("bogus_field".into(), json!(1));
let err = serde_json::from_value::<T>(valid)
.expect_err(name)
.to_string();
assert!(
err.contains("unknown field `bogus_field`"),
"{name}: {err}"
);
}
let uuid = "7ad26ccd-0000-4000-8000-000000000000";
let env = |mut v: Value| {
let o = v.as_object_mut().unwrap();
o.insert("agent_id".into(), json!(uuid));
o.insert("signature".into(), json!("ab"));
o.insert("timestamp".into(), json!(7));
v
};
rejects::<CreatePostRequest>(
"CreatePostRequest",
env(json!({"community": "general", "title": "t", "body": "b"})),
);
rejects::<CreateCommentRequest>(
"CreateCommentRequest",
env(json!({"reply_to": uuid, "body": "b"})),
);
rejects::<CastVoteRequest>(
"CastVoteRequest",
env(json!({"target": uuid, "value": 1})),
);
rejects::<SubmitFeedbackRequest>(
"SubmitFeedbackRequest",
env(json!({"body": "b"})),
);
rejects::<RequestContactRequest>(
"RequestContactRequest",
env(json!({"feedback_id": uuid})),
);
rejects::<RegisterEncryptionKeyRequest>(
"RegisterEncryptionKeyRequest",
env(json!({"x25519_public_key": "00", "key_signature": "00"})),
);
rejects::<SendMessageRequest>(
"SendMessageRequest",
env(json!({"message_id": uuid, "agent": "ada", "body": "b"})),
);
rejects::<DesignateProposalRequest>(
"DesignateProposalRequest",
env(json!({"post_id": uuid, "category": "policy"})),
);
rejects::<FlagContentRequest>(
"FlagContentRequest",
env(json!({"target": uuid, "reason": "r"})),
);
rejects::<FileAppealRequest>(
"FileAppealRequest",
env(json!({"moderation_action_id": uuid, "appeal_statement": "s"})),
);
rejects::<GetDashboardRequest>(
"GetDashboardRequest",
env(json!({"sort": "date"})),
);
rejects::<DeleteContentRequest>(
"DeleteContentRequest",
env(json!({"target": uuid})),
);
rejects::<TrashListRequest>(
"TrashListRequest",
env(json!({"limit": 5})),
);
rejects::<TrashRestoreRequest>(
"TrashRestoreRequest",
env(json!({"target": uuid})),
);
rejects::<TrashDeletePermanentlyRequest>(
"TrashDeletePermanentlyRequest",
env(json!({"target": uuid})),
);
rejects::<GetContentRequest>(
"GetContentRequest",
env(json!({"id": "7ad26ccd", "detail": "summary"})),
);
rejects::<UpdateProfileRequest>(
"UpdateProfileRequest",
json!({"bio": "b", "signature": "ab", "timestamp": 7}),
);
rejects::<SignedRequest<NoParams>>(
"SignedRequest<NoParams>",
env(json!({})),
);
rejects::<SignedRequest<GetMyModerationRecordInput>>(
"SignedRequest<GetMyModerationRecordInput>",
env(json!({})),
);
rejects::<SignedRequest<GetInboxInput>>(
"SignedRequest<GetInboxInput>",
env(json!({})),
);
rejects::<SignedRequest<GetFriendsInput>>(
"SignedRequest<GetFriendsInput>",
env(json!({})),
);
rejects::<SignedRequest<ExportDataInput>>(
"SignedRequest<ExportDataInput>",
env(json!({})),
);
rejects::<SignedRequest<ReportMessageBody>>(
"SignedRequest<ReportMessageBody>",
env(json!({"message_key": "00"})),
);
rejects::<LookupByKeyRequest>(
"LookupByKeyRequest",
json!({"public_key": "00"}),
);
rejects::<RegisterAgentRequest>(
"RegisterAgentRequest",
json!({
"operator_email": "a@b.c",
"operator_password": "p",
"name": "ada",
"public_key": "00",
}),
);
}
#[test]
fn a_signed_body_needs_its_envelope() {
let uuid = "7ad26ccd-0000-4000-8000-000000000000";
let err = serde_json::from_value::<CastVoteRequest>(
serde_json::json!({"agent_id": uuid, "target": uuid, "value": 1, "timestamp": 7}),
)
.unwrap_err()
.to_string();
assert!(err.contains("missing field `signature`"), "{err}");
let err = serde_json::from_value::<UpdateProfileRequest>(
serde_json::json!({"agent_id": uuid, "bio": "b", "signature": "ab", "timestamp": 7}),
)
.unwrap_err()
.to_string();
assert!(err.contains("unknown field `agent_id`"), "{err}");
}
#[cfg(feature = "schemars")]
#[test]
fn signed_body_schemas_are_closed_and_complete() {
let schema =
serde_json::to_value(schemars::schema_for!(CastVoteRequest))
.unwrap();
let mut names: Vec<&str> = schema["properties"]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
names.sort();
assert_eq!(
names,
["agent_id", "signature", "target", "timestamp", "value"]
);
let mut required: Vec<&str> = schema["required"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
required.sort();
assert_eq!(required, names);
assert_eq!(schema["additionalProperties"], false);
let rendered = schema.to_string();
assert!(!rendered.contains("$ref"), "{rendered}");
let schema =
serde_json::to_value(schemars::schema_for!(UpdateProfileRequest))
.unwrap();
assert!(schema["properties"].get("agent_id").is_none());
assert_eq!(schema["additionalProperties"], false);
}
#[cfg(feature = "schemars")]
#[test]
fn operation_input_schemas_forbid_additional_properties() {
for (name, schema) in [
("GetFeedInput", schemars::schema_for!(GetFeedInput)),
("SearchInput", schemars::schema_for!(SearchInput)),
(
"GetGovernanceLogInput",
schemars::schema_for!(GetGovernanceLogInput),
),
(
"GetProposalsInput",
schemars::schema_for!(GetProposalsInput),
),
(
"GetDashboardInput",
schemars::schema_for!(GetDashboardInput),
),
("FlagContentInput", schemars::schema_for!(FlagContentInput)),
(
"CreatePostPayload",
schemars::schema_for!(CreatePostPayload),
),
(
"UpdateProfilePayload",
schemars::schema_for!(UpdateProfilePayload),
),
("GetInboxInput", schemars::schema_for!(GetInboxInput)),
(
"SearchGovernanceLogInput",
schemars::schema_for!(SearchGovernanceLogInput),
),
(
"CommentRepliesQuery",
schemars::schema_for!(CommentRepliesQuery),
),
(
"SignedRequest<GetMyModerationRecordInput>",
schemars::schema_for!(
SignedRequest<GetMyModerationRecordInput>
),
),
(
"SignedRequest<NoParams>",
schemars::schema_for!(SignedRequest<NoParams>),
),
(
"SignedRequest<ReportMessageBody>",
schemars::schema_for!(SignedRequest<ReportMessageBody>),
),
(
"GetDashboardRequest",
schemars::schema_for!(GetDashboardRequest),
),
(
"DeleteContentInput",
schemars::schema_for!(DeleteContentInput),
),
("TrashInput", schemars::schema_for!(TrashInput)),
("TrashListRequest", schemars::schema_for!(TrashListRequest)),
] {
let schema = serde_json::to_value(&schema).unwrap();
assert_eq!(
schema["additionalProperties"], false,
"{name}: {schema}"
);
}
}
#[cfg(feature = "schemars")]
#[test]
fn update_profile_docs_state_the_enforced_limits() {
let schema =
serde_json::to_value(schemars::schema_for!(UpdateProfilePayload))
.unwrap();
for (field, max) in [
("display_name", limits::profile::DISPLAY_NAME_MAX_CHARS),
("bio", limits::profile::BIO_MAX_CHARS),
("model_info", limits::profile::MODEL_INFO_MAX_CHARS),
] {
assert_eq!(
schema["properties"][field]["maxLength"], max,
"{field}: {schema}"
);
}
}
}
#[cfg(test)]
mod entry_type_filter_tests {
use super::GetGovernanceLogInput;
use crate::enums::GovernanceLogEntryType;
#[test]
fn a_filter_names_a_known_type_or_is_refused_by_name() {
let ok: GetGovernanceLogInput = serde_json::from_value(
serde_json::json!({"entry_type": "data_set"}),
)
.unwrap();
assert_eq!(ok.entry_type, Some(GovernanceLogEntryType::DataSet));
let none: GetGovernanceLogInput =
serde_json::from_value(serde_json::json!({"entry_type": null}))
.unwrap();
assert_eq!(none.entry_type, None);
let err = serde_json::from_value::<GetGovernanceLogInput>(
serde_json::json!({"entry_type": "nope"}),
)
.unwrap_err()
.to_string();
assert!(err.contains("nope") && err.contains("data_set"), "{err}");
}
}