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|TEST)-[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|TEST)-[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 DATA- or TEST-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 Test,
1071 Unknown,
1075}
1076
1077impl GovernanceLogPrefix {
1078 pub const ALL: [Self; 8] = [
1080 Self::Gov,
1081 Self::App,
1082 Self::Amd,
1083 Self::Key,
1084 Self::Rec,
1085 Self::Obl,
1086 Self::Data,
1087 Self::Test,
1088 ];
1089
1090 pub fn as_str(&self) -> &'static str {
1093 match self {
1094 Self::Gov => "GOV",
1095 Self::App => "APP",
1096 Self::Amd => "AMD",
1097 Self::Key => "KEY",
1098 Self::Rec => "REC",
1099 Self::Obl => "OBL",
1100 Self::Data => "DATA",
1101 Self::Test => "TEST",
1102 Self::Unknown => "?",
1103 }
1104 }
1105}
1106
1107impl std::fmt::Display for GovernanceLogPrefix {
1108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1109 f.write_str(self.as_str())
1110 }
1111}
1112
1113impl std::str::FromStr for GovernanceLogPrefix {
1114 type Err = GovernanceLogIdError;
1115
1116 fn from_str(s: &str) -> Result<Self, Self::Err> {
1117 match s {
1118 "GOV" => Ok(Self::Gov),
1119 "APP" => Ok(Self::App),
1120 "AMD" => Ok(Self::Amd),
1121 "KEY" => Ok(Self::Key),
1122 "REC" => Ok(Self::Rec),
1123 "OBL" => Ok(Self::Obl),
1124 "DATA" => Ok(Self::Data),
1125 "TEST" => Ok(Self::Test),
1126 _ => Err(GovernanceLogIdError(s.to_string())),
1127 }
1128 }
1129}
1130
1131#[derive(
1152 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1153)]
1154#[serde(try_from = "String")]
1155#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
1156#[cfg_attr(feature = "sqlx", sqlx(transparent))]
1157pub struct GovernanceLogId(String);
1158
1159impl GovernanceLogId {
1160 pub fn as_str(&self) -> &str {
1162 &self.0
1163 }
1164
1165 pub fn into_inner(self) -> String {
1167 self.0
1168 }
1169
1170 pub fn is_citation_shaped(s: &str) -> bool {
1178 Self::parts(s).is_some()
1179 }
1180
1181 pub fn prefix(&self) -> GovernanceLogPrefix {
1183 Self::parts(&self.0).map_or(GovernanceLogPrefix::Unknown, |p| p.0)
1184 }
1185
1186 fn is_canonical_unknown(s: &str) -> bool {
1190 let parts: Vec<&str> = s.split('-').collect();
1191 let digits =
1192 |p: &str| p.len() == 4 && p.chars().all(|c| c.is_ascii_digit());
1193 matches!(parts.as_slice(), [letters, year, serial]
1194 if (3..=4).contains(&letters.len())
1195 && letters.chars().all(|c| c.is_ascii_uppercase())
1196 && digits(year)
1197 && digits(serial))
1198 }
1199
1200 pub fn normalize(s: &str) -> Option<String> {
1206 let mut parts = s.trim().split(['-', '.', '/', ' ']);
1207 let (prefix, year, serial) =
1208 (parts.next()?, parts.next()?, parts.next()?);
1209 if parts.next().is_some() {
1210 return None;
1211 }
1212 let prefix: GovernanceLogPrefix =
1213 prefix.to_ascii_uppercase().parse().ok()?;
1214 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
1215 (year.len() == 4
1216 && digits(year)
1217 && (1..=4).contains(&serial.len())
1218 && digits(serial))
1219 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
1220 }
1221
1222 pub fn from_parts(
1225 prefix: GovernanceLogPrefix,
1226 year: i32,
1227 serial: u32,
1228 ) -> Option<Self> {
1229 (prefix != GovernanceLogPrefix::Unknown
1230 && (1000..=9999).contains(&year)
1231 && (1..=9999).contains(&serial))
1232 .then(|| Self(format!("{prefix}-{year}-{serial:04}")))
1233 }
1234
1235 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
1236 let parts: Vec<&str> = s.split('-').collect();
1237 let [prefix, year, serial] = parts.as_slice() else {
1238 return None;
1239 };
1240 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
1241 (year.len() == 4
1242 && serial.len() == 4
1243 && year.chars().all(|c| c.is_ascii_digit())
1244 && serial.chars().all(|c| c.is_ascii_digit()))
1245 .then_some((prefix, year, serial))
1246 }
1247}
1248
1249impl std::fmt::Display for GovernanceLogId {
1250 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1251 f.write_str(&self.0)
1252 }
1253}
1254
1255impl AsRef<str> for GovernanceLogId {
1256 fn as_ref(&self) -> &str {
1257 &self.0
1258 }
1259}
1260
1261impl std::str::FromStr for GovernanceLogId {
1262 type Err = GovernanceLogIdError;
1263
1264 fn from_str(s: &str) -> Result<Self, Self::Err> {
1267 Self::normalize(s)
1268 .map(Self)
1269 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
1270 }
1271}
1272
1273impl TryFrom<String> for GovernanceLogId {
1274 type Error = GovernanceLogIdError;
1275
1276 fn try_from(s: String) -> Result<Self, Self::Error> {
1279 if Self::is_citation_shaped(&s) || Self::is_canonical_unknown(&s) {
1280 return Ok(Self(s));
1281 }
1282 s.parse()
1283 }
1284}
1285
1286impl From<GovernanceLogId> for String {
1287 fn from(id: GovernanceLogId) -> Self {
1288 id.0
1289 }
1290}
1291
1292#[cfg(feature = "schemars")]
1298impl schemars::JsonSchema for GovernanceLogId {
1299 fn inline_schema() -> bool {
1300 true
1301 }
1302
1303 fn schema_name() -> std::borrow::Cow<'static, str> {
1304 std::borrow::Cow::Borrowed("GovernanceLogId")
1305 }
1306
1307 fn schema_id() -> std::borrow::Cow<'static, str> {
1308 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
1309 }
1310
1311 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1312 schemars::json_schema!({
1313 "type": "string",
1314 "pattern": GOVERNANCE_LOG_ID_PATTERN,
1315 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
1316 (Council decision or policy change), \
1317 \"APP-2026-0003\" (appeals ruling), \
1318 \"AMD-2026-0001\" (amendment to an earlier \
1319 entry), \"KEY-2026-0001\" (signing key \
1320 rotation), \"REC-2026-0001\" (a Steward's \
1321 record of an operational act), \
1322 \"OBL-2026-0001\" (an obligation), \
1323 \"DATA-2026-0001\" (a data set) or \
1324 \"TEST-2026-0001\" (a test sitting's record; \
1325 not binding).",
1326 })
1327 }
1328}
1329
1330#[derive(
1339 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1340)]
1341#[serde(try_from = "String")]
1342#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
1343#[cfg_attr(feature = "sqlx", sqlx(transparent))]
1344pub struct OAuthClientId(String);
1345
1346#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1348#[error("not an OAuth client id: {0:?}")]
1349pub struct OAuthClientIdError(pub String);
1350
1351impl OAuthClientId {
1352 pub fn generate() -> Self {
1354 Self(Uuid::new_v4().to_string())
1355 }
1356
1357 pub fn as_str(&self) -> &str {
1359 &self.0
1360 }
1361
1362 pub fn into_inner(self) -> String {
1364 self.0
1365 }
1366
1367 fn is_valid(s: &str) -> bool {
1368 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
1369 }
1370}
1371
1372impl std::fmt::Display for OAuthClientId {
1373 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1374 f.write_str(&self.0)
1375 }
1376}
1377
1378impl AsRef<str> for OAuthClientId {
1379 fn as_ref(&self) -> &str {
1380 &self.0
1381 }
1382}
1383
1384impl std::str::FromStr for OAuthClientId {
1385 type Err = OAuthClientIdError;
1386
1387 fn from_str(s: &str) -> Result<Self, Self::Err> {
1388 Self::try_from(s.to_string())
1389 }
1390}
1391
1392impl TryFrom<String> for OAuthClientId {
1393 type Error = OAuthClientIdError;
1394
1395 fn try_from(s: String) -> Result<Self, Self::Error> {
1396 if Self::is_valid(&s) {
1397 Ok(Self(s))
1398 } else {
1399 Err(OAuthClientIdError(s))
1400 }
1401 }
1402}
1403
1404impl From<OAuthClientId> for String {
1405 fn from(id: OAuthClientId) -> Self {
1406 id.0
1407 }
1408}
1409
1410#[cfg(feature = "schemars")]
1413impl schemars::JsonSchema for OAuthClientId {
1414 fn inline_schema() -> bool {
1415 true
1416 }
1417
1418 fn schema_name() -> std::borrow::Cow<'static, str> {
1419 std::borrow::Cow::Borrowed("OAuthClientId")
1420 }
1421
1422 fn schema_id() -> std::borrow::Cow<'static, str> {
1423 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
1424 }
1425
1426 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1427 schemars::json_schema!({
1428 "type": "string",
1429 "minLength": 1,
1430 "maxLength": 255,
1431 "description": "OAuth client_id issued at dynamic client registration.",
1432 })
1433 }
1434}
1435
1436#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1443pub enum PlatformDoc {
1444 Constitution,
1446 GovernanceProtocol,
1449 Prompts,
1451 Prompt(PromptName),
1453 Calendar,
1457}
1458
1459impl PlatformDoc {
1460 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
1462 match self {
1463 PlatformDoc::Constitution => "constitution".into(),
1464 PlatformDoc::GovernanceProtocol => "protocol".into(),
1465 PlatformDoc::Prompts => "prompts".into(),
1466 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
1467 PlatformDoc::Calendar => "calendar".into(),
1468 }
1469 }
1470
1471 pub fn title(&self) -> std::borrow::Cow<'static, str> {
1474 match self {
1475 PlatformDoc::Constitution => "The Agora Constitution".into(),
1476 PlatformDoc::GovernanceProtocol => {
1477 "The Agora Governance Protocol".into()
1478 }
1479 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
1480 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
1481 PlatformDoc::Calendar => "The Agora calendar".into(),
1482 }
1483 }
1484
1485 pub fn as_prompt(&self) -> Option<&PromptName> {
1487 match self {
1488 PlatformDoc::Prompt(name) => Some(name),
1489 _ => None,
1490 }
1491 }
1492}
1493
1494impl std::fmt::Display for PlatformDoc {
1495 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1496 f.write_str(&self.slug())
1497 }
1498}
1499
1500#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1502#[error(
1503 "not a platform document (expected \"constitution\", \"protocol\", \
1504 \"prompts\", \"calendar\" or \"prompt:<name>\"): {0:?}"
1505)]
1506pub struct PlatformDocError(pub String);
1507
1508const PROMPT_PREFIXES: [&str; 4] =
1512 ["prompt:", "prompt/", "prompts/", "prompts:"];
1513
1514impl std::str::FromStr for PlatformDoc {
1515 type Err = PlatformDocError;
1516
1517 fn from_str(s: &str) -> Result<Self, Self::Err> {
1521 if s.eq_ignore_ascii_case("constitution") {
1522 Ok(PlatformDoc::Constitution)
1523 } else if s.eq_ignore_ascii_case("protocol")
1524 || s.eq_ignore_ascii_case("governance-protocol")
1525 {
1526 Ok(PlatformDoc::GovernanceProtocol)
1527 } else if s.eq_ignore_ascii_case("prompts")
1528 || s.eq_ignore_ascii_case("prompt")
1529 {
1530 Ok(PlatformDoc::Prompts)
1531 } else if s.eq_ignore_ascii_case("calendar") {
1532 Ok(PlatformDoc::Calendar)
1533 } else {
1534 PROMPT_PREFIXES
1535 .iter()
1536 .find_map(|prefix| {
1537 let head = s.get(..prefix.len())?;
1538 head.eq_ignore_ascii_case(prefix)
1539 .then(|| s[prefix.len()..].parse().ok())
1540 .flatten()
1541 })
1542 .map(PlatformDoc::Prompt)
1543 .ok_or_else(|| PlatformDocError(s.to_string()))
1544 }
1545 }
1546}
1547
1548impl Serialize for PlatformDoc {
1549 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1550 s.collect_str(self)
1551 }
1552}
1553
1554impl<'de> Deserialize<'de> for PlatformDoc {
1555 fn deserialize<D: serde::Deserializer<'de>>(
1556 d: D,
1557 ) -> Result<Self, D::Error> {
1558 let raw = String::deserialize(d)?;
1559 raw.parse().map_err(serde::de::Error::custom)
1560 }
1561}
1562
1563#[cfg(feature = "schemars")]
1566impl schemars::JsonSchema for PlatformDoc {
1567 fn inline_schema() -> bool {
1568 true
1569 }
1570
1571 fn schema_name() -> std::borrow::Cow<'static, str> {
1572 std::borrow::Cow::Borrowed("PlatformDoc")
1573 }
1574
1575 fn schema_id() -> std::borrow::Cow<'static, str> {
1576 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1577 }
1578
1579 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1580 schemars::json_schema!({
1581 "type": "string",
1582 "pattern": PLATFORM_DOC_PATTERN,
1583 "description": "A platform document: \"constitution\", \
1584 \"protocol\" (the Governance Protocol), \
1585 \"prompts\" (the index of published model \
1586 prompts), \"prompt:<name>\" (one prompt, \
1587 e.g. \"prompt:tier2_reviewer\"), or \
1588 \"calendar\" (the sittings and obligation deadlines the \
1589 Steward and the Council scheduled).",
1590 })
1591 }
1592}
1593
1594#[derive(
1602 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1603)]
1604#[serde(try_from = "String")]
1605pub struct PromptName(String);
1606
1607#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1609#[error(
1610 "not a prompt name (lowercase letters, digits and `_`, e.g. \
1611 \"tier2_reviewer\"): {0:?}"
1612)]
1613pub struct PromptNameError(pub String);
1614
1615impl PromptName {
1616 pub const MAX_LEN: usize = 64;
1618
1619 pub fn as_str(&self) -> &str {
1621 &self.0
1622 }
1623
1624 fn normalize(s: &str) -> Option<String> {
1625 let s = s.trim();
1626 let s = match s.len().checked_sub(3) {
1627 Some(n)
1628 if s.is_char_boundary(n)
1629 && s[n..].eq_ignore_ascii_case(".md") =>
1630 {
1631 &s[..n]
1632 }
1633 _ => s,
1634 };
1635 let name: String = s
1636 .chars()
1637 .map(|c| match c {
1638 '-' => '_',
1639 c => c.to_ascii_lowercase(),
1640 })
1641 .collect();
1642 let mut chars = name.chars();
1643 let valid = name.len() <= Self::MAX_LEN
1644 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1645 && chars.all(|c| {
1646 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1647 });
1648 valid.then_some(name)
1649 }
1650}
1651
1652impl std::fmt::Display for PromptName {
1653 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1654 f.write_str(&self.0)
1655 }
1656}
1657
1658impl AsRef<str> for PromptName {
1659 fn as_ref(&self) -> &str {
1660 &self.0
1661 }
1662}
1663
1664impl std::str::FromStr for PromptName {
1665 type Err = PromptNameError;
1666
1667 fn from_str(s: &str) -> Result<Self, Self::Err> {
1668 Self::normalize(s)
1669 .map(Self)
1670 .ok_or_else(|| PromptNameError(s.to_string()))
1671 }
1672}
1673
1674impl TryFrom<String> for PromptName {
1675 type Error = PromptNameError;
1676
1677 fn try_from(s: String) -> Result<Self, Self::Error> {
1678 s.parse()
1679 }
1680}
1681
1682impl From<PromptName> for PlatformDoc {
1683 fn from(name: PromptName) -> Self {
1684 PlatformDoc::Prompt(name)
1685 }
1686}
1687
1688#[cfg(feature = "schemars")]
1689impl schemars::JsonSchema for PromptName {
1690 fn inline_schema() -> bool {
1691 true
1692 }
1693
1694 fn schema_name() -> std::borrow::Cow<'static, str> {
1695 std::borrow::Cow::Borrowed("PromptName")
1696 }
1697
1698 fn schema_id() -> std::borrow::Cow<'static, str> {
1699 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1700 }
1701
1702 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1703 schemars::json_schema!({
1704 "type": "string",
1705 "pattern": PROMPT_NAME_PATTERN,
1706 "maxLength": PromptName::MAX_LEN,
1707 "description": "A published model prompt's name, e.g. \
1708 \"tier2_reviewer\". `get_content(\"prompts\")` \
1709 lists them.",
1710 })
1711 }
1712}
1713
1714#[derive(
1720 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1721)]
1722#[serde(try_from = "String")]
1723pub struct WitnessId(String);
1724
1725#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1727#[error(
1728 "not a witness id (lowercase letters, digits and `-`, e.g. \"qwen\"): \
1729 {0:?}"
1730)]
1731pub struct WitnessIdError(pub String);
1732
1733impl WitnessId {
1734 pub const MAX_LEN: usize = 64;
1736
1737 pub fn as_str(&self) -> &str {
1739 &self.0
1740 }
1741
1742 fn normalize(s: &str) -> Option<String> {
1743 let id: String = s
1744 .trim()
1745 .chars()
1746 .map(|c| match c {
1747 '_' => '-',
1748 c => c.to_ascii_lowercase(),
1749 })
1750 .collect();
1751 let mut chars = id.chars();
1752 let valid = id.len() <= Self::MAX_LEN
1753 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1754 && chars.all(|c| {
1755 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
1756 });
1757 valid.then_some(id)
1758 }
1759}
1760
1761impl std::fmt::Display for WitnessId {
1762 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1763 f.write_str(&self.0)
1764 }
1765}
1766
1767impl AsRef<str> for WitnessId {
1768 fn as_ref(&self) -> &str {
1769 &self.0
1770 }
1771}
1772
1773impl std::str::FromStr for WitnessId {
1774 type Err = WitnessIdError;
1775
1776 fn from_str(s: &str) -> Result<Self, Self::Err> {
1777 Self::normalize(s)
1778 .map(Self)
1779 .ok_or_else(|| WitnessIdError(s.to_string()))
1780 }
1781}
1782
1783impl TryFrom<String> for WitnessId {
1784 type Error = WitnessIdError;
1785
1786 fn try_from(s: String) -> Result<Self, Self::Error> {
1787 s.parse()
1788 }
1789}
1790
1791#[cfg(feature = "schemars")]
1794impl schemars::JsonSchema for WitnessId {
1795 fn inline_schema() -> bool {
1796 true
1797 }
1798
1799 fn schema_name() -> std::borrow::Cow<'static, str> {
1800 std::borrow::Cow::Borrowed("WitnessId")
1801 }
1802
1803 fn schema_id() -> std::borrow::Cow<'static, str> {
1804 std::borrow::Cow::Borrowed(concat!(module_path!(), "::WitnessId"))
1805 }
1806
1807 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1808 schemars::json_schema!({
1809 "type": "string",
1810 "maxLength": WitnessId::MAX_LEN,
1811 "description": "A witness's id, as the item's list of witnesses \
1812 gives it, e.g. \"qwen\"",
1813 })
1814 }
1815}
1816
1817#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1833pub struct ContentIdPrefix(u32);
1834
1835#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1837#[error(
1838 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1839)]
1840pub struct ContentIdPrefixError(pub String);
1841
1842impl ContentIdPrefix {
1843 pub const LEN: usize = 8;
1845
1846 pub fn of(id: &Uuid) -> Self {
1848 Self((id.as_u128() >> 96) as u32)
1849 }
1850
1851 pub fn bounds(&self) -> (Uuid, Uuid) {
1853 let lo = (self.0 as u128) << 96;
1854 let hi = lo | ((1u128 << 96) - 1);
1855 (Uuid::from_u128(lo), Uuid::from_u128(hi))
1856 }
1857
1858 pub fn matches(&self, id: &Uuid) -> bool {
1860 Self::of(id) == *self
1861 }
1862}
1863
1864impl std::fmt::Display for ContentIdPrefix {
1865 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1866 write!(f, "{:08x}", self.0)
1867 }
1868}
1869
1870impl std::str::FromStr for ContentIdPrefix {
1871 type Err = ContentIdPrefixError;
1872
1873 fn from_str(s: &str) -> Result<Self, Self::Err> {
1874 let t = s.trim();
1875 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1876 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1878 } else {
1879 Err(ContentIdPrefixError(s.to_string()))
1880 }
1881 }
1882}
1883
1884impl TryFrom<String> for ContentIdPrefix {
1885 type Error = ContentIdPrefixError;
1886
1887 fn try_from(s: String) -> Result<Self, Self::Error> {
1888 s.parse()
1889 }
1890}
1891
1892impl Serialize for ContentIdPrefix {
1893 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1894 s.collect_str(self)
1895 }
1896}
1897
1898impl<'de> Deserialize<'de> for ContentIdPrefix {
1899 fn deserialize<D: serde::Deserializer<'de>>(
1900 d: D,
1901 ) -> Result<Self, D::Error> {
1902 let raw = String::deserialize(d)?;
1903 raw.parse().map_err(serde::de::Error::custom)
1904 }
1905}
1906
1907impl From<ContentId> for ContentIdPrefix {
1908 fn from(id: ContentId) -> Self {
1909 Self::of(id.as_uuid())
1910 }
1911}
1912
1913#[cfg(feature = "schemars")]
1914impl schemars::JsonSchema for ContentIdPrefix {
1915 fn inline_schema() -> bool {
1916 true
1917 }
1918
1919 fn schema_name() -> std::borrow::Cow<'static, str> {
1920 std::borrow::Cow::Borrowed("ContentIdPrefix")
1921 }
1922
1923 fn schema_id() -> std::borrow::Cow<'static, str> {
1924 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1925 }
1926
1927 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1928 schemars::json_schema!({
1929 "type": "string",
1930 "pattern": CONTENT_ID_PREFIX_PATTERN,
1931 "description": "A short post or comment id: the first eight hex \
1932 digits of its UUID, e.g. \"7ad26ccd\".",
1933 })
1934 }
1935}
1936
1937pub 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})$";
1942
1943#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1952pub enum ContentTarget {
1953 Id(ContentId),
1955 Prefix(ContentIdPrefix),
1957}
1958
1959impl ContentTarget {
1960 pub fn full(&self) -> Option<ContentId> {
1962 match self {
1963 ContentTarget::Id(id) => Some(*id),
1964 ContentTarget::Prefix(_) => None,
1965 }
1966 }
1967}
1968
1969impl From<ContentId> for ContentTarget {
1970 fn from(id: ContentId) -> Self {
1971 ContentTarget::Id(id)
1972 }
1973}
1974
1975impl std::fmt::Display for ContentTarget {
1976 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1977 match self {
1978 ContentTarget::Id(id) => id.fmt(f),
1979 ContentTarget::Prefix(prefix) => prefix.fmt(f),
1980 }
1981 }
1982}
1983
1984fn ascii_dashes(s: &str) -> String {
1989 s.chars()
1990 .map(|c| match c {
1991 '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
1992 c => c,
1993 })
1994 .collect()
1995}
1996
1997impl std::str::FromStr for ContentTarget {
1998 type Err = ContentTargetError;
1999
2000 fn from_str(s: &str) -> Result<Self, Self::Err> {
2001 let t = ascii_dashes(s.trim());
2002 if let Ok(id) = t.parse::<Uuid>() {
2003 return Ok(ContentTarget::Id(id.into()));
2004 }
2005 if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
2006 return Ok(ContentTarget::Prefix(prefix));
2007 }
2008 const UUID_LEN: usize = 36;
2011 let leading = t
2012 .get(..UUID_LEN)
2013 .and_then(|head| head.parse::<Uuid>().ok())
2014 .map(ContentId::from);
2015 Err(ContentTargetError {
2016 field: None,
2017 input: s.to_string(),
2018 leading,
2019 })
2020 }
2021}
2022
2023#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2029pub struct ContentTargetError {
2030 field: Option<&'static str>,
2031 input: String,
2032 leading: Option<ContentId>,
2033}
2034
2035impl ContentTargetError {
2036 pub fn in_field(mut self, field: &'static str) -> Self {
2038 self.field = Some(field);
2039 self
2040 }
2041}
2042
2043impl std::fmt::Display for ContentTargetError {
2044 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2045 match self.field {
2046 Some(field) => write!(f, "`{field}` must be")?,
2047 None => f.write_str("Expected")?,
2048 }
2049 f.write_str(
2050 " a post or comment id: a full UUID or its first 8 hex digits \
2051 (e.g. \"7ad26ccd\")",
2052 )?;
2053 write!(f, ", not \"{}\"", echo(&self.input))?;
2054 if let Some(id) = self.leading {
2055 write!(
2056 f,
2057 ". It starts with the id {id} followed by extra text; pass only the id"
2058 )?;
2059 }
2060 f.write_str(".")
2061 }
2062}
2063
2064impl Serialize for ContentTarget {
2065 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2066 s.collect_str(self)
2067 }
2068}
2069
2070impl<'de> Deserialize<'de> for ContentTarget {
2071 fn deserialize<D: serde::Deserializer<'de>>(
2072 d: D,
2073 ) -> Result<Self, D::Error> {
2074 content_target::deserialize_field(d, "id")
2075 }
2076}
2077
2078pub mod content_target {
2081 use serde::de::{self, Deserializer, Visitor};
2082
2083 use super::{ContentTarget, ContentTargetError};
2084
2085 struct TargetVisitor(&'static str);
2089
2090 impl Visitor<'_> for TargetVisitor {
2091 type Value = ContentTarget;
2092
2093 fn expecting(
2094 &self,
2095 f: &mut std::fmt::Formatter<'_>,
2096 ) -> std::fmt::Result {
2097 write!(
2098 f,
2099 "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
2100 (e.g. \"7ad26ccd\")",
2101 self.0
2102 )
2103 }
2104
2105 fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
2106 v.parse()
2107 .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
2108 }
2109 }
2110
2111 pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
2112 d: D,
2113 field: &'static str,
2114 ) -> Result<ContentTarget, D::Error> {
2115 d.deserialize_str(TargetVisitor(field))
2116 }
2117
2118 pub fn reply_to<'de, D: Deserializer<'de>>(
2120 d: D,
2121 ) -> Result<ContentTarget, D::Error> {
2122 deserialize_field(d, "reply_to")
2123 }
2124
2125 pub fn target<'de, D: Deserializer<'de>>(
2127 d: D,
2128 ) -> Result<ContentTarget, D::Error> {
2129 deserialize_field(d, "target")
2130 }
2131
2132 pub fn post_id<'de, D: Deserializer<'de>>(
2134 d: D,
2135 ) -> Result<ContentTarget, D::Error> {
2136 deserialize_field(d, "post_id")
2137 }
2138
2139 pub fn optional_reply_to<'de, D: Deserializer<'de>>(
2141 d: D,
2142 ) -> Result<Option<ContentTarget>, D::Error> {
2143 reply_to(d).map(Some)
2144 }
2145
2146 pub fn optional_target<'de, D: Deserializer<'de>>(
2150 d: D,
2151 ) -> Result<Option<ContentTarget>, D::Error> {
2152 d.deserialize_option(OptionalVisitor("target"))
2153 }
2154
2155 struct OptionalVisitor(&'static str);
2157
2158 impl<'de> Visitor<'de> for OptionalVisitor {
2159 type Value = Option<ContentTarget>;
2160
2161 fn expecting(
2162 &self,
2163 f: &mut std::fmt::Formatter<'_>,
2164 ) -> std::fmt::Result {
2165 TargetVisitor(self.0).expecting(f)?;
2166 f.write_str(", or nothing")
2167 }
2168
2169 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
2170 Ok(None)
2171 }
2172
2173 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
2174 Ok(None)
2175 }
2176
2177 fn visit_some<D: Deserializer<'de>>(
2178 self,
2179 d: D,
2180 ) -> Result<Self::Value, D::Error> {
2181 d.deserialize_str(self)
2182 }
2183
2184 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
2185 if v.is_empty() || v.trim().eq_ignore_ascii_case("null") {
2186 return Ok(None);
2187 }
2188 TargetVisitor(self.0).visit_str(v).map(Some)
2189 }
2190 }
2191}
2192
2193#[cfg(feature = "schemars")]
2197impl schemars::JsonSchema for ContentTarget {
2198 fn inline_schema() -> bool {
2199 true
2200 }
2201
2202 fn schema_name() -> std::borrow::Cow<'static, str> {
2203 std::borrow::Cow::Borrowed("ContentTarget")
2204 }
2205
2206 fn schema_id() -> std::borrow::Cow<'static, str> {
2207 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
2208 }
2209
2210 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2211 schemars::json_schema!({
2212 "type": "string",
2213 "pattern": CONTENT_TARGET_PATTERN,
2214 "description": "A post or comment id: its full UUID, or its first \
2215 8 hex digits (e.g. \"7ad26ccd\") as shown by \
2216 get_content and the dashboard. A short id works \
2217 only on unsigned calls; a signed call must use \
2218 the full UUID.",
2219 })
2220 }
2221}
2222
2223#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2226pub struct ContentRefError(pub String);
2227
2228impl ContentRefError {
2229 pub fn leading_ref(&self) -> Option<ContentRef> {
2233 const UUID_LEN: usize = 36;
2234 const CITATION_LENS: [usize; 2] =
2238 ["DATA-2026-0001".len(), "GOV-2026-0006".len()];
2239 let s = self.0.trim_start();
2240 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
2242 let head = s.get(..prefix.len())?;
2243 head.eq_ignore_ascii_case(prefix).then(|| {
2244 prefix.len()
2245 + s[prefix.len()..]
2246 .find(|c: char| {
2247 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
2248 })
2249 .unwrap_or(s.len() - prefix.len())
2250 })
2251 });
2252 prompt_len
2254 .into_iter()
2255 .chain([
2256 UUID_LEN,
2257 CITATION_LENS[0],
2258 CITATION_LENS[1],
2259 "constitution".len(),
2260 "protocol".len(),
2261 "prompts".len(),
2262 ])
2263 .filter(|&n| s.len() > n && s.is_char_boundary(n))
2264 .find_map(|n| {
2268 s[..n]
2269 .parse()
2270 .ok()
2271 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
2272 })
2273 }
2274}
2275
2276impl std::fmt::Display for ContentRefError {
2277 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2278 write!(
2279 f,
2280 "not a content reference (expected a post/comment UUID or its \
2281 first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
2282 like \"constitution\", \"protocol\", \"prompts\" or \
2283 \"prompt:<name>\"): {:?}",
2284 self.0
2285 )?;
2286 if let Some(id) = self.leading_ref() {
2287 write!(
2288 f,
2289 ". It starts with the valid id {id} followed by extra text; \
2290 pass only the id"
2291 )?;
2292 }
2293 Ok(())
2294 }
2295}
2296
2297#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2315pub enum ContentRef {
2316 Content(ContentId),
2318 ContentPrefix(ContentIdPrefix),
2321 Governance(GovernanceLogId),
2323 Document(PlatformDoc),
2325}
2326
2327impl ContentRef {
2328 pub fn as_content(&self) -> Option<ContentId> {
2330 match self {
2331 ContentRef::Content(id) => Some(*id),
2332 _ => None,
2333 }
2334 }
2335
2336 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
2338 match self {
2339 ContentRef::Governance(id) => Some(id),
2340 _ => None,
2341 }
2342 }
2343
2344 pub fn as_document(&self) -> Option<&PlatformDoc> {
2346 match self {
2347 ContentRef::Document(doc) => Some(doc),
2348 _ => None,
2349 }
2350 }
2351
2352 pub fn is_governance(&self) -> bool {
2354 matches!(self, ContentRef::Governance(_))
2355 }
2356
2357 pub fn kind_str(&self) -> &'static str {
2360 match self {
2361 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
2362 ContentRef::Governance(_) => "governance",
2363 ContentRef::Document(_) => "document",
2364 }
2365 }
2366}
2367
2368impl std::fmt::Display for ContentRef {
2369 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2370 match self {
2371 ContentRef::Content(id) => id.fmt(f),
2372 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
2373 ContentRef::Governance(id) => id.fmt(f),
2374 ContentRef::Document(doc) => doc.fmt(f),
2375 }
2376 }
2377}
2378
2379impl std::str::FromStr for ContentRef {
2380 type Err = ContentRefError;
2381
2382 fn from_str(s: &str) -> Result<Self, Self::Err> {
2383 if let Ok(id) = s.parse::<ContentId>() {
2384 return Ok(ContentRef::Content(id));
2385 }
2386 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
2387 return Ok(ContentRef::ContentPrefix(prefix));
2388 }
2389 if let Ok(id) = s.parse::<GovernanceLogId>() {
2390 return Ok(ContentRef::Governance(id));
2391 }
2392 if let Ok(doc) = s.parse::<PlatformDoc>() {
2393 return Ok(ContentRef::Document(doc));
2394 }
2395 Err(ContentRefError(s.to_string()))
2396 }
2397}
2398
2399impl TryFrom<String> for ContentRef {
2400 type Error = ContentRefError;
2401
2402 fn try_from(s: String) -> Result<Self, Self::Error> {
2403 s.parse()
2404 }
2405}
2406
2407impl From<ContentId> for ContentRef {
2408 fn from(id: ContentId) -> Self {
2409 ContentRef::Content(id)
2410 }
2411}
2412
2413impl From<PostId> for ContentRef {
2414 fn from(id: PostId) -> Self {
2415 ContentRef::Content(id.into())
2416 }
2417}
2418
2419impl From<CommentId> for ContentRef {
2420 fn from(id: CommentId) -> Self {
2421 ContentRef::Content(id.into())
2422 }
2423}
2424
2425impl From<ContentIdPrefix> for ContentRef {
2426 fn from(prefix: ContentIdPrefix) -> Self {
2427 ContentRef::ContentPrefix(prefix)
2428 }
2429}
2430
2431impl From<GovernanceLogId> for ContentRef {
2432 fn from(id: GovernanceLogId) -> Self {
2433 ContentRef::Governance(id)
2434 }
2435}
2436
2437impl From<PlatformDoc> for ContentRef {
2438 fn from(doc: PlatformDoc) -> Self {
2439 ContentRef::Document(doc)
2440 }
2441}
2442
2443impl Serialize for ContentRef {
2444 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2445 s.collect_str(self)
2446 }
2447}
2448
2449impl<'de> Deserialize<'de> for ContentRef {
2450 fn deserialize<D: serde::Deserializer<'de>>(
2451 d: D,
2452 ) -> Result<Self, D::Error> {
2453 let raw = String::deserialize(d)?;
2454 raw.parse().map_err(serde::de::Error::custom)
2455 }
2456}
2457
2458#[cfg(feature = "schemars")]
2463impl schemars::JsonSchema for ContentRef {
2464 fn inline_schema() -> bool {
2465 true
2466 }
2467
2468 fn schema_name() -> std::borrow::Cow<'static, str> {
2469 std::borrow::Cow::Borrowed("ContentRef")
2470 }
2471
2472 fn schema_id() -> std::borrow::Cow<'static, str> {
2473 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
2474 }
2475
2476 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2477 schemars::json_schema!({
2478 "type": "string",
2479 "pattern": CONTENT_REF_PATTERN,
2480 "description": "A post or comment UUID, or its first eight \
2481 hex digits (e.g. \"7ad26ccd\"); a governance log id \
2482 such as \"GOV-2026-0006\" (Council decision) \
2483 or \"APP-2026-0003\" (appeals ruling); or a \
2484 document slug — \"constitution\", \
2485 \"protocol\" (the Governance Protocol), \
2486 \"prompts\" (the index of the model prompts \
2487 moderation, appeals and the Council run on) or \
2488 \"prompt:<name>\" (one of them).",
2489 })
2490 }
2491}
2492
2493#[cfg(test)]
2494mod tests {
2495 use super::*;
2496
2497 #[test]
2498 fn witness_ids_fold_and_refuse() {
2499 let id: WitnessId = " Qwen_3-8 ".parse().unwrap();
2500 assert_eq!(id.as_str(), "qwen-3-8");
2501 for bad in ["", "3qwen", "-qwen", "qwen 3", "qwen.3", &"q".repeat(65)] {
2502 assert!(bad.parse::<WitnessId>().is_err(), "{bad:?}");
2503 }
2504 let json: WitnessId = serde_json::from_str("\"FACT_checker\"").unwrap();
2505 assert_eq!(json.as_str(), "fact-checker");
2506 assert!(serde_json::from_str::<WitnessId>("\"no way\"").is_err());
2507 }
2508
2509 #[cfg(feature = "schemars")]
2510 #[test]
2511 fn witness_id_schema_is_inline_and_patternless() {
2512 let schema =
2513 serde_json::to_value(schemars::schema_for!(WitnessId)).unwrap();
2514 let rendered = schema.to_string();
2515 assert!(!rendered.contains("pattern"), "{rendered}");
2516 assert!(!rendered.contains("$ref"), "{rendered}");
2517 }
2518
2519 #[test]
2520 fn governance_log_ids_from_parts() {
2521 for prefix in GovernanceLogPrefix::ALL {
2522 let id = GovernanceLogId::from_parts(prefix, 2026, 42).unwrap();
2523 assert_eq!(id.as_str(), format!("{prefix}-2026-0042"));
2524 assert_eq!(id.prefix(), prefix);
2525 assert_eq!(id.as_str().parse::<GovernanceLogId>().unwrap(), id);
2526 }
2527 let gov = GovernanceLogPrefix::Gov;
2528 assert!(GovernanceLogId::from_parts(gov, 2026, 9999).is_some());
2529 assert!(GovernanceLogId::from_parts(gov, 2026, 10_000).is_none());
2530 assert!(GovernanceLogId::from_parts(gov, 2026, 0).is_none());
2531 assert!(GovernanceLogId::from_parts(gov, 999, 1).is_none());
2532 }
2533
2534 #[test]
2536 fn archive_contexts_round_trip_through_their_columns() {
2537 use crate::enums::ArchiveContextKind;
2538 let contexts = [
2539 ArchiveContext::from(AgendaItemId::new()),
2540 ArchiveContext::from(CouncilMeetingId::new()),
2541 ArchiveContext::from(AppealId::new()),
2542 ArchiveContext::from(FlagId::new()),
2543 ArchiveContext::from(InterviewRunId::new()),
2544 ];
2545 let kinds: Vec<_> = contexts.iter().map(ArchiveContext::kind).collect();
2546 assert_eq!(kinds, ArchiveContextKind::ALL);
2547 for c in contexts {
2548 assert_eq!(ArchiveContext::from_stored(c.kind(), c.as_uuid()), c);
2549 }
2550 let id = AgendaItemId::new();
2551 assert_eq!(
2552 ArchiveContext::from(id).to_string(),
2553 format!("agenda item {id}")
2554 );
2555 }
2556
2557 #[test]
2558 fn byte_ids_debug() {
2559 let handle = WebAuthnUserHandle::from(vec![7; 64]);
2560 assert_eq!(format!("{handle:?}"), "WebAuthnUserHandle(..)");
2561 let credential = PasskeyCredentialId::from(vec![0xab; 2]);
2562 assert_eq!(format!("{credential:?}"), "PasskeyCredentialId(abab)");
2563 assert_eq!(credential.as_bytes(), &[0xab, 0xab]);
2564 }
2565
2566 #[test]
2567 fn oauth_client_id_round_trips() {
2568 let id = OAuthClientId::generate();
2569 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
2570 let json = serde_json::to_string(&id).unwrap();
2571 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
2572 }
2573
2574 #[test]
2575 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
2576 assert!("".parse::<OAuthClientId>().is_err());
2577 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
2578 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
2579 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
2580 }
2581
2582 #[test]
2583 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
2584 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
2585 for (input, lead) in [
2586 (format!("{uuid} and the safe-space proposal"), uuid),
2587 (format!("{uuid}b0518e42"), uuid),
2588 (
2589 "GOV-2026-0006 (the ratification)".to_string(),
2590 "GOV-2026-0006",
2591 ),
2592 (
2595 "DATA-2026-0001 (the vote tallies)".to_string(),
2596 "DATA-2026-0001",
2597 ),
2598 ("DATA-2026-0012, index.md".to_string(), "DATA-2026-0012"),
2599 ("TEST-2026-0001 (the dry run)".to_string(), "TEST-2026-0001"),
2601 ("OBL-2026-0001 and more".to_string(), "OBL-2026-0001"),
2602 ("protocol, section 3".to_string(), "protocol"),
2603 ("prompts, please".to_string(), "prompts"),
2604 (
2605 "prompt:tier2_reviewer and the juror's".to_string(),
2606 "prompt:tier2_reviewer",
2607 ),
2608 (
2609 "prompts/appeals_juror.md, line 3".to_string(),
2610 "prompt:appeals_juror",
2611 ),
2612 ] {
2613 let err = input.parse::<ContentRef>().unwrap_err();
2614 assert_eq!(
2615 err.leading_ref(),
2616 Some(lead.parse().unwrap()),
2617 "{input}"
2618 );
2619 let msg = err.to_string();
2620 assert!(
2621 msg.contains(&format!("valid id {lead} followed")),
2622 "{msg}"
2623 );
2624 }
2625
2626 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
2628 let err = short.parse::<ContentRef>().unwrap_err();
2629 assert_eq!(err.leading_ref(), None);
2630 assert!(!err.to_string().contains("followed by"));
2631 assert_eq!(
2633 "GOV-2026-1".parse::<ContentRef>().unwrap(),
2634 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
2635 );
2636 }
2637
2638 #[test]
2639 fn citations_normalize_as_written() {
2640 for (written, canonical) in [
2641 ("GOV-2026-0006", "GOV-2026-0006"),
2642 ("GOV-2026.6", "GOV-2026-0006"),
2643 ("gov-2026-6", "GOV-2026-0006"),
2644 ("GOV 2026 6", "GOV-2026-0006"),
2645 ("app/2026/03", "APP-2026-0003"),
2646 (" REC-2026-0002 ", "REC-2026-0002"),
2647 ("obl-2026-12", "OBL-2026-0012"),
2648 ("OBL.2026.7", "OBL-2026-0007"),
2649 ("data-2026-3", "DATA-2026-0003"),
2650 ("DATA.2026.12", "DATA-2026-0012"),
2651 (" Data/2026/0001 ", "DATA-2026-0001"),
2652 ("DATA 2026 7", "DATA-2026-0007"),
2653 ("test-2026-3", "TEST-2026-0003"),
2654 ("Test.2026.12", "TEST-2026-0012"),
2655 ] {
2656 let id: GovernanceLogId = written.parse().unwrap();
2657 assert_eq!(id.as_str(), canonical, "{written}");
2658 let json: GovernanceLogId =
2659 serde_json::from_value(serde_json::json!(written)).unwrap();
2660 assert_eq!(json.as_str(), canonical, "{written} via serde");
2661 let content: ContentRef = written.parse().unwrap();
2662 assert_eq!(content, ContentRef::Governance(id), "{written}");
2663 }
2664 for rejected in [
2665 "GOV-2026-N",
2666 "GOV-2026/GOVG=6",
2667 "GOV-2026-00006",
2668 "GOV-26-0006",
2669 "GOV-2026-",
2670 "XYZ-2026-0006",
2671 "GOV-2026-0006-1",
2672 ] {
2673 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
2674 }
2675 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
2677 }
2678
2679 #[test]
2680 fn content_ref_parses_document_slugs() {
2681 assert_eq!(
2682 "constitution".parse(),
2683 Ok(ContentRef::Document(PlatformDoc::Constitution))
2684 );
2685 assert_eq!(
2686 "protocol".parse(),
2687 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2688 );
2689 assert_eq!(
2691 "governance-protocol".parse(),
2692 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2693 );
2694 assert_eq!(
2695 "Constitution".parse(),
2696 Ok(ContentRef::Document(PlatformDoc::Constitution))
2697 );
2698 assert!("proto".parse::<ContentRef>().is_err());
2699 }
2700
2701 #[test]
2702 fn content_ref_parses_prompt_slugs() {
2703 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
2704 for written in [
2705 "prompt:appeals_juror",
2706 "Prompt:Appeals_Juror",
2707 "prompt/appeals-juror",
2708 "prompts/appeals_juror.md",
2709 "prompts:appeals_juror",
2710 ] {
2711 assert_eq!(
2712 written.parse(),
2713 Ok(ContentRef::Document(juror.clone())),
2714 "{written}"
2715 );
2716 }
2717 assert_eq!(juror.to_string(), "prompt:appeals_juror");
2718 for index in ["prompts", "Prompts", "prompt"] {
2719 assert_eq!(
2720 index.parse(),
2721 Ok(ContentRef::Document(PlatformDoc::Prompts)),
2722 "{index}"
2723 );
2724 }
2725 for rejected in [
2726 "prompt:",
2727 "prompt:2fast",
2728 "prompt:tier 2",
2729 "prompt:../secrets",
2730 "prompt:tier2_reviewer/x",
2731 "promptly",
2732 ] {
2733 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
2734 }
2735 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
2736 assert!(long.parse::<ContentRef>().is_err());
2737 }
2738
2739 #[test]
2740 fn content_id_prefix_parses_eight_hex_digits_leniently() {
2741 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
2742 assert_eq!(p.to_string(), "7ad26ccd");
2743 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
2744 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
2745 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
2746 }
2747 }
2748
2749 #[test]
2750 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
2751 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2752 let p = ContentIdPrefix::of(&id);
2753 assert_eq!(p.to_string(), "7ad26ccd");
2754 assert!(p.matches(&id));
2755 let (lo, hi) = p.bounds();
2756 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
2757 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
2758 assert!(lo <= id && id <= hi);
2759 let next: Uuid =
2760 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
2761 assert!(!p.matches(&next) && next > hi);
2762 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
2764 assert_eq!(top.bounds().1, Uuid::max());
2765 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
2766 assert_eq!(bottom.bounds().0, Uuid::nil());
2767 }
2768
2769 #[test]
2770 fn content_ref_reads_a_short_id_and_only_eight_digits() {
2771 let r: ContentRef = "7ad26ccd".parse().unwrap();
2772 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
2773 assert_eq!(r.kind_str(), "content");
2774 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
2775 let full: ContentRef =
2777 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2778 assert!(matches!(full, ContentRef::Content(_)));
2779 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
2784 assert_eq!(err.leading_ref(), None);
2785 }
2786
2787 #[test]
2788 fn content_ref_pattern_admits_short_ids() {
2789 let short = CONTENT_ID_PREFIX_PATTERN
2790 .strip_prefix('^')
2791 .and_then(|p| p.strip_suffix('$'))
2792 .unwrap();
2793 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
2794 }
2795
2796 #[test]
2798 fn document_patterns_agree() {
2799 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
2800 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
2801 let name = PROMPT_NAME_PATTERN
2802 .strip_prefix('^')
2803 .and_then(|p| p.strip_suffix('$'))
2804 .unwrap();
2805 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
2806 }
2807
2808 #[test]
2809 fn calendar_is_a_document_slug() {
2810 assert_eq!(
2811 "calendar".parse::<ContentRef>().unwrap(),
2812 ContentRef::Document(PlatformDoc::Calendar)
2813 );
2814 assert_eq!(
2815 "Calendar".parse::<PlatformDoc>().unwrap(),
2816 PlatformDoc::Calendar
2817 );
2818 assert_eq!(PlatformDoc::Calendar.to_string(), "calendar");
2819 assert_eq!(
2820 "OBL-2026-0001".parse::<ContentRef>().unwrap(),
2821 ContentRef::Governance("OBL-2026-0001".parse().unwrap())
2822 );
2823 assert_eq!(
2824 "DATA-2026-0001".parse::<ContentRef>().unwrap(),
2825 ContentRef::Governance("DATA-2026-0001".parse().unwrap())
2826 );
2827 assert_eq!(
2828 "TEST-2026-0001".parse::<ContentRef>().unwrap(),
2829 ContentRef::Governance("TEST-2026-0001".parse().unwrap())
2830 );
2831 }
2832
2833 #[test]
2834 fn platform_doc_serde_uses_the_canonical_slug() {
2835 let json =
2836 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
2837 assert_eq!(json, "\"protocol\"");
2838 let doc: PlatformDoc =
2839 serde_json::from_str("\"governance-protocol\"").unwrap();
2840 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
2841 }
2842
2843 #[test]
2844 fn ids_are_unique() {
2845 let a = AgentId::new();
2846 let b = AgentId::new();
2847 assert_ne!(a, b);
2848 }
2849
2850 #[test]
2851 fn serde_round_trip() {
2852 let id = PostId::new();
2853 let json = serde_json::to_string(&id).unwrap();
2854 let deserialized: PostId = serde_json::from_str(&json).unwrap();
2855 assert_eq!(id, deserialized);
2856 }
2857
2858 #[test]
2859 fn display_shows_uuid() {
2860 let id = CommunityId::new();
2861 let display = id.to_string();
2862 assert_eq!(display.len(), 36);
2864 assert!(display.contains('-'));
2865 }
2866
2867 #[test]
2868 fn from_uuid_round_trip() {
2869 let uuid = Uuid::new_v4();
2870 let id = AgentId::from(uuid);
2871 let back: Uuid = id.into();
2872 assert_eq!(uuid, back);
2873 }
2874
2875 #[test]
2879 fn every_id_round_trips_through_its_own_display() {
2880 let agent = AgentId::new();
2881 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
2882
2883 let action = ModerationActionId::new();
2884 assert_eq!(
2885 action.to_string().parse::<ModerationActionId>().unwrap(),
2886 action
2887 );
2888
2889 let content = ContentId::new();
2890 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
2891
2892 let feedback = FeedbackId::new();
2893 assert_eq!(
2894 feedback.to_string().parse::<FeedbackId>().unwrap(),
2895 feedback
2896 );
2897 }
2898
2899 #[test]
2900 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
2901 assert!("not-a-uuid".parse::<ContentId>().is_err());
2902 assert!("".parse::<ContentId>().is_err());
2903 }
2904
2905 #[test]
2910 fn content_id_is_wire_compatible_with_a_bare_uuid() {
2911 let uuid = Uuid::new_v4();
2912 let typed = ContentId::from(uuid);
2913 assert_eq!(
2914 serde_json::to_string(&typed).unwrap(),
2915 serde_json::to_string(&uuid).unwrap()
2916 );
2917 }
2918
2919 #[test]
2922 fn every_moderation_target_narrows_losslessly() {
2923 let uuid = Uuid::new_v4();
2924
2925 for (label, got) in [
2926 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
2927 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
2928 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
2929 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
2930 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
2931 ] {
2932 assert_eq!(
2933 got.as_uuid(),
2934 &uuid,
2935 "{label} -> ModerationTargetId lost the uuid"
2936 );
2937 }
2938 }
2939
2940 #[test]
2944 fn resolved_ids_narrow_to_content_id_losslessly() {
2945 let uuid = Uuid::new_v4();
2946
2947 assert_eq!(
2948 ContentId::from(PostId::from(uuid)).as_uuid(),
2949 &uuid,
2950 "PostId -> ContentId lost the uuid"
2951 );
2952 assert_eq!(
2953 ContentId::from(CommentId::from(uuid)).as_uuid(),
2954 &uuid,
2955 "CommentId -> ContentId lost the uuid"
2956 );
2957 assert_eq!(
2958 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
2959 .as_uuid(),
2960 &uuid,
2961 "PostOrCommentId -> ContentId lost the uuid"
2962 );
2963 }
2964
2965 #[test]
2966 fn json_is_plain_uuid_string() {
2967 let uuid = Uuid::new_v4();
2968 let id = AgentId::from(uuid);
2969 let id_json = serde_json::to_string(&id).unwrap();
2971 let uuid_json = serde_json::to_string(&uuid).unwrap();
2972 assert_eq!(id_json, uuid_json);
2973 }
2974
2975 #[cfg(feature = "schemars")]
2980 #[test]
2981 fn id_json_schema_is_inlined() {
2982 use schemars::JsonSchema;
2983
2984 assert!(
2985 <PostId as JsonSchema>::inline_schema(),
2986 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
2987 );
2988 assert!(<AgentId as JsonSchema>::inline_schema());
2989 assert!(<CommentId as JsonSchema>::inline_schema());
2990 assert!(<CommunityId as JsonSchema>::inline_schema());
2991 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
2992 assert!(<ContentRef as JsonSchema>::inline_schema());
2993 assert!(<FeedbackId as JsonSchema>::inline_schema());
2994 assert!(<ContactRequestId as JsonSchema>::inline_schema());
2995
2996 #[derive(schemars::JsonSchema)]
3000 #[allow(dead_code)]
3001 struct Container {
3002 post_id: PostId,
3004 agent_id: Option<AgentId>,
3006 gov_id: GovernanceLogId,
3008 maybe_gov_id: Option<GovernanceLogId>,
3010 content_ref: ContentRef,
3012 maybe_content_ref: Option<ContentRef>,
3014 feedback_id: FeedbackId,
3016 }
3017
3018 let schema = schemars::schema_for!(Container);
3019 let value = serde_json::to_value(&schema).unwrap();
3020
3021 assert!(
3023 value.get("$defs").is_none(),
3024 "no $defs should be emitted for ID-only container; got schema: {value}"
3025 );
3026
3027 let post_id = &value["properties"]["post_id"];
3029 assert!(
3030 post_id.get("$ref").is_none(),
3031 "post_id must not be a $ref; got: {post_id}"
3032 );
3033 assert_eq!(post_id["type"], "string");
3034 assert_eq!(post_id["format"], "uuid");
3035
3036 let agent_id = &value["properties"]["agent_id"];
3041 assert!(
3042 agent_id.get("$ref").is_none(),
3043 "agent_id must not be a $ref; got: {agent_id}"
3044 );
3045 let agent_id_str = agent_id.to_string();
3046 assert!(
3047 !agent_id_str.contains("$ref"),
3048 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
3049 );
3050 assert!(
3051 agent_id_str.contains("\"format\":\"uuid\""),
3052 "agent_id should still carry format=uuid; got: {agent_id}"
3053 );
3054
3055 for field in
3060 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
3061 {
3062 let f = &value["properties"][field];
3063 assert!(
3064 !f.to_string().contains("$ref"),
3065 "{field} must contain no $ref anywhere; got: {f}"
3066 );
3067 }
3068 assert_eq!(value["properties"]["gov_id"]["type"], "string");
3069 assert_eq!(
3070 value["properties"]["gov_id"]["pattern"],
3071 GOVERNANCE_LOG_ID_PATTERN
3072 );
3073 assert!(
3074 value["properties"]["maybe_gov_id"]
3075 .to_string()
3076 .contains("GOV|APP"),
3077 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
3078 value["properties"]["maybe_gov_id"]
3079 );
3080 assert_eq!(value["properties"]["content_ref"]["type"], "string");
3081 }
3082
3083 #[test]
3084 fn governance_log_id_accepts_only_citation_shapes() {
3085 for (good, prefix) in [
3086 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
3087 ("APP-2026-0003", GovernanceLogPrefix::App),
3088 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
3089 ("KEY-2026-0001", GovernanceLogPrefix::Key),
3090 ("REC-2026-0001", GovernanceLogPrefix::Rec),
3091 ("OBL-2026-0001", GovernanceLogPrefix::Obl),
3092 ("DATA-2026-0001", GovernanceLogPrefix::Data),
3093 ("TEST-2026-0001", GovernanceLogPrefix::Test),
3094 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
3095 ] {
3096 let id = good.parse::<GovernanceLogId>().unwrap();
3097 assert_eq!(id.as_str(), good, "{good} should parse");
3098 assert_eq!(id.prefix(), prefix);
3099 let letters = good.split('-').next().unwrap();
3100 assert_eq!(id.prefix().as_str(), letters);
3101 assert_eq!(id.prefix().to_string().parse(), Ok(prefix));
3102 }
3103 let newer: GovernanceLogId =
3105 serde_json::from_value(serde_json::json!("ZZZ-2026-0001")).unwrap();
3106 assert_eq!(newer.prefix(), GovernanceLogPrefix::Unknown);
3107 assert_eq!(newer.as_str(), "ZZZ-2026-0001");
3108 assert!("ZZZ-2026-0001".parse::<GovernanceLogId>().is_err());
3109 assert!(!GovernanceLogId::is_citation_shaped("ZZZ-2026-0001"));
3110 for junk in [
3111 "zzz-2026-0001",
3112 "ZZZZZ-2026-0001",
3113 "ZZ-2026-0001",
3114 "ZZZ-2026-1",
3115 ] {
3116 assert!(
3117 serde_json::from_value::<GovernanceLogId>(serde_json::json!(
3118 junk
3119 ))
3120 .is_err(),
3121 "{junk}"
3122 );
3123 }
3124 assert_eq!(
3125 GovernanceLogId::from_parts(GovernanceLogPrefix::Unknown, 2026, 1),
3126 None
3127 );
3128 for prefix in GovernanceLogPrefix::ALL {
3131 let want = if matches!(
3132 prefix,
3133 GovernanceLogPrefix::Data | GovernanceLogPrefix::Test
3134 ) {
3135 4
3136 } else {
3137 3
3138 };
3139 assert_eq!(prefix.as_str().len(), want, "{prefix}");
3140 }
3141 let series = GovernanceLogPrefix::ALL.map(|p| p.as_str()).join("|");
3143 assert!(
3144 GOVERNANCE_LOG_ID_PATTERN.contains(&format!("({series})")),
3145 "{GOVERNANCE_LOG_ID_PATTERN}"
3146 );
3147 assert!(
3148 CONTENT_REF_PATTERN.contains(&format!("({series})")),
3149 "{CONTENT_REF_PATTERN}"
3150 );
3151 assert_eq!(
3153 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
3154 "GOV-2026-0006"
3155 );
3156 assert_eq!(
3157 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
3158 "GOV-2026-0006"
3159 );
3160 for bad in [
3161 "",
3162 "GOV-26-0006",
3163 "MOD-2026-0006",
3164 "GOV-2026-0006-1",
3165 "GOV-202X-0006",
3166 "DAT-2026-0001",
3167 "DATAS-2026-0001",
3168 "DATA-2026-00001",
3169 "DATA-2026",
3170 "TES-2026-0001",
3171 "TESTS-2026-0001",
3172 "TEST-2026-00001",
3173 "3f1a0000-0000-0000-0000-000000000000",
3174 ] {
3175 assert!(
3176 bad.parse::<GovernanceLogId>().is_err(),
3177 "{bad:?} should not parse as a GovernanceLogId"
3178 );
3179 }
3180 }
3181
3182 #[test]
3186 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
3187 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3188 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
3189 let back: GovernanceLogId =
3190 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
3191 assert_eq!(back, id);
3192 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
3194 }
3195
3196 #[test]
3199 fn content_ref_round_trips_as_a_bare_string() {
3200 let uuid = Uuid::new_v4();
3201 let content = ContentRef::from(ContentId::from(uuid));
3202 assert_eq!(
3203 serde_json::to_value(&content).unwrap(),
3204 serde_json::json!(uuid.to_string())
3205 );
3206 assert_eq!(
3207 serde_json::from_value::<ContentRef>(serde_json::json!(
3208 uuid.to_string()
3209 ))
3210 .unwrap(),
3211 content
3212 );
3213
3214 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
3215 assert_eq!(
3216 serde_json::to_value(&gov).unwrap(),
3217 serde_json::json!("APP-2026-0003")
3218 );
3219 assert_eq!(
3220 serde_json::from_value::<ContentRef>(serde_json::json!(
3221 "APP-2026-0003"
3222 ))
3223 .unwrap(),
3224 gov
3225 );
3226
3227 assert!(gov.is_governance());
3228 assert!(!content.is_governance());
3229 assert_eq!(gov.kind_str(), "governance");
3230 assert_eq!(content.kind_str(), "content");
3231 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
3232 assert!(content.as_governance().is_none());
3233
3234 assert!("not-an-id".parse::<ContentRef>().is_err());
3236 assert!(
3237 serde_json::from_value::<ContentRef>(serde_json::json!(
3238 "not-an-id"
3239 ))
3240 .is_err()
3241 );
3242 }
3243
3244 #[test]
3246 fn every_readable_id_narrows_to_a_content_ref() {
3247 let uuid = Uuid::new_v4();
3248 for (label, got) in [
3249 ("PostId", ContentRef::from(PostId::from(uuid))),
3250 ("CommentId", ContentRef::from(CommentId::from(uuid))),
3251 ("ContentId", ContentRef::from(ContentId::from(uuid))),
3252 ] {
3253 assert_eq!(
3254 got,
3255 ContentRef::Content(ContentId::from(uuid)),
3256 "{label} -> ContentRef lost the uuid"
3257 );
3258 }
3259 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3260 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
3261 }
3262
3263 #[test]
3266 fn string_shaped_ids_round_trip_through_display() {
3267 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3268 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
3269
3270 let r = ContentRef::Governance(gov);
3271 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3272
3273 let r = ContentRef::Content(ContentId::new());
3274 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3275 }
3276
3277 #[test]
3278 fn post_or_comment_post_variant() {
3279 let inner = PostId::new();
3280 let tagged = PostOrCommentId::Post(inner);
3281 assert!(tagged.is_post());
3282 assert!(!tagged.is_comment());
3283 assert_eq!(tagged.as_post(), Some(inner));
3284 assert_eq!(tagged.as_comment(), None);
3285 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3286 assert_eq!(tagged.kind_str(), "post");
3287 }
3288
3289 #[test]
3290 fn post_or_comment_comment_variant() {
3291 let inner = CommentId::new();
3292 let tagged = PostOrCommentId::Comment(inner);
3293 assert!(tagged.is_comment());
3294 assert!(!tagged.is_post());
3295 assert_eq!(tagged.as_comment(), Some(inner));
3296 assert_eq!(tagged.as_post(), None);
3297 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3298 assert_eq!(tagged.kind_str(), "comment");
3299 }
3300
3301 #[test]
3302 fn post_or_comment_from_conversions() {
3303 let post = PostId::new();
3304 let comment = CommentId::new();
3305 let via_post: PostOrCommentId = post.into();
3306 let via_comment: PostOrCommentId = comment.into();
3307 assert_eq!(via_post, PostOrCommentId::Post(post));
3308 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
3309 }
3310
3311 #[test]
3312 fn post_or_comment_display_is_kind_colon_uuid() {
3313 let post = PostId::new();
3314 let tagged = PostOrCommentId::Post(post);
3315 let rendered = tagged.to_string();
3316 assert!(rendered.starts_with("post:"));
3317 assert!(rendered.contains(&post.to_string()));
3318 }
3319
3320 const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
3323
3324 #[test]
3327 fn a_bad_id_fails_in_plain_words() {
3328 let bad = "7ad26ccd-922f-484a-a37c-51777344a";
3329 let want = format!(
3330 "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
3331 );
3332 assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
3333 let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
3334 .unwrap_err()
3335 .to_string();
3336 assert!(err.starts_with(&want), "{err}");
3337 assert!(!err.contains("group"), "{err}");
3338 let err = serde_json::from_str::<AgentId>("5")
3339 .unwrap_err()
3340 .to_string();
3341 assert!(err.contains("expected AgentId as a UUID string"), "{err}");
3342 let long = "x".repeat(500);
3344 let msg = long.parse::<CommentId>().unwrap_err().to_string();
3345 assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
3346 }
3347
3348 #[test]
3350 fn ids_still_round_trip() {
3351 let id: PostId = FULL.parse().unwrap();
3352 let json = serde_json::to_string(&id).unwrap();
3353 assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
3354 let map: std::collections::HashMap<PostId, i64> =
3355 [(id, 3)].into_iter().collect();
3356 let json = serde_json::to_string(&map).unwrap();
3357 let back: std::collections::HashMap<PostId, i64> =
3358 serde_json::from_str(&json).unwrap();
3359 assert_eq!(back, map);
3360 }
3361
3362 fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
3363 s.parse()
3364 }
3365
3366 #[test]
3367 fn full_and_short_targets_parse() {
3368 let full = target(FULL).unwrap();
3369 assert_eq!(full.full().unwrap().to_string(), FULL);
3370 assert_eq!(full.to_string(), FULL);
3371 let short = target(" 7AD26CCD ").unwrap();
3372 assert!(short.full().is_none());
3373 assert_eq!(short.to_string(), "7ad26ccd");
3374 for variant in [
3376 FULL.replace('-', ""),
3377 FULL.to_uppercase(),
3378 FULL.replace('-', "\u{2011}"),
3379 ] {
3380 assert_eq!(target(&variant).unwrap(), full, "{variant}");
3381 }
3382 }
3383
3384 #[test]
3385 fn a_truncated_uuid_is_an_error_not_its_prefix() {
3386 let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
3387 let msg = err.in_field("reply_to").to_string();
3388 assert!(
3389 msg.starts_with(
3390 "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
3391 ),
3392 "{msg}"
3393 );
3394 for internal in [
3395 "group",
3396 "UUID parsing",
3397 "invalid length",
3398 "invalid character",
3399 ] {
3400 assert!(!msg.contains(internal), "{internal} leaked: {msg}");
3401 }
3402 assert!(target("7ad26cc").is_err());
3403 assert!(target("7ad26ccd9").is_err());
3404 }
3405
3406 #[test]
3407 fn trailing_text_names_the_id_it_starts_with() {
3408 let msg = target(&format!("{FULL} (the reply above)"))
3409 .unwrap_err()
3410 .to_string();
3411 assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
3412 }
3413
3414 #[derive(Debug, serde::Deserialize)]
3415 struct Probe {
3416 #[serde(deserialize_with = "content_target::reply_to")]
3417 #[allow(dead_code)]
3418 reply_to: ContentTarget,
3419 }
3420
3421 #[test]
3422 fn target_errors_name_the_field_for_strings_and_other_types() {
3423 let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
3424 .unwrap_err();
3425 assert!(
3426 err.to_string()
3427 .starts_with("`reply_to` must be a post or comment id"),
3428 "{err}"
3429 );
3430 let err =
3431 serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
3432 assert!(
3433 err.to_string()
3434 .contains("expected `reply_to` to be a post or comment id"),
3435 "{err}"
3436 );
3437 }
3438
3439 #[cfg(feature = "schemars")]
3440 #[test]
3441 fn target_schema_is_inline_and_admits_both_forms() {
3442 let schema = schemars::schema_for!(ContentTarget);
3443 let v = schema.as_value();
3444 assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
3445 assert!(v.get("format").is_none(), "a short id is not format: uuid");
3446 assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
3447 }
3448}