1use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13pub const UUID_PATTERN: &str =
20 "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
21
22pub const GOVERNANCE_LOG_ID_PATTERN: &str =
24 "^(GOV|APP|AMD|KEY|REC|OBL|DATA)-[0-9]{4}-[0-9]{4}$";
25
26pub const CONTENT_ID_PREFIX_PATTERN: &str = "^[0-9a-f]{8}$";
29
30pub const CONTENT_REF_PATTERN: &str = "^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{8}|(GOV|APP|AMD|KEY|REC|OBL|DATA)-[0-9]{4}-[0-9]{4}|constitution|protocol|prompts|calendar|prompt:[a-z][a-z0-9_]*)$";
33
34pub const PLATFORM_DOC_PATTERN: &str =
36 "^(constitution|protocol|prompts|calendar|prompt:[a-z][a-z0-9_]*)$";
37
38pub const PROMPT_NAME_PATTERN: &str = "^[a-z][a-z0-9_]*$";
40
41macro_rules! define_id {
42 ($(#[doc = $doc:expr])* $name:ident) => {
43 $(#[doc = $doc])*
44 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
45 #[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
46 #[cfg_attr(feature = "sqlx", sqlx(transparent))]
47 pub struct $name(Uuid);
48
49 impl $name {
50 pub fn new() -> Self {
52 Self(Uuid::new_v4())
53 }
54
55 pub fn as_uuid(&self) -> &Uuid {
57 &self.0
58 }
59 }
60
61 impl Default for $name {
62 fn default() -> Self {
63 Self::new()
64 }
65 }
66
67 impl std::fmt::Display for $name {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 self.0.fmt(f)
70 }
71 }
72
73 impl From<Uuid> for $name {
74 fn from(uuid: Uuid) -> Self {
75 Self(uuid)
76 }
77 }
78
79 impl From<$name> for Uuid {
80 fn from(id: $name) -> Self {
81 id.0
82 }
83 }
84
85 impl std::str::FromStr for $name {
94 type Err = IdParseError;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
97 s.parse::<Uuid>().map(Self).map_err(|_| IdParseError {
98 name: stringify!($name),
99 input: s.to_string(),
100 })
101 }
102 }
103
104 impl<'de> Deserialize<'de> for $name {
107 fn deserialize<D: serde::Deserializer<'de>>(
108 d: D,
109 ) -> Result<Self, D::Error> {
110 struct V;
111 impl serde::de::Visitor<'_> for V {
112 type Value = $name;
113
114 fn expecting(
115 &self,
116 f: &mut std::fmt::Formatter<'_>,
117 ) -> std::fmt::Result {
118 write!(f, "{} as a UUID string", stringify!($name))
119 }
120
121 fn visit_str<E: serde::de::Error>(
122 self,
123 v: &str,
124 ) -> Result<$name, E> {
125 v.parse().map_err(E::custom)
126 }
127 }
128 d.deserialize_str(V)
129 }
130 }
131
132 #[cfg(feature = "schemars")]
139 impl schemars::JsonSchema for $name {
140 fn inline_schema() -> bool {
141 true
142 }
143
144 fn schema_name() -> std::borrow::Cow<'static, str> {
145 std::borrow::Cow::Borrowed(stringify!($name))
146 }
147
148 fn schema_id() -> std::borrow::Cow<'static, str> {
149 std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
150 }
151
152 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
153 schemars::json_schema!({
154 "type": "string",
155 "format": "uuid",
156 "pattern": UUID_PATTERN,
157 })
158 }
159 }
160 };
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
169#[error(
170 "not a valid {name}: expected a UUID like \
171 \"7ad26ccd-922f-484a-a37c-51777344a98c\", got \"{}\"",
172 echo(input)
173)]
174pub struct IdParseError {
175 pub name: &'static str,
177 pub input: String,
178}
179
180const ECHO_CHARS: usize = 60;
182
183fn echo(input: &str) -> String {
185 let mut out: String = input.chars().take(ECHO_CHARS).collect();
186 if input.chars().count() > ECHO_CHARS {
187 out.push('…');
188 }
189 out
190}
191
192define_id! {
193 AgentId
195}
196
197define_id! {
198 ReactorId
200}
201
202define_id! {
203 OperatorId
205}
206
207define_id! {
208 PostId
210}
211
212define_id! {
213 CommentId
215}
216
217define_id! {
218 CommunityId
220}
221
222define_id! {
223 VoteId
225}
226
227define_id! {
228 ModerationActionId
230}
231
232define_id! {
233 ModerationNoteId
240}
241
242define_id! {
243 PromptArchiveId
250}
251
252define_id! {
253 AppealId
255}
256
257define_id! {
258 FlagId
260}
261
262define_id! {
263 ProposalDesignationId
266}
267
268define_id! {
269 CouncilMeetingId
271}
272
273define_id! {
274 AgendaItemId
276}
277
278define_id! {
279 DecisionId
281}
282
283define_id! {
284 InterviewRunId
288}
289
290define_id! {
291 BatchTrackingId
293}
294
295define_id! {
296 ThreadSummaryId
298}
299
300define_id! {
301 McpSessionId
303}
304
305define_id! {
306 EmailVerificationTokenId
308}
309
310define_id! {
311 PostEmbeddingId
313}
314
315define_id! {
316 DataExportId
322}
323
324define_id! {
325 RefreshTokenId
332}
333
334define_id! {
335 MessageId
343}
344
345define_id! {
346 NotificationPreferenceId
350}
351
352#[derive(
362 Debug,
363 Clone,
364 Copy,
365 PartialEq,
366 Eq,
367 PartialOrd,
368 Ord,
369 Hash,
370 Serialize,
371 Deserialize,
372)]
373#[serde(transparent)]
374#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
375#[cfg_attr(feature = "sqlx", sqlx(transparent))]
376pub struct EventSeq(i64);
377
378impl EventSeq {
379 pub const ZERO: Self = Self(0);
382
383 pub fn get(&self) -> i64 {
384 self.0
385 }
386}
387
388impl From<i64> for EventSeq {
389 fn from(n: i64) -> Self {
390 Self(n)
391 }
392}
393
394impl std::fmt::Display for EventSeq {
395 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396 self.0.fmt(f)
397 }
398}
399
400#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
402#[error(
403 "not a valid event id: expected a non-negative integer, got \"{}\"",
404 echo(input)
405)]
406pub struct EventSeqParseError {
407 pub input: String,
408}
409
410impl std::str::FromStr for EventSeq {
411 type Err = EventSeqParseError;
412
413 fn from_str(s: &str) -> Result<Self, Self::Err> {
414 s.trim()
415 .parse::<i64>()
416 .ok()
417 .filter(|n| *n >= 0)
418 .map(Self)
419 .ok_or_else(|| EventSeqParseError {
420 input: s.to_string(),
421 })
422 }
423}
424
425#[cfg(feature = "schemars")]
426impl schemars::JsonSchema for EventSeq {
427 fn inline_schema() -> bool {
428 true
429 }
430
431 fn schema_name() -> std::borrow::Cow<'static, str> {
432 std::borrow::Cow::Borrowed("EventSeq")
433 }
434
435 fn schema_id() -> std::borrow::Cow<'static, str> {
436 std::borrow::Cow::Borrowed(concat!(module_path!(), "::EventSeq"))
437 }
438
439 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
440 schemars::json_schema!({ "type": "integer" })
441 }
442}
443
444define_id! {
445 FeedbackId
449}
450
451define_id! {
452 ContactRequestId
455}
456
457define_id! {
458 TrashEntryId
462}
463
464define_id! {
465 PollId
468}
469
470define_id! {
471 PollOptionId
475}
476
477define_id! {
478 BallotId
481}
482
483define_id! {
484 CalendarEventId
488}
489
490define_id! {
491 PromptArchiveResponseId
494}
495
496define_id! {
497 CleanupId
501}
502
503define_id! {
504 OperatorSessionId
507}
508
509define_id! {
510 OperatorPasswordResetId
513}
514
515define_id! {
516 OperatorPasskeyId
519}
520
521define_id! {
522 OperatorPendingLoginId
526}
527
528#[derive(Clone, PartialEq, Eq, Hash)]
532#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
533#[cfg_attr(feature = "sqlx", sqlx(transparent))]
534pub struct PasskeyCredentialId(Vec<u8>);
535
536impl PasskeyCredentialId {
537 pub fn as_bytes(&self) -> &[u8] {
538 &self.0
539 }
540}
541
542impl From<Vec<u8>> for PasskeyCredentialId {
543 fn from(bytes: Vec<u8>) -> Self {
544 Self(bytes)
545 }
546}
547
548impl std::fmt::Debug for PasskeyCredentialId {
549 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
550 write!(f, "PasskeyCredentialId({})", hex::encode(&self.0))
551 }
552}
553
554#[derive(Clone, PartialEq, Eq)]
559#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
560#[cfg_attr(feature = "sqlx", sqlx(transparent))]
561pub struct WebAuthnUserHandle(Vec<u8>);
562
563impl WebAuthnUserHandle {
564 pub fn as_bytes(&self) -> &[u8] {
565 &self.0
566 }
567}
568
569impl From<Vec<u8>> for WebAuthnUserHandle {
570 fn from(bytes: Vec<u8>) -> Self {
571 Self(bytes)
572 }
573}
574
575impl std::fmt::Debug for WebAuthnUserHandle {
576 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
577 f.write_str("WebAuthnUserHandle(..)")
578 }
579}
580
581#[derive(
595 Debug,
596 Clone,
597 Copy,
598 PartialEq,
599 Eq,
600 PartialOrd,
601 Ord,
602 Hash,
603 Serialize,
604 Deserialize,
605)]
606#[serde(transparent)]
607pub struct PollOptionNumber(i16);
608
609impl PollOptionNumber {
610 pub fn new(n: usize) -> Option<Self> {
612 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
613 }
614
615 pub fn index(&self) -> Option<usize> {
617 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
618 }
619
620 pub fn get(&self) -> i16 {
621 self.0
622 }
623}
624
625impl From<i16> for PollOptionNumber {
626 fn from(n: i16) -> Self {
627 Self(n)
628 }
629}
630
631impl std::fmt::Display for PollOptionNumber {
632 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
633 self.0.fmt(f)
634 }
635}
636
637#[cfg(feature = "schemars")]
638impl schemars::JsonSchema for PollOptionNumber {
639 fn inline_schema() -> bool {
640 true
641 }
642
643 fn schema_name() -> std::borrow::Cow<'static, str> {
644 std::borrow::Cow::Borrowed("PollOptionNumber")
645 }
646
647 fn schema_id() -> std::borrow::Cow<'static, str> {
648 std::borrow::Cow::Borrowed(concat!(
649 module_path!(),
650 "::PollOptionNumber"
651 ))
652 }
653
654 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
656 schemars::json_schema!({ "type": "integer" })
657 }
658}
659
660#[derive(
666 Debug,
667 Clone,
668 Copy,
669 PartialEq,
670 Eq,
671 PartialOrd,
672 Ord,
673 Hash,
674 Serialize,
675 Deserialize,
676)]
677#[serde(transparent)]
678#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
679#[cfg_attr(feature = "schemars", schemars(inline))]
680#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
681#[cfg_attr(feature = "sqlx", sqlx(transparent))]
682pub struct PollOptionPosition(i16);
683
684impl PollOptionPosition {
685 pub fn new(n: usize) -> Option<Self> {
687 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
688 }
689
690 pub fn index(&self) -> Option<usize> {
692 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
693 }
694
695 pub fn get(&self) -> i16 {
696 self.0
697 }
698}
699
700impl From<i16> for PollOptionPosition {
701 fn from(n: i16) -> Self {
702 Self(n)
703 }
704}
705
706impl std::fmt::Display for PollOptionPosition {
707 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
708 self.0.fmt(f)
709 }
710}
711
712define_id! {
713 ContentId
734}
735
736define_id! {
737 ModerationTargetId
755}
756
757impl From<PostId> for ModerationTargetId {
763 fn from(id: PostId) -> Self {
764 Self::from(*id.as_uuid())
765 }
766}
767
768impl From<CommentId> for ModerationTargetId {
769 fn from(id: CommentId) -> Self {
770 Self::from(*id.as_uuid())
771 }
772}
773
774impl From<MessageId> for ModerationTargetId {
775 fn from(id: MessageId) -> Self {
776 Self::from(*id.as_uuid())
777 }
778}
779
780impl From<AgentId> for ModerationTargetId {
781 fn from(id: AgentId) -> Self {
782 Self::from(*id.as_uuid())
783 }
784}
785
786impl From<ContentId> for ModerationTargetId {
789 fn from(id: ContentId) -> Self {
790 Self::from(*id.as_uuid())
791 }
792}
793
794impl From<PostId> for ContentId {
799 fn from(id: PostId) -> Self {
800 Self::from(*id.as_uuid())
801 }
802}
803
804impl From<CommentId> for ContentId {
805 fn from(id: CommentId) -> Self {
806 Self::from(*id.as_uuid())
807 }
808}
809
810impl From<PostOrCommentId> for ContentId {
811 fn from(id: PostOrCommentId) -> Self {
812 Self::from(id.as_uuid())
813 }
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
844pub enum PostOrCommentId {
845 Post(PostId),
846 Comment(CommentId),
847}
848
849impl PostOrCommentId {
850 pub fn as_uuid(&self) -> Uuid {
852 match self {
853 PostOrCommentId::Post(id) => *id.as_uuid(),
854 PostOrCommentId::Comment(id) => *id.as_uuid(),
855 }
856 }
857
858 pub fn is_post(&self) -> bool {
860 matches!(self, PostOrCommentId::Post(_))
861 }
862
863 pub fn is_comment(&self) -> bool {
865 matches!(self, PostOrCommentId::Comment(_))
866 }
867
868 pub fn as_post(&self) -> Option<PostId> {
870 match self {
871 PostOrCommentId::Post(id) => Some(*id),
872 PostOrCommentId::Comment(_) => None,
873 }
874 }
875
876 pub fn as_comment(&self) -> Option<CommentId> {
878 match self {
879 PostOrCommentId::Comment(id) => Some(*id),
880 PostOrCommentId::Post(_) => None,
881 }
882 }
883
884 pub fn kind(&self) -> crate::enums::ContentKind {
886 match self {
887 PostOrCommentId::Post(_) => crate::enums::ContentKind::Post,
888 PostOrCommentId::Comment(_) => crate::enums::ContentKind::Comment,
889 }
890 }
891
892 pub fn kind_str(&self) -> &'static str {
895 match self {
896 PostOrCommentId::Post(_) => "post",
897 PostOrCommentId::Comment(_) => "comment",
898 }
899 }
900}
901
902impl From<PostId> for PostOrCommentId {
903 fn from(id: PostId) -> Self {
904 PostOrCommentId::Post(id)
905 }
906}
907
908impl From<CommentId> for PostOrCommentId {
909 fn from(id: CommentId) -> Self {
910 PostOrCommentId::Comment(id)
911 }
912}
913
914impl std::fmt::Display for PostOrCommentId {
915 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
916 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
917 }
918}
919
920#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
935pub enum ArchiveContext {
936 AgendaItem(AgendaItemId),
938 Meeting(CouncilMeetingId),
941 Appeal(AppealId),
943 Flag(FlagId),
945 InterviewRun(InterviewRunId),
947}
948
949impl ArchiveContext {
950 pub fn kind(&self) -> crate::enums::ArchiveContextKind {
952 use crate::enums::ArchiveContextKind as K;
953 match self {
954 Self::AgendaItem(_) => K::AgendaItem,
955 Self::Meeting(_) => K::Meeting,
956 Self::Appeal(_) => K::Appeal,
957 Self::Flag(_) => K::Flag,
958 Self::InterviewRun(_) => K::InterviewRun,
959 }
960 }
961
962 pub fn as_uuid(&self) -> Uuid {
964 match self {
965 Self::AgendaItem(id) => *id.as_uuid(),
966 Self::Meeting(id) => *id.as_uuid(),
967 Self::Appeal(id) => *id.as_uuid(),
968 Self::Flag(id) => *id.as_uuid(),
969 Self::InterviewRun(id) => *id.as_uuid(),
970 }
971 }
972
973 pub fn from_stored(
975 kind: crate::enums::ArchiveContextKind,
976 id: Uuid,
977 ) -> Self {
978 use crate::enums::ArchiveContextKind as K;
979 match kind {
980 K::AgendaItem => Self::AgendaItem(id.into()),
981 K::Meeting => Self::Meeting(id.into()),
982 K::Appeal => Self::Appeal(id.into()),
983 K::Flag => Self::Flag(id.into()),
984 K::InterviewRun => Self::InterviewRun(id.into()),
985 }
986 }
987}
988
989impl From<AgendaItemId> for ArchiveContext {
990 fn from(id: AgendaItemId) -> Self {
991 Self::AgendaItem(id)
992 }
993}
994
995impl From<CouncilMeetingId> for ArchiveContext {
996 fn from(id: CouncilMeetingId) -> Self {
997 Self::Meeting(id)
998 }
999}
1000
1001impl From<AppealId> for ArchiveContext {
1002 fn from(id: AppealId) -> Self {
1003 Self::Appeal(id)
1004 }
1005}
1006
1007impl From<FlagId> for ArchiveContext {
1008 fn from(id: FlagId) -> Self {
1009 Self::Flag(id)
1010 }
1011}
1012
1013impl From<InterviewRunId> for ArchiveContext {
1014 fn from(id: InterviewRunId) -> Self {
1015 Self::InterviewRun(id)
1016 }
1017}
1018
1019impl std::fmt::Display for ArchiveContext {
1022 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1023 let kind = match self {
1024 Self::AgendaItem(_) => "agenda item",
1025 Self::Meeting(_) => "meeting",
1026 Self::Appeal(_) => "appeal",
1027 Self::Flag(_) => "flag",
1028 Self::InterviewRun(_) => "interview run",
1029 };
1030 write!(f, "{kind} {}", self.as_uuid())
1031 }
1032}
1033
1034#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1040#[error(
1041 "not a governance log id (expected GOV-, APP-, AMD-, KEY-, REC-, OBL- \
1042 or DATA-YYYY-NNNN): {0:?}"
1043)]
1044pub struct GovernanceLogIdError(pub String);
1045
1046#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1052pub enum GovernanceLogPrefix {
1053 Gov,
1055 App,
1057 Amd,
1059 Key,
1061 Rec,
1063 Obl,
1065 Data,
1068 Unknown,
1072}
1073
1074impl GovernanceLogPrefix {
1075 pub const ALL: [Self; 7] = [
1077 Self::Gov,
1078 Self::App,
1079 Self::Amd,
1080 Self::Key,
1081 Self::Rec,
1082 Self::Obl,
1083 Self::Data,
1084 ];
1085
1086 pub fn as_str(&self) -> &'static str {
1088 match self {
1089 Self::Gov => "GOV",
1090 Self::App => "APP",
1091 Self::Amd => "AMD",
1092 Self::Key => "KEY",
1093 Self::Rec => "REC",
1094 Self::Obl => "OBL",
1095 Self::Data => "DATA",
1096 Self::Unknown => "?",
1097 }
1098 }
1099}
1100
1101impl std::fmt::Display for GovernanceLogPrefix {
1102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1103 f.write_str(self.as_str())
1104 }
1105}
1106
1107impl std::str::FromStr for GovernanceLogPrefix {
1108 type Err = GovernanceLogIdError;
1109
1110 fn from_str(s: &str) -> Result<Self, Self::Err> {
1111 match s {
1112 "GOV" => Ok(Self::Gov),
1113 "APP" => Ok(Self::App),
1114 "AMD" => Ok(Self::Amd),
1115 "KEY" => Ok(Self::Key),
1116 "REC" => Ok(Self::Rec),
1117 "OBL" => Ok(Self::Obl),
1118 "DATA" => Ok(Self::Data),
1119 _ => Err(GovernanceLogIdError(s.to_string())),
1120 }
1121 }
1122}
1123
1124#[derive(
1144 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1145)]
1146#[serde(try_from = "String")]
1147#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
1148#[cfg_attr(feature = "sqlx", sqlx(transparent))]
1149pub struct GovernanceLogId(String);
1150
1151impl GovernanceLogId {
1152 pub fn as_str(&self) -> &str {
1154 &self.0
1155 }
1156
1157 pub fn into_inner(self) -> String {
1159 self.0
1160 }
1161
1162 pub fn is_citation_shaped(s: &str) -> bool {
1170 Self::parts(s).is_some()
1171 }
1172
1173 pub fn prefix(&self) -> GovernanceLogPrefix {
1175 Self::parts(&self.0).map_or(GovernanceLogPrefix::Unknown, |p| p.0)
1176 }
1177
1178 fn is_canonical_unknown(s: &str) -> bool {
1182 let parts: Vec<&str> = s.split('-').collect();
1183 let digits =
1184 |p: &str| p.len() == 4 && p.chars().all(|c| c.is_ascii_digit());
1185 matches!(parts.as_slice(), [letters, year, serial]
1186 if (3..=4).contains(&letters.len())
1187 && letters.chars().all(|c| c.is_ascii_uppercase())
1188 && digits(year)
1189 && digits(serial))
1190 }
1191
1192 pub fn normalize(s: &str) -> Option<String> {
1198 let mut parts = s.trim().split(['-', '.', '/', ' ']);
1199 let (prefix, year, serial) =
1200 (parts.next()?, parts.next()?, parts.next()?);
1201 if parts.next().is_some() {
1202 return None;
1203 }
1204 let prefix: GovernanceLogPrefix =
1205 prefix.to_ascii_uppercase().parse().ok()?;
1206 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
1207 (year.len() == 4
1208 && digits(year)
1209 && (1..=4).contains(&serial.len())
1210 && digits(serial))
1211 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
1212 }
1213
1214 pub fn from_parts(
1217 prefix: GovernanceLogPrefix,
1218 year: i32,
1219 serial: u32,
1220 ) -> Option<Self> {
1221 (prefix != GovernanceLogPrefix::Unknown
1222 && (1000..=9999).contains(&year)
1223 && (1..=9999).contains(&serial))
1224 .then(|| Self(format!("{prefix}-{year}-{serial:04}")))
1225 }
1226
1227 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
1228 let parts: Vec<&str> = s.split('-').collect();
1229 let [prefix, year, serial] = parts.as_slice() else {
1230 return None;
1231 };
1232 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
1233 (year.len() == 4
1234 && serial.len() == 4
1235 && year.chars().all(|c| c.is_ascii_digit())
1236 && serial.chars().all(|c| c.is_ascii_digit()))
1237 .then_some((prefix, year, serial))
1238 }
1239}
1240
1241impl std::fmt::Display for GovernanceLogId {
1242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1243 f.write_str(&self.0)
1244 }
1245}
1246
1247impl AsRef<str> for GovernanceLogId {
1248 fn as_ref(&self) -> &str {
1249 &self.0
1250 }
1251}
1252
1253impl std::str::FromStr for GovernanceLogId {
1254 type Err = GovernanceLogIdError;
1255
1256 fn from_str(s: &str) -> Result<Self, Self::Err> {
1259 Self::normalize(s)
1260 .map(Self)
1261 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
1262 }
1263}
1264
1265impl TryFrom<String> for GovernanceLogId {
1266 type Error = GovernanceLogIdError;
1267
1268 fn try_from(s: String) -> Result<Self, Self::Error> {
1271 if Self::is_citation_shaped(&s) || Self::is_canonical_unknown(&s) {
1272 return Ok(Self(s));
1273 }
1274 s.parse()
1275 }
1276}
1277
1278impl From<GovernanceLogId> for String {
1279 fn from(id: GovernanceLogId) -> Self {
1280 id.0
1281 }
1282}
1283
1284#[cfg(feature = "schemars")]
1290impl schemars::JsonSchema for GovernanceLogId {
1291 fn inline_schema() -> bool {
1292 true
1293 }
1294
1295 fn schema_name() -> std::borrow::Cow<'static, str> {
1296 std::borrow::Cow::Borrowed("GovernanceLogId")
1297 }
1298
1299 fn schema_id() -> std::borrow::Cow<'static, str> {
1300 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
1301 }
1302
1303 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1304 schemars::json_schema!({
1305 "type": "string",
1306 "pattern": GOVERNANCE_LOG_ID_PATTERN,
1307 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
1308 (Council decision or policy change), \
1309 \"APP-2026-0003\" (appeals ruling), \
1310 \"AMD-2026-0001\" (amendment to an earlier \
1311 entry), \"KEY-2026-0001\" (signing key \
1312 rotation), \"REC-2026-0001\" (a Steward's \
1313 record of an operational act), \
1314 \"OBL-2026-0001\" (an obligation) or \
1315 \"DATA-2026-0001\" (a data set).",
1316 })
1317 }
1318}
1319
1320#[derive(
1329 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1330)]
1331#[serde(try_from = "String")]
1332#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
1333#[cfg_attr(feature = "sqlx", sqlx(transparent))]
1334pub struct OAuthClientId(String);
1335
1336#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1338#[error("not an OAuth client id: {0:?}")]
1339pub struct OAuthClientIdError(pub String);
1340
1341impl OAuthClientId {
1342 pub fn generate() -> Self {
1344 Self(Uuid::new_v4().to_string())
1345 }
1346
1347 pub fn as_str(&self) -> &str {
1349 &self.0
1350 }
1351
1352 pub fn into_inner(self) -> String {
1354 self.0
1355 }
1356
1357 fn is_valid(s: &str) -> bool {
1358 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
1359 }
1360}
1361
1362impl std::fmt::Display for OAuthClientId {
1363 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1364 f.write_str(&self.0)
1365 }
1366}
1367
1368impl AsRef<str> for OAuthClientId {
1369 fn as_ref(&self) -> &str {
1370 &self.0
1371 }
1372}
1373
1374impl std::str::FromStr for OAuthClientId {
1375 type Err = OAuthClientIdError;
1376
1377 fn from_str(s: &str) -> Result<Self, Self::Err> {
1378 Self::try_from(s.to_string())
1379 }
1380}
1381
1382impl TryFrom<String> for OAuthClientId {
1383 type Error = OAuthClientIdError;
1384
1385 fn try_from(s: String) -> Result<Self, Self::Error> {
1386 if Self::is_valid(&s) {
1387 Ok(Self(s))
1388 } else {
1389 Err(OAuthClientIdError(s))
1390 }
1391 }
1392}
1393
1394impl From<OAuthClientId> for String {
1395 fn from(id: OAuthClientId) -> Self {
1396 id.0
1397 }
1398}
1399
1400#[cfg(feature = "schemars")]
1403impl schemars::JsonSchema for OAuthClientId {
1404 fn inline_schema() -> bool {
1405 true
1406 }
1407
1408 fn schema_name() -> std::borrow::Cow<'static, str> {
1409 std::borrow::Cow::Borrowed("OAuthClientId")
1410 }
1411
1412 fn schema_id() -> std::borrow::Cow<'static, str> {
1413 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
1414 }
1415
1416 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1417 schemars::json_schema!({
1418 "type": "string",
1419 "minLength": 1,
1420 "maxLength": 255,
1421 "description": "OAuth client_id issued at dynamic client registration.",
1422 })
1423 }
1424}
1425
1426#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1433pub enum PlatformDoc {
1434 Constitution,
1436 GovernanceProtocol,
1439 Prompts,
1441 Prompt(PromptName),
1443 Calendar,
1447}
1448
1449impl PlatformDoc {
1450 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
1452 match self {
1453 PlatformDoc::Constitution => "constitution".into(),
1454 PlatformDoc::GovernanceProtocol => "protocol".into(),
1455 PlatformDoc::Prompts => "prompts".into(),
1456 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
1457 PlatformDoc::Calendar => "calendar".into(),
1458 }
1459 }
1460
1461 pub fn title(&self) -> std::borrow::Cow<'static, str> {
1464 match self {
1465 PlatformDoc::Constitution => "The Agora Constitution".into(),
1466 PlatformDoc::GovernanceProtocol => {
1467 "The Agora Governance Protocol".into()
1468 }
1469 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
1470 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
1471 PlatformDoc::Calendar => "The Agora calendar".into(),
1472 }
1473 }
1474
1475 pub fn as_prompt(&self) -> Option<&PromptName> {
1477 match self {
1478 PlatformDoc::Prompt(name) => Some(name),
1479 _ => None,
1480 }
1481 }
1482}
1483
1484impl std::fmt::Display for PlatformDoc {
1485 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1486 f.write_str(&self.slug())
1487 }
1488}
1489
1490#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1492#[error(
1493 "not a platform document (expected \"constitution\", \"protocol\", \
1494 \"prompts\", \"calendar\" or \"prompt:<name>\"): {0:?}"
1495)]
1496pub struct PlatformDocError(pub String);
1497
1498const PROMPT_PREFIXES: [&str; 4] =
1502 ["prompt:", "prompt/", "prompts/", "prompts:"];
1503
1504impl std::str::FromStr for PlatformDoc {
1505 type Err = PlatformDocError;
1506
1507 fn from_str(s: &str) -> Result<Self, Self::Err> {
1511 if s.eq_ignore_ascii_case("constitution") {
1512 Ok(PlatformDoc::Constitution)
1513 } else if s.eq_ignore_ascii_case("protocol")
1514 || s.eq_ignore_ascii_case("governance-protocol")
1515 {
1516 Ok(PlatformDoc::GovernanceProtocol)
1517 } else if s.eq_ignore_ascii_case("prompts")
1518 || s.eq_ignore_ascii_case("prompt")
1519 {
1520 Ok(PlatformDoc::Prompts)
1521 } else if s.eq_ignore_ascii_case("calendar") {
1522 Ok(PlatformDoc::Calendar)
1523 } else {
1524 PROMPT_PREFIXES
1525 .iter()
1526 .find_map(|prefix| {
1527 let head = s.get(..prefix.len())?;
1528 head.eq_ignore_ascii_case(prefix)
1529 .then(|| s[prefix.len()..].parse().ok())
1530 .flatten()
1531 })
1532 .map(PlatformDoc::Prompt)
1533 .ok_or_else(|| PlatformDocError(s.to_string()))
1534 }
1535 }
1536}
1537
1538impl Serialize for PlatformDoc {
1539 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1540 s.collect_str(self)
1541 }
1542}
1543
1544impl<'de> Deserialize<'de> for PlatformDoc {
1545 fn deserialize<D: serde::Deserializer<'de>>(
1546 d: D,
1547 ) -> Result<Self, D::Error> {
1548 let raw = String::deserialize(d)?;
1549 raw.parse().map_err(serde::de::Error::custom)
1550 }
1551}
1552
1553#[cfg(feature = "schemars")]
1556impl schemars::JsonSchema for PlatformDoc {
1557 fn inline_schema() -> bool {
1558 true
1559 }
1560
1561 fn schema_name() -> std::borrow::Cow<'static, str> {
1562 std::borrow::Cow::Borrowed("PlatformDoc")
1563 }
1564
1565 fn schema_id() -> std::borrow::Cow<'static, str> {
1566 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1567 }
1568
1569 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1570 schemars::json_schema!({
1571 "type": "string",
1572 "pattern": PLATFORM_DOC_PATTERN,
1573 "description": "A platform document: \"constitution\", \
1574 \"protocol\" (the Governance Protocol), \
1575 \"prompts\" (the index of published model \
1576 prompts), \"prompt:<name>\" (one prompt, \
1577 e.g. \"prompt:tier2_reviewer\"), or \
1578 \"calendar\" (the sittings and obligation deadlines the \
1579 Steward and the Council scheduled).",
1580 })
1581 }
1582}
1583
1584#[derive(
1592 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1593)]
1594#[serde(try_from = "String")]
1595pub struct PromptName(String);
1596
1597#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1599#[error(
1600 "not a prompt name (lowercase letters, digits and `_`, e.g. \
1601 \"tier2_reviewer\"): {0:?}"
1602)]
1603pub struct PromptNameError(pub String);
1604
1605impl PromptName {
1606 pub const MAX_LEN: usize = 64;
1608
1609 pub fn as_str(&self) -> &str {
1611 &self.0
1612 }
1613
1614 fn normalize(s: &str) -> Option<String> {
1615 let s = s.trim();
1616 let s = match s.len().checked_sub(3) {
1617 Some(n)
1618 if s.is_char_boundary(n)
1619 && s[n..].eq_ignore_ascii_case(".md") =>
1620 {
1621 &s[..n]
1622 }
1623 _ => s,
1624 };
1625 let name: String = s
1626 .chars()
1627 .map(|c| match c {
1628 '-' => '_',
1629 c => c.to_ascii_lowercase(),
1630 })
1631 .collect();
1632 let mut chars = name.chars();
1633 let valid = name.len() <= Self::MAX_LEN
1634 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1635 && chars.all(|c| {
1636 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1637 });
1638 valid.then_some(name)
1639 }
1640}
1641
1642impl std::fmt::Display for PromptName {
1643 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1644 f.write_str(&self.0)
1645 }
1646}
1647
1648impl AsRef<str> for PromptName {
1649 fn as_ref(&self) -> &str {
1650 &self.0
1651 }
1652}
1653
1654impl std::str::FromStr for PromptName {
1655 type Err = PromptNameError;
1656
1657 fn from_str(s: &str) -> Result<Self, Self::Err> {
1658 Self::normalize(s)
1659 .map(Self)
1660 .ok_or_else(|| PromptNameError(s.to_string()))
1661 }
1662}
1663
1664impl TryFrom<String> for PromptName {
1665 type Error = PromptNameError;
1666
1667 fn try_from(s: String) -> Result<Self, Self::Error> {
1668 s.parse()
1669 }
1670}
1671
1672impl From<PromptName> for PlatformDoc {
1673 fn from(name: PromptName) -> Self {
1674 PlatformDoc::Prompt(name)
1675 }
1676}
1677
1678#[cfg(feature = "schemars")]
1679impl schemars::JsonSchema for PromptName {
1680 fn inline_schema() -> bool {
1681 true
1682 }
1683
1684 fn schema_name() -> std::borrow::Cow<'static, str> {
1685 std::borrow::Cow::Borrowed("PromptName")
1686 }
1687
1688 fn schema_id() -> std::borrow::Cow<'static, str> {
1689 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1690 }
1691
1692 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1693 schemars::json_schema!({
1694 "type": "string",
1695 "pattern": PROMPT_NAME_PATTERN,
1696 "maxLength": PromptName::MAX_LEN,
1697 "description": "A published model prompt's name, e.g. \
1698 \"tier2_reviewer\". `get_content(\"prompts\")` \
1699 lists them.",
1700 })
1701 }
1702}
1703
1704#[derive(
1710 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1711)]
1712#[serde(try_from = "String")]
1713pub struct WitnessId(String);
1714
1715#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1717#[error(
1718 "not a witness id (lowercase letters, digits and `-`, e.g. \"qwen\"): \
1719 {0:?}"
1720)]
1721pub struct WitnessIdError(pub String);
1722
1723impl WitnessId {
1724 pub const MAX_LEN: usize = 64;
1726
1727 pub fn as_str(&self) -> &str {
1729 &self.0
1730 }
1731
1732 fn normalize(s: &str) -> Option<String> {
1733 let id: String = s
1734 .trim()
1735 .chars()
1736 .map(|c| match c {
1737 '_' => '-',
1738 c => c.to_ascii_lowercase(),
1739 })
1740 .collect();
1741 let mut chars = id.chars();
1742 let valid = id.len() <= Self::MAX_LEN
1743 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1744 && chars.all(|c| {
1745 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
1746 });
1747 valid.then_some(id)
1748 }
1749}
1750
1751impl std::fmt::Display for WitnessId {
1752 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1753 f.write_str(&self.0)
1754 }
1755}
1756
1757impl AsRef<str> for WitnessId {
1758 fn as_ref(&self) -> &str {
1759 &self.0
1760 }
1761}
1762
1763impl std::str::FromStr for WitnessId {
1764 type Err = WitnessIdError;
1765
1766 fn from_str(s: &str) -> Result<Self, Self::Err> {
1767 Self::normalize(s)
1768 .map(Self)
1769 .ok_or_else(|| WitnessIdError(s.to_string()))
1770 }
1771}
1772
1773impl TryFrom<String> for WitnessId {
1774 type Error = WitnessIdError;
1775
1776 fn try_from(s: String) -> Result<Self, Self::Error> {
1777 s.parse()
1778 }
1779}
1780
1781#[cfg(feature = "schemars")]
1784impl schemars::JsonSchema for WitnessId {
1785 fn inline_schema() -> bool {
1786 true
1787 }
1788
1789 fn schema_name() -> std::borrow::Cow<'static, str> {
1790 std::borrow::Cow::Borrowed("WitnessId")
1791 }
1792
1793 fn schema_id() -> std::borrow::Cow<'static, str> {
1794 std::borrow::Cow::Borrowed(concat!(module_path!(), "::WitnessId"))
1795 }
1796
1797 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1798 schemars::json_schema!({
1799 "type": "string",
1800 "maxLength": WitnessId::MAX_LEN,
1801 "description": "A witness's id, as the item's list of witnesses \
1802 gives it, e.g. \"qwen\"",
1803 })
1804 }
1805}
1806
1807#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1823pub struct ContentIdPrefix(u32);
1824
1825#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1827#[error(
1828 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1829)]
1830pub struct ContentIdPrefixError(pub String);
1831
1832impl ContentIdPrefix {
1833 pub const LEN: usize = 8;
1835
1836 pub fn of(id: &Uuid) -> Self {
1838 Self((id.as_u128() >> 96) as u32)
1839 }
1840
1841 pub fn bounds(&self) -> (Uuid, Uuid) {
1843 let lo = (self.0 as u128) << 96;
1844 let hi = lo | ((1u128 << 96) - 1);
1845 (Uuid::from_u128(lo), Uuid::from_u128(hi))
1846 }
1847
1848 pub fn matches(&self, id: &Uuid) -> bool {
1850 Self::of(id) == *self
1851 }
1852}
1853
1854impl std::fmt::Display for ContentIdPrefix {
1855 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1856 write!(f, "{:08x}", self.0)
1857 }
1858}
1859
1860impl std::str::FromStr for ContentIdPrefix {
1861 type Err = ContentIdPrefixError;
1862
1863 fn from_str(s: &str) -> Result<Self, Self::Err> {
1864 let t = s.trim();
1865 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1866 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1868 } else {
1869 Err(ContentIdPrefixError(s.to_string()))
1870 }
1871 }
1872}
1873
1874impl TryFrom<String> for ContentIdPrefix {
1875 type Error = ContentIdPrefixError;
1876
1877 fn try_from(s: String) -> Result<Self, Self::Error> {
1878 s.parse()
1879 }
1880}
1881
1882impl Serialize for ContentIdPrefix {
1883 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1884 s.collect_str(self)
1885 }
1886}
1887
1888impl<'de> Deserialize<'de> for ContentIdPrefix {
1889 fn deserialize<D: serde::Deserializer<'de>>(
1890 d: D,
1891 ) -> Result<Self, D::Error> {
1892 let raw = String::deserialize(d)?;
1893 raw.parse().map_err(serde::de::Error::custom)
1894 }
1895}
1896
1897impl From<ContentId> for ContentIdPrefix {
1898 fn from(id: ContentId) -> Self {
1899 Self::of(id.as_uuid())
1900 }
1901}
1902
1903#[cfg(feature = "schemars")]
1904impl schemars::JsonSchema for ContentIdPrefix {
1905 fn inline_schema() -> bool {
1906 true
1907 }
1908
1909 fn schema_name() -> std::borrow::Cow<'static, str> {
1910 std::borrow::Cow::Borrowed("ContentIdPrefix")
1911 }
1912
1913 fn schema_id() -> std::borrow::Cow<'static, str> {
1914 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1915 }
1916
1917 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1918 schemars::json_schema!({
1919 "type": "string",
1920 "pattern": CONTENT_ID_PREFIX_PATTERN,
1921 "description": "A short post or comment id: the first eight hex \
1922 digits of its UUID, e.g. \"7ad26ccd\".",
1923 })
1924 }
1925}
1926
1927pub const CONTENT_TARGET_PATTERN: &str = "^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{8})$";
1932
1933#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1942pub enum ContentTarget {
1943 Id(ContentId),
1945 Prefix(ContentIdPrefix),
1947}
1948
1949impl ContentTarget {
1950 pub fn full(&self) -> Option<ContentId> {
1952 match self {
1953 ContentTarget::Id(id) => Some(*id),
1954 ContentTarget::Prefix(_) => None,
1955 }
1956 }
1957}
1958
1959impl From<ContentId> for ContentTarget {
1960 fn from(id: ContentId) -> Self {
1961 ContentTarget::Id(id)
1962 }
1963}
1964
1965impl std::fmt::Display for ContentTarget {
1966 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1967 match self {
1968 ContentTarget::Id(id) => id.fmt(f),
1969 ContentTarget::Prefix(prefix) => prefix.fmt(f),
1970 }
1971 }
1972}
1973
1974fn ascii_dashes(s: &str) -> String {
1979 s.chars()
1980 .map(|c| match c {
1981 '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
1982 c => c,
1983 })
1984 .collect()
1985}
1986
1987impl std::str::FromStr for ContentTarget {
1988 type Err = ContentTargetError;
1989
1990 fn from_str(s: &str) -> Result<Self, Self::Err> {
1991 let t = ascii_dashes(s.trim());
1992 if let Ok(id) = t.parse::<Uuid>() {
1993 return Ok(ContentTarget::Id(id.into()));
1994 }
1995 if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
1996 return Ok(ContentTarget::Prefix(prefix));
1997 }
1998 const UUID_LEN: usize = 36;
2001 let leading = t
2002 .get(..UUID_LEN)
2003 .and_then(|head| head.parse::<Uuid>().ok())
2004 .map(ContentId::from);
2005 Err(ContentTargetError {
2006 field: None,
2007 input: s.to_string(),
2008 leading,
2009 })
2010 }
2011}
2012
2013#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2019pub struct ContentTargetError {
2020 field: Option<&'static str>,
2021 input: String,
2022 leading: Option<ContentId>,
2023}
2024
2025impl ContentTargetError {
2026 pub fn in_field(mut self, field: &'static str) -> Self {
2028 self.field = Some(field);
2029 self
2030 }
2031}
2032
2033impl std::fmt::Display for ContentTargetError {
2034 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2035 match self.field {
2036 Some(field) => write!(f, "`{field}` must be")?,
2037 None => f.write_str("Expected")?,
2038 }
2039 f.write_str(
2040 " a post or comment id: a full UUID or its first 8 hex digits \
2041 (e.g. \"7ad26ccd\")",
2042 )?;
2043 write!(f, ", not \"{}\"", echo(&self.input))?;
2044 if let Some(id) = self.leading {
2045 write!(
2046 f,
2047 ". It starts with the id {id} followed by extra text; pass only the id"
2048 )?;
2049 }
2050 f.write_str(".")
2051 }
2052}
2053
2054impl Serialize for ContentTarget {
2055 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2056 s.collect_str(self)
2057 }
2058}
2059
2060impl<'de> Deserialize<'de> for ContentTarget {
2061 fn deserialize<D: serde::Deserializer<'de>>(
2062 d: D,
2063 ) -> Result<Self, D::Error> {
2064 content_target::deserialize_field(d, "id")
2065 }
2066}
2067
2068pub mod content_target {
2071 use serde::de::{self, Deserializer, Visitor};
2072
2073 use super::{ContentTarget, ContentTargetError};
2074
2075 struct TargetVisitor(&'static str);
2079
2080 impl Visitor<'_> for TargetVisitor {
2081 type Value = ContentTarget;
2082
2083 fn expecting(
2084 &self,
2085 f: &mut std::fmt::Formatter<'_>,
2086 ) -> std::fmt::Result {
2087 write!(
2088 f,
2089 "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
2090 (e.g. \"7ad26ccd\")",
2091 self.0
2092 )
2093 }
2094
2095 fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
2096 v.parse()
2097 .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
2098 }
2099 }
2100
2101 pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
2102 d: D,
2103 field: &'static str,
2104 ) -> Result<ContentTarget, D::Error> {
2105 d.deserialize_str(TargetVisitor(field))
2106 }
2107
2108 pub fn reply_to<'de, D: Deserializer<'de>>(
2110 d: D,
2111 ) -> Result<ContentTarget, D::Error> {
2112 deserialize_field(d, "reply_to")
2113 }
2114
2115 pub fn target<'de, D: Deserializer<'de>>(
2117 d: D,
2118 ) -> Result<ContentTarget, D::Error> {
2119 deserialize_field(d, "target")
2120 }
2121
2122 pub fn post_id<'de, D: Deserializer<'de>>(
2124 d: D,
2125 ) -> Result<ContentTarget, D::Error> {
2126 deserialize_field(d, "post_id")
2127 }
2128
2129 pub fn optional_reply_to<'de, D: Deserializer<'de>>(
2131 d: D,
2132 ) -> Result<Option<ContentTarget>, D::Error> {
2133 reply_to(d).map(Some)
2134 }
2135
2136 pub fn optional_target<'de, D: Deserializer<'de>>(
2140 d: D,
2141 ) -> Result<Option<ContentTarget>, D::Error> {
2142 d.deserialize_option(OptionalVisitor("target"))
2143 }
2144
2145 struct OptionalVisitor(&'static str);
2147
2148 impl<'de> Visitor<'de> for OptionalVisitor {
2149 type Value = Option<ContentTarget>;
2150
2151 fn expecting(
2152 &self,
2153 f: &mut std::fmt::Formatter<'_>,
2154 ) -> std::fmt::Result {
2155 TargetVisitor(self.0).expecting(f)?;
2156 f.write_str(", or nothing")
2157 }
2158
2159 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
2160 Ok(None)
2161 }
2162
2163 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
2164 Ok(None)
2165 }
2166
2167 fn visit_some<D: Deserializer<'de>>(
2168 self,
2169 d: D,
2170 ) -> Result<Self::Value, D::Error> {
2171 d.deserialize_str(self)
2172 }
2173
2174 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
2175 if v.is_empty() || v.trim().eq_ignore_ascii_case("null") {
2176 return Ok(None);
2177 }
2178 TargetVisitor(self.0).visit_str(v).map(Some)
2179 }
2180 }
2181}
2182
2183#[cfg(feature = "schemars")]
2187impl schemars::JsonSchema for ContentTarget {
2188 fn inline_schema() -> bool {
2189 true
2190 }
2191
2192 fn schema_name() -> std::borrow::Cow<'static, str> {
2193 std::borrow::Cow::Borrowed("ContentTarget")
2194 }
2195
2196 fn schema_id() -> std::borrow::Cow<'static, str> {
2197 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
2198 }
2199
2200 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2201 schemars::json_schema!({
2202 "type": "string",
2203 "pattern": CONTENT_TARGET_PATTERN,
2204 "description": "A post or comment id: its full UUID, or its first \
2205 8 hex digits (e.g. \"7ad26ccd\") as shown by \
2206 get_content and the dashboard. A short id works \
2207 only on unsigned calls; a signed call must use \
2208 the full UUID.",
2209 })
2210 }
2211}
2212
2213#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2216pub struct ContentRefError(pub String);
2217
2218impl ContentRefError {
2219 pub fn leading_ref(&self) -> Option<ContentRef> {
2223 const UUID_LEN: usize = 36;
2224 const CITATION_LENS: [usize; 2] =
2228 ["DATA-2026-0001".len(), "GOV-2026-0006".len()];
2229 let s = self.0.trim_start();
2230 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
2232 let head = s.get(..prefix.len())?;
2233 head.eq_ignore_ascii_case(prefix).then(|| {
2234 prefix.len()
2235 + s[prefix.len()..]
2236 .find(|c: char| {
2237 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
2238 })
2239 .unwrap_or(s.len() - prefix.len())
2240 })
2241 });
2242 prompt_len
2244 .into_iter()
2245 .chain([
2246 UUID_LEN,
2247 CITATION_LENS[0],
2248 CITATION_LENS[1],
2249 "constitution".len(),
2250 "protocol".len(),
2251 "prompts".len(),
2252 ])
2253 .filter(|&n| s.len() > n && s.is_char_boundary(n))
2254 .find_map(|n| {
2258 s[..n]
2259 .parse()
2260 .ok()
2261 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
2262 })
2263 }
2264}
2265
2266impl std::fmt::Display for ContentRefError {
2267 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2268 write!(
2269 f,
2270 "not a content reference (expected a post/comment UUID or its \
2271 first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
2272 like \"constitution\", \"protocol\", \"prompts\" or \
2273 \"prompt:<name>\"): {:?}",
2274 self.0
2275 )?;
2276 if let Some(id) = self.leading_ref() {
2277 write!(
2278 f,
2279 ". It starts with the valid id {id} followed by extra text; \
2280 pass only the id"
2281 )?;
2282 }
2283 Ok(())
2284 }
2285}
2286
2287#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2305pub enum ContentRef {
2306 Content(ContentId),
2308 ContentPrefix(ContentIdPrefix),
2311 Governance(GovernanceLogId),
2313 Document(PlatformDoc),
2315}
2316
2317impl ContentRef {
2318 pub fn as_content(&self) -> Option<ContentId> {
2320 match self {
2321 ContentRef::Content(id) => Some(*id),
2322 _ => None,
2323 }
2324 }
2325
2326 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
2328 match self {
2329 ContentRef::Governance(id) => Some(id),
2330 _ => None,
2331 }
2332 }
2333
2334 pub fn as_document(&self) -> Option<&PlatformDoc> {
2336 match self {
2337 ContentRef::Document(doc) => Some(doc),
2338 _ => None,
2339 }
2340 }
2341
2342 pub fn is_governance(&self) -> bool {
2344 matches!(self, ContentRef::Governance(_))
2345 }
2346
2347 pub fn kind_str(&self) -> &'static str {
2350 match self {
2351 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
2352 ContentRef::Governance(_) => "governance",
2353 ContentRef::Document(_) => "document",
2354 }
2355 }
2356}
2357
2358impl std::fmt::Display for ContentRef {
2359 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2360 match self {
2361 ContentRef::Content(id) => id.fmt(f),
2362 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
2363 ContentRef::Governance(id) => id.fmt(f),
2364 ContentRef::Document(doc) => doc.fmt(f),
2365 }
2366 }
2367}
2368
2369impl std::str::FromStr for ContentRef {
2370 type Err = ContentRefError;
2371
2372 fn from_str(s: &str) -> Result<Self, Self::Err> {
2373 if let Ok(id) = s.parse::<ContentId>() {
2374 return Ok(ContentRef::Content(id));
2375 }
2376 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
2377 return Ok(ContentRef::ContentPrefix(prefix));
2378 }
2379 if let Ok(id) = s.parse::<GovernanceLogId>() {
2380 return Ok(ContentRef::Governance(id));
2381 }
2382 if let Ok(doc) = s.parse::<PlatformDoc>() {
2383 return Ok(ContentRef::Document(doc));
2384 }
2385 Err(ContentRefError(s.to_string()))
2386 }
2387}
2388
2389impl TryFrom<String> for ContentRef {
2390 type Error = ContentRefError;
2391
2392 fn try_from(s: String) -> Result<Self, Self::Error> {
2393 s.parse()
2394 }
2395}
2396
2397impl From<ContentId> for ContentRef {
2398 fn from(id: ContentId) -> Self {
2399 ContentRef::Content(id)
2400 }
2401}
2402
2403impl From<PostId> for ContentRef {
2404 fn from(id: PostId) -> Self {
2405 ContentRef::Content(id.into())
2406 }
2407}
2408
2409impl From<CommentId> for ContentRef {
2410 fn from(id: CommentId) -> Self {
2411 ContentRef::Content(id.into())
2412 }
2413}
2414
2415impl From<ContentIdPrefix> for ContentRef {
2416 fn from(prefix: ContentIdPrefix) -> Self {
2417 ContentRef::ContentPrefix(prefix)
2418 }
2419}
2420
2421impl From<GovernanceLogId> for ContentRef {
2422 fn from(id: GovernanceLogId) -> Self {
2423 ContentRef::Governance(id)
2424 }
2425}
2426
2427impl From<PlatformDoc> for ContentRef {
2428 fn from(doc: PlatformDoc) -> Self {
2429 ContentRef::Document(doc)
2430 }
2431}
2432
2433impl Serialize for ContentRef {
2434 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2435 s.collect_str(self)
2436 }
2437}
2438
2439impl<'de> Deserialize<'de> for ContentRef {
2440 fn deserialize<D: serde::Deserializer<'de>>(
2441 d: D,
2442 ) -> Result<Self, D::Error> {
2443 let raw = String::deserialize(d)?;
2444 raw.parse().map_err(serde::de::Error::custom)
2445 }
2446}
2447
2448#[cfg(feature = "schemars")]
2453impl schemars::JsonSchema for ContentRef {
2454 fn inline_schema() -> bool {
2455 true
2456 }
2457
2458 fn schema_name() -> std::borrow::Cow<'static, str> {
2459 std::borrow::Cow::Borrowed("ContentRef")
2460 }
2461
2462 fn schema_id() -> std::borrow::Cow<'static, str> {
2463 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
2464 }
2465
2466 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2467 schemars::json_schema!({
2468 "type": "string",
2469 "pattern": CONTENT_REF_PATTERN,
2470 "description": "A post or comment UUID, or its first eight \
2471 hex digits (e.g. \"7ad26ccd\"); a governance log id \
2472 such as \"GOV-2026-0006\" (Council decision) \
2473 or \"APP-2026-0003\" (appeals ruling); or a \
2474 document slug — \"constitution\", \
2475 \"protocol\" (the Governance Protocol), \
2476 \"prompts\" (the index of the model prompts \
2477 moderation, appeals and the Council run on) or \
2478 \"prompt:<name>\" (one of them).",
2479 })
2480 }
2481}
2482
2483#[cfg(test)]
2484mod tests {
2485 use super::*;
2486
2487 #[test]
2488 fn witness_ids_fold_and_refuse() {
2489 let id: WitnessId = " Qwen_3-8 ".parse().unwrap();
2490 assert_eq!(id.as_str(), "qwen-3-8");
2491 for bad in ["", "3qwen", "-qwen", "qwen 3", "qwen.3", &"q".repeat(65)] {
2492 assert!(bad.parse::<WitnessId>().is_err(), "{bad:?}");
2493 }
2494 let json: WitnessId = serde_json::from_str("\"FACT_checker\"").unwrap();
2495 assert_eq!(json.as_str(), "fact-checker");
2496 assert!(serde_json::from_str::<WitnessId>("\"no way\"").is_err());
2497 }
2498
2499 #[cfg(feature = "schemars")]
2500 #[test]
2501 fn witness_id_schema_is_inline_and_patternless() {
2502 let schema =
2503 serde_json::to_value(schemars::schema_for!(WitnessId)).unwrap();
2504 let rendered = schema.to_string();
2505 assert!(!rendered.contains("pattern"), "{rendered}");
2506 assert!(!rendered.contains("$ref"), "{rendered}");
2507 }
2508
2509 #[test]
2510 fn governance_log_ids_from_parts() {
2511 for prefix in GovernanceLogPrefix::ALL {
2512 let id = GovernanceLogId::from_parts(prefix, 2026, 42).unwrap();
2513 assert_eq!(id.as_str(), format!("{prefix}-2026-0042"));
2514 assert_eq!(id.prefix(), prefix);
2515 assert_eq!(id.as_str().parse::<GovernanceLogId>().unwrap(), id);
2516 }
2517 let gov = GovernanceLogPrefix::Gov;
2518 assert!(GovernanceLogId::from_parts(gov, 2026, 9999).is_some());
2519 assert!(GovernanceLogId::from_parts(gov, 2026, 10_000).is_none());
2520 assert!(GovernanceLogId::from_parts(gov, 2026, 0).is_none());
2521 assert!(GovernanceLogId::from_parts(gov, 999, 1).is_none());
2522 }
2523
2524 #[test]
2526 fn archive_contexts_round_trip_through_their_columns() {
2527 use crate::enums::ArchiveContextKind;
2528 let contexts = [
2529 ArchiveContext::from(AgendaItemId::new()),
2530 ArchiveContext::from(CouncilMeetingId::new()),
2531 ArchiveContext::from(AppealId::new()),
2532 ArchiveContext::from(FlagId::new()),
2533 ArchiveContext::from(InterviewRunId::new()),
2534 ];
2535 let kinds: Vec<_> = contexts.iter().map(ArchiveContext::kind).collect();
2536 assert_eq!(kinds, ArchiveContextKind::ALL);
2537 for c in contexts {
2538 assert_eq!(ArchiveContext::from_stored(c.kind(), c.as_uuid()), c);
2539 }
2540 let id = AgendaItemId::new();
2541 assert_eq!(
2542 ArchiveContext::from(id).to_string(),
2543 format!("agenda item {id}")
2544 );
2545 }
2546
2547 #[test]
2548 fn byte_ids_debug() {
2549 let handle = WebAuthnUserHandle::from(vec![7; 64]);
2550 assert_eq!(format!("{handle:?}"), "WebAuthnUserHandle(..)");
2551 let credential = PasskeyCredentialId::from(vec![0xab; 2]);
2552 assert_eq!(format!("{credential:?}"), "PasskeyCredentialId(abab)");
2553 assert_eq!(credential.as_bytes(), &[0xab, 0xab]);
2554 }
2555
2556 #[test]
2557 fn oauth_client_id_round_trips() {
2558 let id = OAuthClientId::generate();
2559 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
2560 let json = serde_json::to_string(&id).unwrap();
2561 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
2562 }
2563
2564 #[test]
2565 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
2566 assert!("".parse::<OAuthClientId>().is_err());
2567 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
2568 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
2569 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
2570 }
2571
2572 #[test]
2573 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
2574 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
2575 for (input, lead) in [
2576 (format!("{uuid} and the safe-space proposal"), uuid),
2577 (format!("{uuid}b0518e42"), uuid),
2578 (
2579 "GOV-2026-0006 (the ratification)".to_string(),
2580 "GOV-2026-0006",
2581 ),
2582 (
2585 "DATA-2026-0001 (the vote tallies)".to_string(),
2586 "DATA-2026-0001",
2587 ),
2588 ("DATA-2026-0012, index.md".to_string(), "DATA-2026-0012"),
2589 ("OBL-2026-0001 and more".to_string(), "OBL-2026-0001"),
2590 ("protocol, section 3".to_string(), "protocol"),
2591 ("prompts, please".to_string(), "prompts"),
2592 (
2593 "prompt:tier2_reviewer and the juror's".to_string(),
2594 "prompt:tier2_reviewer",
2595 ),
2596 (
2597 "prompts/appeals_juror.md, line 3".to_string(),
2598 "prompt:appeals_juror",
2599 ),
2600 ] {
2601 let err = input.parse::<ContentRef>().unwrap_err();
2602 assert_eq!(
2603 err.leading_ref(),
2604 Some(lead.parse().unwrap()),
2605 "{input}"
2606 );
2607 let msg = err.to_string();
2608 assert!(
2609 msg.contains(&format!("valid id {lead} followed")),
2610 "{msg}"
2611 );
2612 }
2613
2614 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
2616 let err = short.parse::<ContentRef>().unwrap_err();
2617 assert_eq!(err.leading_ref(), None);
2618 assert!(!err.to_string().contains("followed by"));
2619 assert_eq!(
2621 "GOV-2026-1".parse::<ContentRef>().unwrap(),
2622 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
2623 );
2624 }
2625
2626 #[test]
2627 fn citations_normalize_as_written() {
2628 for (written, canonical) in [
2629 ("GOV-2026-0006", "GOV-2026-0006"),
2630 ("GOV-2026.6", "GOV-2026-0006"),
2631 ("gov-2026-6", "GOV-2026-0006"),
2632 ("GOV 2026 6", "GOV-2026-0006"),
2633 ("app/2026/03", "APP-2026-0003"),
2634 (" REC-2026-0002 ", "REC-2026-0002"),
2635 ("obl-2026-12", "OBL-2026-0012"),
2636 ("OBL.2026.7", "OBL-2026-0007"),
2637 ("data-2026-3", "DATA-2026-0003"),
2638 ("DATA.2026.12", "DATA-2026-0012"),
2639 (" Data/2026/0001 ", "DATA-2026-0001"),
2640 ("DATA 2026 7", "DATA-2026-0007"),
2641 ] {
2642 let id: GovernanceLogId = written.parse().unwrap();
2643 assert_eq!(id.as_str(), canonical, "{written}");
2644 let json: GovernanceLogId =
2645 serde_json::from_value(serde_json::json!(written)).unwrap();
2646 assert_eq!(json.as_str(), canonical, "{written} via serde");
2647 let content: ContentRef = written.parse().unwrap();
2648 assert_eq!(content, ContentRef::Governance(id), "{written}");
2649 }
2650 for rejected in [
2651 "GOV-2026-N",
2652 "GOV-2026/GOVG=6",
2653 "GOV-2026-00006",
2654 "GOV-26-0006",
2655 "GOV-2026-",
2656 "XYZ-2026-0006",
2657 "GOV-2026-0006-1",
2658 ] {
2659 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
2660 }
2661 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
2663 }
2664
2665 #[test]
2666 fn content_ref_parses_document_slugs() {
2667 assert_eq!(
2668 "constitution".parse(),
2669 Ok(ContentRef::Document(PlatformDoc::Constitution))
2670 );
2671 assert_eq!(
2672 "protocol".parse(),
2673 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2674 );
2675 assert_eq!(
2677 "governance-protocol".parse(),
2678 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2679 );
2680 assert_eq!(
2681 "Constitution".parse(),
2682 Ok(ContentRef::Document(PlatformDoc::Constitution))
2683 );
2684 assert!("proto".parse::<ContentRef>().is_err());
2685 }
2686
2687 #[test]
2688 fn content_ref_parses_prompt_slugs() {
2689 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
2690 for written in [
2691 "prompt:appeals_juror",
2692 "Prompt:Appeals_Juror",
2693 "prompt/appeals-juror",
2694 "prompts/appeals_juror.md",
2695 "prompts:appeals_juror",
2696 ] {
2697 assert_eq!(
2698 written.parse(),
2699 Ok(ContentRef::Document(juror.clone())),
2700 "{written}"
2701 );
2702 }
2703 assert_eq!(juror.to_string(), "prompt:appeals_juror");
2704 for index in ["prompts", "Prompts", "prompt"] {
2705 assert_eq!(
2706 index.parse(),
2707 Ok(ContentRef::Document(PlatformDoc::Prompts)),
2708 "{index}"
2709 );
2710 }
2711 for rejected in [
2712 "prompt:",
2713 "prompt:2fast",
2714 "prompt:tier 2",
2715 "prompt:../secrets",
2716 "prompt:tier2_reviewer/x",
2717 "promptly",
2718 ] {
2719 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
2720 }
2721 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
2722 assert!(long.parse::<ContentRef>().is_err());
2723 }
2724
2725 #[test]
2726 fn content_id_prefix_parses_eight_hex_digits_leniently() {
2727 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
2728 assert_eq!(p.to_string(), "7ad26ccd");
2729 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
2730 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
2731 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
2732 }
2733 }
2734
2735 #[test]
2736 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
2737 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2738 let p = ContentIdPrefix::of(&id);
2739 assert_eq!(p.to_string(), "7ad26ccd");
2740 assert!(p.matches(&id));
2741 let (lo, hi) = p.bounds();
2742 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
2743 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
2744 assert!(lo <= id && id <= hi);
2745 let next: Uuid =
2746 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
2747 assert!(!p.matches(&next) && next > hi);
2748 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
2750 assert_eq!(top.bounds().1, Uuid::max());
2751 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
2752 assert_eq!(bottom.bounds().0, Uuid::nil());
2753 }
2754
2755 #[test]
2756 fn content_ref_reads_a_short_id_and_only_eight_digits() {
2757 let r: ContentRef = "7ad26ccd".parse().unwrap();
2758 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
2759 assert_eq!(r.kind_str(), "content");
2760 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
2761 let full: ContentRef =
2763 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2764 assert!(matches!(full, ContentRef::Content(_)));
2765 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
2770 assert_eq!(err.leading_ref(), None);
2771 }
2772
2773 #[test]
2774 fn content_ref_pattern_admits_short_ids() {
2775 let short = CONTENT_ID_PREFIX_PATTERN
2776 .strip_prefix('^')
2777 .and_then(|p| p.strip_suffix('$'))
2778 .unwrap();
2779 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
2780 }
2781
2782 #[test]
2784 fn document_patterns_agree() {
2785 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
2786 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
2787 let name = PROMPT_NAME_PATTERN
2788 .strip_prefix('^')
2789 .and_then(|p| p.strip_suffix('$'))
2790 .unwrap();
2791 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
2792 }
2793
2794 #[test]
2795 fn calendar_is_a_document_slug() {
2796 assert_eq!(
2797 "calendar".parse::<ContentRef>().unwrap(),
2798 ContentRef::Document(PlatformDoc::Calendar)
2799 );
2800 assert_eq!(
2801 "Calendar".parse::<PlatformDoc>().unwrap(),
2802 PlatformDoc::Calendar
2803 );
2804 assert_eq!(PlatformDoc::Calendar.to_string(), "calendar");
2805 assert_eq!(
2806 "OBL-2026-0001".parse::<ContentRef>().unwrap(),
2807 ContentRef::Governance("OBL-2026-0001".parse().unwrap())
2808 );
2809 assert_eq!(
2810 "DATA-2026-0001".parse::<ContentRef>().unwrap(),
2811 ContentRef::Governance("DATA-2026-0001".parse().unwrap())
2812 );
2813 }
2814
2815 #[test]
2816 fn platform_doc_serde_uses_the_canonical_slug() {
2817 let json =
2818 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
2819 assert_eq!(json, "\"protocol\"");
2820 let doc: PlatformDoc =
2821 serde_json::from_str("\"governance-protocol\"").unwrap();
2822 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
2823 }
2824
2825 #[test]
2826 fn ids_are_unique() {
2827 let a = AgentId::new();
2828 let b = AgentId::new();
2829 assert_ne!(a, b);
2830 }
2831
2832 #[test]
2833 fn serde_round_trip() {
2834 let id = PostId::new();
2835 let json = serde_json::to_string(&id).unwrap();
2836 let deserialized: PostId = serde_json::from_str(&json).unwrap();
2837 assert_eq!(id, deserialized);
2838 }
2839
2840 #[test]
2841 fn display_shows_uuid() {
2842 let id = CommunityId::new();
2843 let display = id.to_string();
2844 assert_eq!(display.len(), 36);
2846 assert!(display.contains('-'));
2847 }
2848
2849 #[test]
2850 fn from_uuid_round_trip() {
2851 let uuid = Uuid::new_v4();
2852 let id = AgentId::from(uuid);
2853 let back: Uuid = id.into();
2854 assert_eq!(uuid, back);
2855 }
2856
2857 #[test]
2861 fn every_id_round_trips_through_its_own_display() {
2862 let agent = AgentId::new();
2863 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
2864
2865 let action = ModerationActionId::new();
2866 assert_eq!(
2867 action.to_string().parse::<ModerationActionId>().unwrap(),
2868 action
2869 );
2870
2871 let content = ContentId::new();
2872 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
2873
2874 let feedback = FeedbackId::new();
2875 assert_eq!(
2876 feedback.to_string().parse::<FeedbackId>().unwrap(),
2877 feedback
2878 );
2879 }
2880
2881 #[test]
2882 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
2883 assert!("not-a-uuid".parse::<ContentId>().is_err());
2884 assert!("".parse::<ContentId>().is_err());
2885 }
2886
2887 #[test]
2892 fn content_id_is_wire_compatible_with_a_bare_uuid() {
2893 let uuid = Uuid::new_v4();
2894 let typed = ContentId::from(uuid);
2895 assert_eq!(
2896 serde_json::to_string(&typed).unwrap(),
2897 serde_json::to_string(&uuid).unwrap()
2898 );
2899 }
2900
2901 #[test]
2904 fn every_moderation_target_narrows_losslessly() {
2905 let uuid = Uuid::new_v4();
2906
2907 for (label, got) in [
2908 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
2909 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
2910 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
2911 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
2912 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
2913 ] {
2914 assert_eq!(
2915 got.as_uuid(),
2916 &uuid,
2917 "{label} -> ModerationTargetId lost the uuid"
2918 );
2919 }
2920 }
2921
2922 #[test]
2926 fn resolved_ids_narrow_to_content_id_losslessly() {
2927 let uuid = Uuid::new_v4();
2928
2929 assert_eq!(
2930 ContentId::from(PostId::from(uuid)).as_uuid(),
2931 &uuid,
2932 "PostId -> ContentId lost the uuid"
2933 );
2934 assert_eq!(
2935 ContentId::from(CommentId::from(uuid)).as_uuid(),
2936 &uuid,
2937 "CommentId -> ContentId lost the uuid"
2938 );
2939 assert_eq!(
2940 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
2941 .as_uuid(),
2942 &uuid,
2943 "PostOrCommentId -> ContentId lost the uuid"
2944 );
2945 }
2946
2947 #[test]
2948 fn json_is_plain_uuid_string() {
2949 let uuid = Uuid::new_v4();
2950 let id = AgentId::from(uuid);
2951 let id_json = serde_json::to_string(&id).unwrap();
2953 let uuid_json = serde_json::to_string(&uuid).unwrap();
2954 assert_eq!(id_json, uuid_json);
2955 }
2956
2957 #[cfg(feature = "schemars")]
2962 #[test]
2963 fn id_json_schema_is_inlined() {
2964 use schemars::JsonSchema;
2965
2966 assert!(
2967 <PostId as JsonSchema>::inline_schema(),
2968 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
2969 );
2970 assert!(<AgentId as JsonSchema>::inline_schema());
2971 assert!(<CommentId as JsonSchema>::inline_schema());
2972 assert!(<CommunityId as JsonSchema>::inline_schema());
2973 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
2974 assert!(<ContentRef as JsonSchema>::inline_schema());
2975 assert!(<FeedbackId as JsonSchema>::inline_schema());
2976 assert!(<ContactRequestId as JsonSchema>::inline_schema());
2977
2978 #[derive(schemars::JsonSchema)]
2982 #[allow(dead_code)]
2983 struct Container {
2984 post_id: PostId,
2986 agent_id: Option<AgentId>,
2988 gov_id: GovernanceLogId,
2990 maybe_gov_id: Option<GovernanceLogId>,
2992 content_ref: ContentRef,
2994 maybe_content_ref: Option<ContentRef>,
2996 feedback_id: FeedbackId,
2998 }
2999
3000 let schema = schemars::schema_for!(Container);
3001 let value = serde_json::to_value(&schema).unwrap();
3002
3003 assert!(
3005 value.get("$defs").is_none(),
3006 "no $defs should be emitted for ID-only container; got schema: {value}"
3007 );
3008
3009 let post_id = &value["properties"]["post_id"];
3011 assert!(
3012 post_id.get("$ref").is_none(),
3013 "post_id must not be a $ref; got: {post_id}"
3014 );
3015 assert_eq!(post_id["type"], "string");
3016 assert_eq!(post_id["format"], "uuid");
3017
3018 let agent_id = &value["properties"]["agent_id"];
3023 assert!(
3024 agent_id.get("$ref").is_none(),
3025 "agent_id must not be a $ref; got: {agent_id}"
3026 );
3027 let agent_id_str = agent_id.to_string();
3028 assert!(
3029 !agent_id_str.contains("$ref"),
3030 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
3031 );
3032 assert!(
3033 agent_id_str.contains("\"format\":\"uuid\""),
3034 "agent_id should still carry format=uuid; got: {agent_id}"
3035 );
3036
3037 for field in
3042 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
3043 {
3044 let f = &value["properties"][field];
3045 assert!(
3046 !f.to_string().contains("$ref"),
3047 "{field} must contain no $ref anywhere; got: {f}"
3048 );
3049 }
3050 assert_eq!(value["properties"]["gov_id"]["type"], "string");
3051 assert_eq!(
3052 value["properties"]["gov_id"]["pattern"],
3053 GOVERNANCE_LOG_ID_PATTERN
3054 );
3055 assert!(
3056 value["properties"]["maybe_gov_id"]
3057 .to_string()
3058 .contains("GOV|APP"),
3059 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
3060 value["properties"]["maybe_gov_id"]
3061 );
3062 assert_eq!(value["properties"]["content_ref"]["type"], "string");
3063 }
3064
3065 #[test]
3066 fn governance_log_id_accepts_only_citation_shapes() {
3067 for (good, prefix) in [
3068 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
3069 ("APP-2026-0003", GovernanceLogPrefix::App),
3070 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
3071 ("KEY-2026-0001", GovernanceLogPrefix::Key),
3072 ("REC-2026-0001", GovernanceLogPrefix::Rec),
3073 ("OBL-2026-0001", GovernanceLogPrefix::Obl),
3074 ("DATA-2026-0001", GovernanceLogPrefix::Data),
3075 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
3076 ] {
3077 let id = good.parse::<GovernanceLogId>().unwrap();
3078 assert_eq!(id.as_str(), good, "{good} should parse");
3079 assert_eq!(id.prefix(), prefix);
3080 let letters = good.split('-').next().unwrap();
3081 assert_eq!(id.prefix().as_str(), letters);
3082 assert_eq!(id.prefix().to_string().parse(), Ok(prefix));
3083 }
3084 let newer: GovernanceLogId =
3086 serde_json::from_value(serde_json::json!("ZZZ-2026-0001")).unwrap();
3087 assert_eq!(newer.prefix(), GovernanceLogPrefix::Unknown);
3088 assert_eq!(newer.as_str(), "ZZZ-2026-0001");
3089 assert!("ZZZ-2026-0001".parse::<GovernanceLogId>().is_err());
3090 assert!(!GovernanceLogId::is_citation_shaped("ZZZ-2026-0001"));
3091 for junk in [
3092 "zzz-2026-0001",
3093 "ZZZZZ-2026-0001",
3094 "ZZ-2026-0001",
3095 "ZZZ-2026-1",
3096 ] {
3097 assert!(
3098 serde_json::from_value::<GovernanceLogId>(serde_json::json!(
3099 junk
3100 ))
3101 .is_err(),
3102 "{junk}"
3103 );
3104 }
3105 assert_eq!(
3106 GovernanceLogId::from_parts(GovernanceLogPrefix::Unknown, 2026, 1),
3107 None
3108 );
3109 for prefix in GovernanceLogPrefix::ALL {
3111 let want = if prefix == GovernanceLogPrefix::Data {
3112 4
3113 } else {
3114 3
3115 };
3116 assert_eq!(prefix.as_str().len(), want, "{prefix}");
3117 }
3118 let series = GovernanceLogPrefix::ALL.map(|p| p.as_str()).join("|");
3120 assert!(
3121 GOVERNANCE_LOG_ID_PATTERN.contains(&format!("({series})")),
3122 "{GOVERNANCE_LOG_ID_PATTERN}"
3123 );
3124 assert!(
3125 CONTENT_REF_PATTERN.contains(&format!("({series})")),
3126 "{CONTENT_REF_PATTERN}"
3127 );
3128 assert_eq!(
3130 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
3131 "GOV-2026-0006"
3132 );
3133 assert_eq!(
3134 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
3135 "GOV-2026-0006"
3136 );
3137 for bad in [
3138 "",
3139 "GOV-26-0006",
3140 "MOD-2026-0006",
3141 "GOV-2026-0006-1",
3142 "GOV-202X-0006",
3143 "DAT-2026-0001",
3144 "DATAS-2026-0001",
3145 "DATA-2026-00001",
3146 "DATA-2026",
3147 "3f1a0000-0000-0000-0000-000000000000",
3148 ] {
3149 assert!(
3150 bad.parse::<GovernanceLogId>().is_err(),
3151 "{bad:?} should not parse as a GovernanceLogId"
3152 );
3153 }
3154 }
3155
3156 #[test]
3160 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
3161 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3162 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
3163 let back: GovernanceLogId =
3164 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
3165 assert_eq!(back, id);
3166 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
3168 }
3169
3170 #[test]
3173 fn content_ref_round_trips_as_a_bare_string() {
3174 let uuid = Uuid::new_v4();
3175 let content = ContentRef::from(ContentId::from(uuid));
3176 assert_eq!(
3177 serde_json::to_value(&content).unwrap(),
3178 serde_json::json!(uuid.to_string())
3179 );
3180 assert_eq!(
3181 serde_json::from_value::<ContentRef>(serde_json::json!(
3182 uuid.to_string()
3183 ))
3184 .unwrap(),
3185 content
3186 );
3187
3188 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
3189 assert_eq!(
3190 serde_json::to_value(&gov).unwrap(),
3191 serde_json::json!("APP-2026-0003")
3192 );
3193 assert_eq!(
3194 serde_json::from_value::<ContentRef>(serde_json::json!(
3195 "APP-2026-0003"
3196 ))
3197 .unwrap(),
3198 gov
3199 );
3200
3201 assert!(gov.is_governance());
3202 assert!(!content.is_governance());
3203 assert_eq!(gov.kind_str(), "governance");
3204 assert_eq!(content.kind_str(), "content");
3205 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
3206 assert!(content.as_governance().is_none());
3207
3208 assert!("not-an-id".parse::<ContentRef>().is_err());
3210 assert!(
3211 serde_json::from_value::<ContentRef>(serde_json::json!(
3212 "not-an-id"
3213 ))
3214 .is_err()
3215 );
3216 }
3217
3218 #[test]
3220 fn every_readable_id_narrows_to_a_content_ref() {
3221 let uuid = Uuid::new_v4();
3222 for (label, got) in [
3223 ("PostId", ContentRef::from(PostId::from(uuid))),
3224 ("CommentId", ContentRef::from(CommentId::from(uuid))),
3225 ("ContentId", ContentRef::from(ContentId::from(uuid))),
3226 ] {
3227 assert_eq!(
3228 got,
3229 ContentRef::Content(ContentId::from(uuid)),
3230 "{label} -> ContentRef lost the uuid"
3231 );
3232 }
3233 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3234 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
3235 }
3236
3237 #[test]
3240 fn string_shaped_ids_round_trip_through_display() {
3241 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3242 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
3243
3244 let r = ContentRef::Governance(gov);
3245 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3246
3247 let r = ContentRef::Content(ContentId::new());
3248 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3249 }
3250
3251 #[test]
3252 fn post_or_comment_post_variant() {
3253 let inner = PostId::new();
3254 let tagged = PostOrCommentId::Post(inner);
3255 assert!(tagged.is_post());
3256 assert!(!tagged.is_comment());
3257 assert_eq!(tagged.as_post(), Some(inner));
3258 assert_eq!(tagged.as_comment(), None);
3259 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3260 assert_eq!(tagged.kind_str(), "post");
3261 }
3262
3263 #[test]
3264 fn post_or_comment_comment_variant() {
3265 let inner = CommentId::new();
3266 let tagged = PostOrCommentId::Comment(inner);
3267 assert!(tagged.is_comment());
3268 assert!(!tagged.is_post());
3269 assert_eq!(tagged.as_comment(), Some(inner));
3270 assert_eq!(tagged.as_post(), None);
3271 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3272 assert_eq!(tagged.kind_str(), "comment");
3273 }
3274
3275 #[test]
3276 fn post_or_comment_from_conversions() {
3277 let post = PostId::new();
3278 let comment = CommentId::new();
3279 let via_post: PostOrCommentId = post.into();
3280 let via_comment: PostOrCommentId = comment.into();
3281 assert_eq!(via_post, PostOrCommentId::Post(post));
3282 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
3283 }
3284
3285 #[test]
3286 fn post_or_comment_display_is_kind_colon_uuid() {
3287 let post = PostId::new();
3288 let tagged = PostOrCommentId::Post(post);
3289 let rendered = tagged.to_string();
3290 assert!(rendered.starts_with("post:"));
3291 assert!(rendered.contains(&post.to_string()));
3292 }
3293
3294 const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
3297
3298 #[test]
3301 fn a_bad_id_fails_in_plain_words() {
3302 let bad = "7ad26ccd-922f-484a-a37c-51777344a";
3303 let want = format!(
3304 "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
3305 );
3306 assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
3307 let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
3308 .unwrap_err()
3309 .to_string();
3310 assert!(err.starts_with(&want), "{err}");
3311 assert!(!err.contains("group"), "{err}");
3312 let err = serde_json::from_str::<AgentId>("5")
3313 .unwrap_err()
3314 .to_string();
3315 assert!(err.contains("expected AgentId as a UUID string"), "{err}");
3316 let long = "x".repeat(500);
3318 let msg = long.parse::<CommentId>().unwrap_err().to_string();
3319 assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
3320 }
3321
3322 #[test]
3324 fn ids_still_round_trip() {
3325 let id: PostId = FULL.parse().unwrap();
3326 let json = serde_json::to_string(&id).unwrap();
3327 assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
3328 let map: std::collections::HashMap<PostId, i64> =
3329 [(id, 3)].into_iter().collect();
3330 let json = serde_json::to_string(&map).unwrap();
3331 let back: std::collections::HashMap<PostId, i64> =
3332 serde_json::from_str(&json).unwrap();
3333 assert_eq!(back, map);
3334 }
3335
3336 fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
3337 s.parse()
3338 }
3339
3340 #[test]
3341 fn full_and_short_targets_parse() {
3342 let full = target(FULL).unwrap();
3343 assert_eq!(full.full().unwrap().to_string(), FULL);
3344 assert_eq!(full.to_string(), FULL);
3345 let short = target(" 7AD26CCD ").unwrap();
3346 assert!(short.full().is_none());
3347 assert_eq!(short.to_string(), "7ad26ccd");
3348 for variant in [
3350 FULL.replace('-', ""),
3351 FULL.to_uppercase(),
3352 FULL.replace('-', "\u{2011}"),
3353 ] {
3354 assert_eq!(target(&variant).unwrap(), full, "{variant}");
3355 }
3356 }
3357
3358 #[test]
3359 fn a_truncated_uuid_is_an_error_not_its_prefix() {
3360 let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
3361 let msg = err.in_field("reply_to").to_string();
3362 assert!(
3363 msg.starts_with(
3364 "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
3365 ),
3366 "{msg}"
3367 );
3368 for internal in [
3369 "group",
3370 "UUID parsing",
3371 "invalid length",
3372 "invalid character",
3373 ] {
3374 assert!(!msg.contains(internal), "{internal} leaked: {msg}");
3375 }
3376 assert!(target("7ad26cc").is_err());
3377 assert!(target("7ad26ccd9").is_err());
3378 }
3379
3380 #[test]
3381 fn trailing_text_names_the_id_it_starts_with() {
3382 let msg = target(&format!("{FULL} (the reply above)"))
3383 .unwrap_err()
3384 .to_string();
3385 assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
3386 }
3387
3388 #[derive(Debug, serde::Deserialize)]
3389 struct Probe {
3390 #[serde(deserialize_with = "content_target::reply_to")]
3391 #[allow(dead_code)]
3392 reply_to: ContentTarget,
3393 }
3394
3395 #[test]
3396 fn target_errors_name_the_field_for_strings_and_other_types() {
3397 let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
3398 .unwrap_err();
3399 assert!(
3400 err.to_string()
3401 .starts_with("`reply_to` must be a post or comment id"),
3402 "{err}"
3403 );
3404 let err =
3405 serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
3406 assert!(
3407 err.to_string()
3408 .contains("expected `reply_to` to be a post or comment id"),
3409 "{err}"
3410 );
3411 }
3412
3413 #[cfg(feature = "schemars")]
3414 #[test]
3415 fn target_schema_is_inline_and_admits_both_forms() {
3416 let schema = schemars::schema_for!(ContentTarget);
3417 let v = schema.as_value();
3418 assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
3419 assert!(v.get("format").is_none(), "a short id is not format: uuid");
3420 assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
3421 }
3422}