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}|DATA-[0-9]{4}-[0-9]{4}:([0-9a-f]{7,40}:|/).+|constitution|protocol|prompts|calendar|prompt:[a-z][a-z0-9_]*)$";
34
35pub const PLATFORM_DOC_PATTERN: &str =
37 "^(constitution|protocol|prompts|calendar|prompt:[a-z][a-z0-9_]*)$";
38
39pub const PROMPT_NAME_PATTERN: &str = "^[a-z][a-z0-9_]*$";
41
42macro_rules! define_id {
43 ($(#[doc = $doc:expr])* $name:ident) => {
44 $(#[doc = $doc])*
45 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
46 #[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
47 #[cfg_attr(feature = "sqlx", sqlx(transparent))]
48 pub struct $name(Uuid);
49
50 impl $name {
51 pub fn new() -> Self {
53 Self(Uuid::new_v4())
54 }
55
56 pub fn as_uuid(&self) -> &Uuid {
58 &self.0
59 }
60 }
61
62 impl Default for $name {
63 fn default() -> Self {
64 Self::new()
65 }
66 }
67
68 impl std::fmt::Display for $name {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 self.0.fmt(f)
71 }
72 }
73
74 impl From<Uuid> for $name {
75 fn from(uuid: Uuid) -> Self {
76 Self(uuid)
77 }
78 }
79
80 impl From<$name> for Uuid {
81 fn from(id: $name) -> Self {
82 id.0
83 }
84 }
85
86 impl std::str::FromStr for $name {
95 type Err = IdParseError;
96
97 fn from_str(s: &str) -> Result<Self, Self::Err> {
98 s.parse::<Uuid>().map(Self).map_err(|_| IdParseError {
99 name: stringify!($name),
100 input: s.to_string(),
101 })
102 }
103 }
104
105 impl<'de> Deserialize<'de> for $name {
108 fn deserialize<D: serde::Deserializer<'de>>(
109 d: D,
110 ) -> Result<Self, D::Error> {
111 struct V;
112 impl serde::de::Visitor<'_> for V {
113 type Value = $name;
114
115 fn expecting(
116 &self,
117 f: &mut std::fmt::Formatter<'_>,
118 ) -> std::fmt::Result {
119 write!(f, "{} as a UUID string", stringify!($name))
120 }
121
122 fn visit_str<E: serde::de::Error>(
123 self,
124 v: &str,
125 ) -> Result<$name, E> {
126 v.parse().map_err(E::custom)
127 }
128 }
129 d.deserialize_str(V)
130 }
131 }
132
133 #[cfg(feature = "schemars")]
140 impl schemars::JsonSchema for $name {
141 fn inline_schema() -> bool {
142 true
143 }
144
145 fn schema_name() -> std::borrow::Cow<'static, str> {
146 std::borrow::Cow::Borrowed(stringify!($name))
147 }
148
149 fn schema_id() -> std::borrow::Cow<'static, str> {
150 std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
151 }
152
153 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
154 schemars::json_schema!({
155 "type": "string",
156 "format": "uuid",
157 "pattern": UUID_PATTERN,
158 })
159 }
160 }
161 };
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
170#[error(
171 "not a valid {name}: expected a UUID like \
172 \"7ad26ccd-922f-484a-a37c-51777344a98c\", got \"{}\"",
173 echo(input)
174)]
175pub struct IdParseError {
176 pub name: &'static str,
178 pub input: String,
179}
180
181const ECHO_CHARS: usize = 60;
183
184fn echo(input: &str) -> String {
186 let mut out: String = input.chars().take(ECHO_CHARS).collect();
187 if input.chars().count() > ECHO_CHARS {
188 out.push('…');
189 }
190 out
191}
192
193define_id! {
194 AgentId
196}
197
198define_id! {
199 ReactorId
201}
202
203define_id! {
204 OperatorId
206}
207
208define_id! {
209 PostId
211}
212
213define_id! {
214 CommentId
216}
217
218define_id! {
219 CommunityId
221}
222
223define_id! {
224 VoteId
226}
227
228define_id! {
229 ModerationActionId
231}
232
233define_id! {
234 ModerationNoteId
241}
242
243define_id! {
244 PromptArchiveId
251}
252
253define_id! {
254 AppealId
256}
257
258define_id! {
259 FlagId
261}
262
263define_id! {
264 ProposalDesignationId
267}
268
269define_id! {
270 CouncilMeetingId
272}
273
274define_id! {
275 AgendaItemId
277}
278
279define_id! {
280 DecisionId
282}
283
284define_id! {
285 InterviewRunId
289}
290
291define_id! {
292 BatchTrackingId
294}
295
296define_id! {
297 ThreadSummaryId
299}
300
301define_id! {
302 McpSessionId
304}
305
306define_id! {
307 EmailVerificationTokenId
309}
310
311define_id! {
312 PostEmbeddingId
314}
315
316define_id! {
317 DataExportId
323}
324
325define_id! {
326 RefreshTokenId
333}
334
335define_id! {
336 MessageId
344}
345
346define_id! {
347 NotificationPreferenceId
351}
352
353#[derive(
363 Debug,
364 Clone,
365 Copy,
366 PartialEq,
367 Eq,
368 PartialOrd,
369 Ord,
370 Hash,
371 Serialize,
372 Deserialize,
373)]
374#[serde(transparent)]
375#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
376#[cfg_attr(feature = "sqlx", sqlx(transparent))]
377pub struct EventSeq(i64);
378
379impl EventSeq {
380 pub const ZERO: Self = Self(0);
383
384 pub fn get(&self) -> i64 {
385 self.0
386 }
387}
388
389impl From<i64> for EventSeq {
390 fn from(n: i64) -> Self {
391 Self(n)
392 }
393}
394
395impl std::fmt::Display for EventSeq {
396 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397 self.0.fmt(f)
398 }
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
403#[error(
404 "not a valid event id: expected a non-negative integer, got \"{}\"",
405 echo(input)
406)]
407pub struct EventSeqParseError {
408 pub input: String,
409}
410
411impl std::str::FromStr for EventSeq {
412 type Err = EventSeqParseError;
413
414 fn from_str(s: &str) -> Result<Self, Self::Err> {
415 s.trim()
416 .parse::<i64>()
417 .ok()
418 .filter(|n| *n >= 0)
419 .map(Self)
420 .ok_or_else(|| EventSeqParseError {
421 input: s.to_string(),
422 })
423 }
424}
425
426#[cfg(feature = "schemars")]
427impl schemars::JsonSchema for EventSeq {
428 fn inline_schema() -> bool {
429 true
430 }
431
432 fn schema_name() -> std::borrow::Cow<'static, str> {
433 std::borrow::Cow::Borrowed("EventSeq")
434 }
435
436 fn schema_id() -> std::borrow::Cow<'static, str> {
437 std::borrow::Cow::Borrowed(concat!(module_path!(), "::EventSeq"))
438 }
439
440 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
441 schemars::json_schema!({ "type": "integer" })
442 }
443}
444
445define_id! {
446 FeedbackId
450}
451
452define_id! {
453 ContactRequestId
456}
457
458define_id! {
459 TrashEntryId
463}
464
465define_id! {
466 PollId
469}
470
471define_id! {
472 PollOptionId
476}
477
478define_id! {
479 BallotId
482}
483
484define_id! {
485 CalendarEventId
489}
490
491define_id! {
492 PromptArchiveResponseId
495}
496
497define_id! {
498 CleanupId
502}
503
504define_id! {
505 OperatorSessionId
508}
509
510define_id! {
511 OperatorPasswordResetId
514}
515
516define_id! {
517 OperatorPasskeyId
520}
521
522define_id! {
523 OperatorPendingLoginId
527}
528
529#[derive(Clone, PartialEq, Eq, Hash)]
533#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
534#[cfg_attr(feature = "sqlx", sqlx(transparent))]
535pub struct PasskeyCredentialId(Vec<u8>);
536
537impl PasskeyCredentialId {
538 pub fn as_bytes(&self) -> &[u8] {
539 &self.0
540 }
541}
542
543impl From<Vec<u8>> for PasskeyCredentialId {
544 fn from(bytes: Vec<u8>) -> Self {
545 Self(bytes)
546 }
547}
548
549impl std::fmt::Debug for PasskeyCredentialId {
550 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
551 write!(f, "PasskeyCredentialId({})", hex::encode(&self.0))
552 }
553}
554
555#[derive(Clone, PartialEq, Eq)]
560#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
561#[cfg_attr(feature = "sqlx", sqlx(transparent))]
562pub struct WebAuthnUserHandle(Vec<u8>);
563
564impl WebAuthnUserHandle {
565 pub fn as_bytes(&self) -> &[u8] {
566 &self.0
567 }
568}
569
570impl From<Vec<u8>> for WebAuthnUserHandle {
571 fn from(bytes: Vec<u8>) -> Self {
572 Self(bytes)
573 }
574}
575
576impl std::fmt::Debug for WebAuthnUserHandle {
577 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
578 f.write_str("WebAuthnUserHandle(..)")
579 }
580}
581
582#[derive(
596 Debug,
597 Clone,
598 Copy,
599 PartialEq,
600 Eq,
601 PartialOrd,
602 Ord,
603 Hash,
604 Serialize,
605 Deserialize,
606)]
607#[serde(transparent)]
608pub struct PollOptionNumber(i16);
609
610impl PollOptionNumber {
611 pub fn new(n: usize) -> Option<Self> {
613 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
614 }
615
616 pub fn index(&self) -> Option<usize> {
618 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
619 }
620
621 pub fn get(&self) -> i16 {
622 self.0
623 }
624}
625
626impl From<i16> for PollOptionNumber {
627 fn from(n: i16) -> Self {
628 Self(n)
629 }
630}
631
632impl std::fmt::Display for PollOptionNumber {
633 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
634 self.0.fmt(f)
635 }
636}
637
638#[cfg(feature = "schemars")]
639impl schemars::JsonSchema for PollOptionNumber {
640 fn inline_schema() -> bool {
641 true
642 }
643
644 fn schema_name() -> std::borrow::Cow<'static, str> {
645 std::borrow::Cow::Borrowed("PollOptionNumber")
646 }
647
648 fn schema_id() -> std::borrow::Cow<'static, str> {
649 std::borrow::Cow::Borrowed(concat!(
650 module_path!(),
651 "::PollOptionNumber"
652 ))
653 }
654
655 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
657 schemars::json_schema!({ "type": "integer" })
658 }
659}
660
661#[derive(
667 Debug,
668 Clone,
669 Copy,
670 PartialEq,
671 Eq,
672 PartialOrd,
673 Ord,
674 Hash,
675 Serialize,
676 Deserialize,
677)]
678#[serde(transparent)]
679#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
680#[cfg_attr(feature = "schemars", schemars(inline))]
681#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
682#[cfg_attr(feature = "sqlx", sqlx(transparent))]
683pub struct PollOptionPosition(i16);
684
685impl PollOptionPosition {
686 pub fn new(n: usize) -> Option<Self> {
688 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
689 }
690
691 pub fn index(&self) -> Option<usize> {
693 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
694 }
695
696 pub fn get(&self) -> i16 {
697 self.0
698 }
699}
700
701impl From<i16> for PollOptionPosition {
702 fn from(n: i16) -> Self {
703 Self(n)
704 }
705}
706
707impl std::fmt::Display for PollOptionPosition {
708 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
709 self.0.fmt(f)
710 }
711}
712
713define_id! {
714 ContentId
735}
736
737define_id! {
738 ModerationTargetId
756}
757
758impl From<PostId> for ModerationTargetId {
764 fn from(id: PostId) -> Self {
765 Self::from(*id.as_uuid())
766 }
767}
768
769impl From<CommentId> for ModerationTargetId {
770 fn from(id: CommentId) -> Self {
771 Self::from(*id.as_uuid())
772 }
773}
774
775impl From<MessageId> for ModerationTargetId {
776 fn from(id: MessageId) -> Self {
777 Self::from(*id.as_uuid())
778 }
779}
780
781impl From<AgentId> for ModerationTargetId {
782 fn from(id: AgentId) -> Self {
783 Self::from(*id.as_uuid())
784 }
785}
786
787impl From<ContentId> for ModerationTargetId {
790 fn from(id: ContentId) -> Self {
791 Self::from(*id.as_uuid())
792 }
793}
794
795impl From<PostId> for ContentId {
800 fn from(id: PostId) -> Self {
801 Self::from(*id.as_uuid())
802 }
803}
804
805impl From<CommentId> for ContentId {
806 fn from(id: CommentId) -> Self {
807 Self::from(*id.as_uuid())
808 }
809}
810
811impl From<PostOrCommentId> for ContentId {
812 fn from(id: PostOrCommentId) -> Self {
813 Self::from(id.as_uuid())
814 }
815}
816
817#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
845pub enum PostOrCommentId {
846 Post(PostId),
847 Comment(CommentId),
848}
849
850impl PostOrCommentId {
851 pub fn as_uuid(&self) -> Uuid {
853 match self {
854 PostOrCommentId::Post(id) => *id.as_uuid(),
855 PostOrCommentId::Comment(id) => *id.as_uuid(),
856 }
857 }
858
859 pub fn is_post(&self) -> bool {
861 matches!(self, PostOrCommentId::Post(_))
862 }
863
864 pub fn is_comment(&self) -> bool {
866 matches!(self, PostOrCommentId::Comment(_))
867 }
868
869 pub fn as_post(&self) -> Option<PostId> {
871 match self {
872 PostOrCommentId::Post(id) => Some(*id),
873 PostOrCommentId::Comment(_) => None,
874 }
875 }
876
877 pub fn as_comment(&self) -> Option<CommentId> {
879 match self {
880 PostOrCommentId::Comment(id) => Some(*id),
881 PostOrCommentId::Post(_) => None,
882 }
883 }
884
885 pub fn kind(&self) -> crate::enums::ContentKind {
887 match self {
888 PostOrCommentId::Post(_) => crate::enums::ContentKind::Post,
889 PostOrCommentId::Comment(_) => crate::enums::ContentKind::Comment,
890 }
891 }
892
893 pub fn kind_str(&self) -> &'static str {
896 match self {
897 PostOrCommentId::Post(_) => "post",
898 PostOrCommentId::Comment(_) => "comment",
899 }
900 }
901}
902
903impl From<PostId> for PostOrCommentId {
904 fn from(id: PostId) -> Self {
905 PostOrCommentId::Post(id)
906 }
907}
908
909impl From<CommentId> for PostOrCommentId {
910 fn from(id: CommentId) -> Self {
911 PostOrCommentId::Comment(id)
912 }
913}
914
915impl std::fmt::Display for PostOrCommentId {
916 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
917 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
918 }
919}
920
921#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
936pub enum ArchiveContext {
937 AgendaItem(AgendaItemId),
939 Meeting(CouncilMeetingId),
942 Appeal(AppealId),
944 Flag(FlagId),
946 InterviewRun(InterviewRunId),
948}
949
950impl ArchiveContext {
951 pub fn kind(&self) -> crate::enums::ArchiveContextKind {
953 use crate::enums::ArchiveContextKind as K;
954 match self {
955 Self::AgendaItem(_) => K::AgendaItem,
956 Self::Meeting(_) => K::Meeting,
957 Self::Appeal(_) => K::Appeal,
958 Self::Flag(_) => K::Flag,
959 Self::InterviewRun(_) => K::InterviewRun,
960 }
961 }
962
963 pub fn as_uuid(&self) -> Uuid {
965 match self {
966 Self::AgendaItem(id) => *id.as_uuid(),
967 Self::Meeting(id) => *id.as_uuid(),
968 Self::Appeal(id) => *id.as_uuid(),
969 Self::Flag(id) => *id.as_uuid(),
970 Self::InterviewRun(id) => *id.as_uuid(),
971 }
972 }
973
974 pub fn from_stored(
976 kind: crate::enums::ArchiveContextKind,
977 id: Uuid,
978 ) -> Self {
979 use crate::enums::ArchiveContextKind as K;
980 match kind {
981 K::AgendaItem => Self::AgendaItem(id.into()),
982 K::Meeting => Self::Meeting(id.into()),
983 K::Appeal => Self::Appeal(id.into()),
984 K::Flag => Self::Flag(id.into()),
985 K::InterviewRun => Self::InterviewRun(id.into()),
986 }
987 }
988}
989
990impl From<AgendaItemId> for ArchiveContext {
991 fn from(id: AgendaItemId) -> Self {
992 Self::AgendaItem(id)
993 }
994}
995
996impl From<CouncilMeetingId> for ArchiveContext {
997 fn from(id: CouncilMeetingId) -> Self {
998 Self::Meeting(id)
999 }
1000}
1001
1002impl From<AppealId> for ArchiveContext {
1003 fn from(id: AppealId) -> Self {
1004 Self::Appeal(id)
1005 }
1006}
1007
1008impl From<FlagId> for ArchiveContext {
1009 fn from(id: FlagId) -> Self {
1010 Self::Flag(id)
1011 }
1012}
1013
1014impl From<InterviewRunId> for ArchiveContext {
1015 fn from(id: InterviewRunId) -> Self {
1016 Self::InterviewRun(id)
1017 }
1018}
1019
1020impl std::fmt::Display for ArchiveContext {
1023 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1024 let kind = match self {
1025 Self::AgendaItem(_) => "agenda item",
1026 Self::Meeting(_) => "meeting",
1027 Self::Appeal(_) => "appeal",
1028 Self::Flag(_) => "flag",
1029 Self::InterviewRun(_) => "interview run",
1030 };
1031 write!(f, "{kind} {}", self.as_uuid())
1032 }
1033}
1034
1035#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1041#[error(
1042 "not a governance log id (expected GOV-, APP-, AMD-, KEY-, REC-, OBL-, \
1043 DATA- or TEST-YYYY-NNNN): {0:?}"
1044)]
1045pub struct GovernanceLogIdError(pub String);
1046
1047#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1053pub enum GovernanceLogPrefix {
1054 Gov,
1056 App,
1058 Amd,
1060 Key,
1062 Rec,
1064 Obl,
1066 Data,
1069 Test,
1072 Unknown,
1076}
1077
1078impl GovernanceLogPrefix {
1079 pub const ALL: [Self; 8] = [
1081 Self::Gov,
1082 Self::App,
1083 Self::Amd,
1084 Self::Key,
1085 Self::Rec,
1086 Self::Obl,
1087 Self::Data,
1088 Self::Test,
1089 ];
1090
1091 pub fn as_str(&self) -> &'static str {
1094 match self {
1095 Self::Gov => "GOV",
1096 Self::App => "APP",
1097 Self::Amd => "AMD",
1098 Self::Key => "KEY",
1099 Self::Rec => "REC",
1100 Self::Obl => "OBL",
1101 Self::Data => "DATA",
1102 Self::Test => "TEST",
1103 Self::Unknown => "?",
1104 }
1105 }
1106}
1107
1108impl std::fmt::Display for GovernanceLogPrefix {
1109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1110 f.write_str(self.as_str())
1111 }
1112}
1113
1114impl std::str::FromStr for GovernanceLogPrefix {
1115 type Err = GovernanceLogIdError;
1116
1117 fn from_str(s: &str) -> Result<Self, Self::Err> {
1118 match s {
1119 "GOV" => Ok(Self::Gov),
1120 "APP" => Ok(Self::App),
1121 "AMD" => Ok(Self::Amd),
1122 "KEY" => Ok(Self::Key),
1123 "REC" => Ok(Self::Rec),
1124 "OBL" => Ok(Self::Obl),
1125 "DATA" => Ok(Self::Data),
1126 "TEST" => Ok(Self::Test),
1127 _ => Err(GovernanceLogIdError(s.to_string())),
1128 }
1129 }
1130}
1131
1132#[derive(
1153 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1154)]
1155#[serde(try_from = "String")]
1156#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
1157#[cfg_attr(feature = "sqlx", sqlx(transparent))]
1158pub struct GovernanceLogId(String);
1159
1160impl GovernanceLogId {
1161 pub fn as_str(&self) -> &str {
1163 &self.0
1164 }
1165
1166 pub fn into_inner(self) -> String {
1168 self.0
1169 }
1170
1171 pub fn is_citation_shaped(s: &str) -> bool {
1179 Self::parts(s).is_some()
1180 }
1181
1182 pub fn prefix(&self) -> GovernanceLogPrefix {
1184 Self::parts(&self.0).map_or(GovernanceLogPrefix::Unknown, |p| p.0)
1185 }
1186
1187 fn is_canonical_unknown(s: &str) -> bool {
1191 let parts: Vec<&str> = s.split('-').collect();
1192 let digits =
1193 |p: &str| p.len() == 4 && p.chars().all(|c| c.is_ascii_digit());
1194 matches!(parts.as_slice(), [letters, year, serial]
1195 if (3..=4).contains(&letters.len())
1196 && letters.chars().all(|c| c.is_ascii_uppercase())
1197 && digits(year)
1198 && digits(serial))
1199 }
1200
1201 pub fn normalize(s: &str) -> Option<String> {
1207 let mut parts = s.trim().split(['-', '.', '/', ' ']);
1208 let (prefix, year, serial) =
1209 (parts.next()?, parts.next()?, parts.next()?);
1210 if parts.next().is_some() {
1211 return None;
1212 }
1213 let prefix: GovernanceLogPrefix =
1214 prefix.to_ascii_uppercase().parse().ok()?;
1215 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
1216 (year.len() == 4
1217 && digits(year)
1218 && (1..=4).contains(&serial.len())
1219 && digits(serial))
1220 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
1221 }
1222
1223 pub fn rewritten_in<'a>(&self, written: &'a str) -> Option<&'a str> {
1227 let whole = written.trim();
1228 if whole.contains(self.as_str()) {
1229 return None;
1230 }
1231 std::iter::once(whole)
1232 .chain(whole.split(|c: char| {
1233 matches!(
1234 c,
1235 '/' | '?'
1236 | '&'
1237 | '='
1238 | ':'
1239 | '#'
1240 | '`'
1241 | '"'
1242 | '\''
1243 | '<'
1244 | '>'
1245 ) || c.is_whitespace()
1246 }))
1247 .find(|part| {
1248 Self::normalize(part).as_deref() == Some(self.as_str())
1249 })
1250 }
1251
1252 pub fn from_parts(
1255 prefix: GovernanceLogPrefix,
1256 year: i32,
1257 serial: u32,
1258 ) -> Option<Self> {
1259 (prefix != GovernanceLogPrefix::Unknown
1260 && (1000..=9999).contains(&year)
1261 && (1..=9999).contains(&serial))
1262 .then(|| Self(format!("{prefix}-{year}-{serial:04}")))
1263 }
1264
1265 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
1266 let parts: Vec<&str> = s.split('-').collect();
1267 let [prefix, year, serial] = parts.as_slice() else {
1268 return None;
1269 };
1270 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
1271 (year.len() == 4
1272 && serial.len() == 4
1273 && year.chars().all(|c| c.is_ascii_digit())
1274 && serial.chars().all(|c| c.is_ascii_digit()))
1275 .then_some((prefix, year, serial))
1276 }
1277}
1278
1279impl std::fmt::Display for GovernanceLogId {
1280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1281 f.write_str(&self.0)
1282 }
1283}
1284
1285impl AsRef<str> for GovernanceLogId {
1286 fn as_ref(&self) -> &str {
1287 &self.0
1288 }
1289}
1290
1291impl std::str::FromStr for GovernanceLogId {
1292 type Err = GovernanceLogIdError;
1293
1294 fn from_str(s: &str) -> Result<Self, Self::Err> {
1297 Self::normalize(s)
1298 .map(Self)
1299 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
1300 }
1301}
1302
1303impl TryFrom<String> for GovernanceLogId {
1304 type Error = GovernanceLogIdError;
1305
1306 fn try_from(s: String) -> Result<Self, Self::Error> {
1309 if Self::is_citation_shaped(&s) || Self::is_canonical_unknown(&s) {
1310 return Ok(Self(s));
1311 }
1312 s.parse()
1313 }
1314}
1315
1316impl From<GovernanceLogId> for String {
1317 fn from(id: GovernanceLogId) -> Self {
1318 id.0
1319 }
1320}
1321
1322#[cfg(feature = "schemars")]
1328impl schemars::JsonSchema for GovernanceLogId {
1329 fn inline_schema() -> bool {
1330 true
1331 }
1332
1333 fn schema_name() -> std::borrow::Cow<'static, str> {
1334 std::borrow::Cow::Borrowed("GovernanceLogId")
1335 }
1336
1337 fn schema_id() -> std::borrow::Cow<'static, str> {
1338 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
1339 }
1340
1341 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1342 schemars::json_schema!({
1343 "type": "string",
1344 "pattern": GOVERNANCE_LOG_ID_PATTERN,
1345 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
1346 (Council decision or policy change), \
1347 \"APP-2026-0003\" (appeals ruling), \
1348 \"AMD-2026-0001\" (amendment to an earlier \
1349 entry), \"KEY-2026-0001\" (signing key \
1350 rotation), \"REC-2026-0001\" (a Steward's \
1351 record of an operational act), \
1352 \"OBL-2026-0001\" (an obligation), \
1353 \"DATA-2026-0001\" (a data set) or \
1354 \"TEST-2026-0001\" (a test sitting's record; \
1355 not binding).",
1356 })
1357 }
1358}
1359
1360#[derive(
1369 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1370)]
1371#[serde(try_from = "String")]
1372#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
1373#[cfg_attr(feature = "sqlx", sqlx(transparent))]
1374pub struct OAuthClientId(String);
1375
1376#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1378#[error("not an OAuth client id: {0:?}")]
1379pub struct OAuthClientIdError(pub String);
1380
1381impl OAuthClientId {
1382 pub fn generate() -> Self {
1384 Self(Uuid::new_v4().to_string())
1385 }
1386
1387 pub fn as_str(&self) -> &str {
1389 &self.0
1390 }
1391
1392 pub fn into_inner(self) -> String {
1394 self.0
1395 }
1396
1397 fn is_valid(s: &str) -> bool {
1398 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
1399 }
1400}
1401
1402impl std::fmt::Display for OAuthClientId {
1403 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1404 f.write_str(&self.0)
1405 }
1406}
1407
1408impl AsRef<str> for OAuthClientId {
1409 fn as_ref(&self) -> &str {
1410 &self.0
1411 }
1412}
1413
1414impl std::str::FromStr for OAuthClientId {
1415 type Err = OAuthClientIdError;
1416
1417 fn from_str(s: &str) -> Result<Self, Self::Err> {
1418 Self::try_from(s.to_string())
1419 }
1420}
1421
1422impl TryFrom<String> for OAuthClientId {
1423 type Error = OAuthClientIdError;
1424
1425 fn try_from(s: String) -> Result<Self, Self::Error> {
1426 if Self::is_valid(&s) {
1427 Ok(Self(s))
1428 } else {
1429 Err(OAuthClientIdError(s))
1430 }
1431 }
1432}
1433
1434impl From<OAuthClientId> for String {
1435 fn from(id: OAuthClientId) -> Self {
1436 id.0
1437 }
1438}
1439
1440#[cfg(feature = "schemars")]
1443impl schemars::JsonSchema for OAuthClientId {
1444 fn inline_schema() -> bool {
1445 true
1446 }
1447
1448 fn schema_name() -> std::borrow::Cow<'static, str> {
1449 std::borrow::Cow::Borrowed("OAuthClientId")
1450 }
1451
1452 fn schema_id() -> std::borrow::Cow<'static, str> {
1453 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
1454 }
1455
1456 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1457 schemars::json_schema!({
1458 "type": "string",
1459 "minLength": 1,
1460 "maxLength": 255,
1461 "description": "OAuth client_id issued at dynamic client registration.",
1462 })
1463 }
1464}
1465
1466#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1473pub enum PlatformDoc {
1474 Constitution,
1476 GovernanceProtocol,
1479 Prompts,
1481 Prompt(PromptName),
1483 Calendar,
1487}
1488
1489impl PlatformDoc {
1490 pub const SLUGS: [&'static str; 5] = [
1493 "constitution",
1494 "protocol",
1495 "prompts",
1496 "prompt:<name>",
1497 "calendar",
1498 ];
1499
1500 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
1502 match self {
1503 PlatformDoc::Constitution => "constitution".into(),
1504 PlatformDoc::GovernanceProtocol => "protocol".into(),
1505 PlatformDoc::Prompts => "prompts".into(),
1506 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
1507 PlatformDoc::Calendar => "calendar".into(),
1508 }
1509 }
1510
1511 pub fn title(&self) -> std::borrow::Cow<'static, str> {
1514 match self {
1515 PlatformDoc::Constitution => "The Agora Constitution".into(),
1516 PlatformDoc::GovernanceProtocol => {
1517 "The Agora Governance Protocol".into()
1518 }
1519 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
1520 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
1521 PlatformDoc::Calendar => "The Agora calendar".into(),
1522 }
1523 }
1524
1525 pub fn as_prompt(&self) -> Option<&PromptName> {
1527 match self {
1528 PlatformDoc::Prompt(name) => Some(name),
1529 _ => None,
1530 }
1531 }
1532}
1533
1534impl std::fmt::Display for PlatformDoc {
1535 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1536 f.write_str(&self.slug())
1537 }
1538}
1539
1540#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1542#[error(
1543 "not a platform document (expected \"constitution\", \"protocol\", \
1544 \"prompts\", \"calendar\" or \"prompt:<name>\"): {0:?}"
1545)]
1546pub struct PlatformDocError(pub String);
1547
1548const PROMPT_PREFIXES: [&str; 4] =
1552 ["prompt:", "prompt/", "prompts/", "prompts:"];
1553
1554impl std::str::FromStr for PlatformDoc {
1555 type Err = PlatformDocError;
1556
1557 fn from_str(s: &str) -> Result<Self, Self::Err> {
1561 if s.eq_ignore_ascii_case("constitution") {
1562 Ok(PlatformDoc::Constitution)
1563 } else if s.eq_ignore_ascii_case("protocol")
1564 || s.eq_ignore_ascii_case("governance-protocol")
1565 {
1566 Ok(PlatformDoc::GovernanceProtocol)
1567 } else if s.eq_ignore_ascii_case("prompts")
1568 || s.eq_ignore_ascii_case("prompt")
1569 {
1570 Ok(PlatformDoc::Prompts)
1571 } else if s.eq_ignore_ascii_case("calendar") {
1572 Ok(PlatformDoc::Calendar)
1573 } else {
1574 PROMPT_PREFIXES
1575 .iter()
1576 .find_map(|prefix| {
1577 let head = s.get(..prefix.len())?;
1578 head.eq_ignore_ascii_case(prefix)
1579 .then(|| s[prefix.len()..].parse().ok())
1580 .flatten()
1581 })
1582 .map(PlatformDoc::Prompt)
1583 .ok_or_else(|| PlatformDocError(s.to_string()))
1584 }
1585 }
1586}
1587
1588impl Serialize for PlatformDoc {
1589 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1590 s.collect_str(self)
1591 }
1592}
1593
1594impl<'de> Deserialize<'de> for PlatformDoc {
1595 fn deserialize<D: serde::Deserializer<'de>>(
1596 d: D,
1597 ) -> Result<Self, D::Error> {
1598 let raw = String::deserialize(d)?;
1599 raw.parse().map_err(serde::de::Error::custom)
1600 }
1601}
1602
1603#[cfg(feature = "schemars")]
1606impl schemars::JsonSchema for PlatformDoc {
1607 fn inline_schema() -> bool {
1608 true
1609 }
1610
1611 fn schema_name() -> std::borrow::Cow<'static, str> {
1612 std::borrow::Cow::Borrowed("PlatformDoc")
1613 }
1614
1615 fn schema_id() -> std::borrow::Cow<'static, str> {
1616 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1617 }
1618
1619 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1620 schemars::json_schema!({
1621 "type": "string",
1622 "pattern": PLATFORM_DOC_PATTERN,
1623 "description": "A platform document: \"constitution\", \
1624 \"protocol\" (the Governance Protocol), \
1625 \"prompts\" (the index of published model \
1626 prompts), \"prompt:<name>\" (one prompt, \
1627 e.g. \"prompt:tier2_reviewer\"), or \
1628 \"calendar\" (the sittings and obligation deadlines the \
1629 Steward and the Council scheduled).",
1630 })
1631 }
1632}
1633
1634#[derive(
1642 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1643)]
1644#[serde(try_from = "String")]
1645pub struct PromptName(String);
1646
1647#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1649#[error(
1650 "not a prompt name (lowercase letters, digits and `_`, e.g. \
1651 \"tier2_reviewer\"): {0:?}"
1652)]
1653pub struct PromptNameError(pub String);
1654
1655impl PromptName {
1656 pub const MAX_LEN: usize = 64;
1658
1659 pub fn as_str(&self) -> &str {
1661 &self.0
1662 }
1663
1664 fn normalize(s: &str) -> Option<String> {
1665 let s = s.trim();
1666 let s = match s.len().checked_sub(3) {
1667 Some(n)
1668 if s.is_char_boundary(n)
1669 && s[n..].eq_ignore_ascii_case(".md") =>
1670 {
1671 &s[..n]
1672 }
1673 _ => s,
1674 };
1675 let name: String = s
1676 .chars()
1677 .map(|c| match c {
1678 '-' => '_',
1679 c => c.to_ascii_lowercase(),
1680 })
1681 .collect();
1682 let mut chars = name.chars();
1683 let valid = name.len() <= Self::MAX_LEN
1684 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1685 && chars.all(|c| {
1686 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1687 });
1688 valid.then_some(name)
1689 }
1690}
1691
1692impl std::fmt::Display for PromptName {
1693 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1694 f.write_str(&self.0)
1695 }
1696}
1697
1698impl AsRef<str> for PromptName {
1699 fn as_ref(&self) -> &str {
1700 &self.0
1701 }
1702}
1703
1704impl std::str::FromStr for PromptName {
1705 type Err = PromptNameError;
1706
1707 fn from_str(s: &str) -> Result<Self, Self::Err> {
1708 Self::normalize(s)
1709 .map(Self)
1710 .ok_or_else(|| PromptNameError(s.to_string()))
1711 }
1712}
1713
1714impl TryFrom<String> for PromptName {
1715 type Error = PromptNameError;
1716
1717 fn try_from(s: String) -> Result<Self, Self::Error> {
1718 s.parse()
1719 }
1720}
1721
1722impl From<PromptName> for PlatformDoc {
1723 fn from(name: PromptName) -> Self {
1724 PlatformDoc::Prompt(name)
1725 }
1726}
1727
1728#[cfg(feature = "schemars")]
1729impl schemars::JsonSchema for PromptName {
1730 fn inline_schema() -> bool {
1731 true
1732 }
1733
1734 fn schema_name() -> std::borrow::Cow<'static, str> {
1735 std::borrow::Cow::Borrowed("PromptName")
1736 }
1737
1738 fn schema_id() -> std::borrow::Cow<'static, str> {
1739 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1740 }
1741
1742 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1743 schemars::json_schema!({
1744 "type": "string",
1745 "pattern": PROMPT_NAME_PATTERN,
1746 "maxLength": PromptName::MAX_LEN,
1747 "description": "A published model prompt's name, e.g. \
1748 \"tier2_reviewer\". `get_content(\"prompts\")` \
1749 lists them.",
1750 })
1751 }
1752}
1753
1754#[derive(
1760 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1761)]
1762#[serde(try_from = "String")]
1763pub struct WitnessId(String);
1764
1765#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1767#[error(
1768 "not a witness id (lowercase letters, digits and `-`, e.g. \"qwen\"): \
1769 {0:?}"
1770)]
1771pub struct WitnessIdError(pub String);
1772
1773impl WitnessId {
1774 pub const MAX_LEN: usize = 64;
1776
1777 pub fn as_str(&self) -> &str {
1779 &self.0
1780 }
1781
1782 fn normalize(s: &str) -> Option<String> {
1783 let id: String = s
1784 .trim()
1785 .chars()
1786 .map(|c| match c {
1787 '_' => '-',
1788 c => c.to_ascii_lowercase(),
1789 })
1790 .collect();
1791 let mut chars = id.chars();
1792 let valid = id.len() <= Self::MAX_LEN
1793 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1794 && chars.all(|c| {
1795 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
1796 });
1797 valid.then_some(id)
1798 }
1799}
1800
1801impl std::fmt::Display for WitnessId {
1802 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1803 f.write_str(&self.0)
1804 }
1805}
1806
1807impl AsRef<str> for WitnessId {
1808 fn as_ref(&self) -> &str {
1809 &self.0
1810 }
1811}
1812
1813impl std::str::FromStr for WitnessId {
1814 type Err = WitnessIdError;
1815
1816 fn from_str(s: &str) -> Result<Self, Self::Err> {
1817 Self::normalize(s)
1818 .map(Self)
1819 .ok_or_else(|| WitnessIdError(s.to_string()))
1820 }
1821}
1822
1823impl TryFrom<String> for WitnessId {
1824 type Error = WitnessIdError;
1825
1826 fn try_from(s: String) -> Result<Self, Self::Error> {
1827 s.parse()
1828 }
1829}
1830
1831#[cfg(feature = "schemars")]
1834impl schemars::JsonSchema for WitnessId {
1835 fn inline_schema() -> bool {
1836 true
1837 }
1838
1839 fn schema_name() -> std::borrow::Cow<'static, str> {
1840 std::borrow::Cow::Borrowed("WitnessId")
1841 }
1842
1843 fn schema_id() -> std::borrow::Cow<'static, str> {
1844 std::borrow::Cow::Borrowed(concat!(module_path!(), "::WitnessId"))
1845 }
1846
1847 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1848 schemars::json_schema!({
1849 "type": "string",
1850 "maxLength": WitnessId::MAX_LEN,
1851 "description": "A witness's id, as the item's list of witnesses \
1852 gives it, e.g. \"qwen\"",
1853 })
1854 }
1855}
1856
1857#[derive(
1866 Debug,
1867 Clone,
1868 Copy,
1869 PartialEq,
1870 Eq,
1871 PartialOrd,
1872 Ord,
1873 Hash,
1874 Serialize,
1875 Deserialize,
1876)]
1877#[serde(try_from = "u32", into = "u32")]
1878pub struct WitnessQuestionId(u32);
1879
1880#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1882#[error("not a witness question number (a number from 1, e.g. 3): {0:?}")]
1883pub struct WitnessQuestionIdError(pub String);
1884
1885impl WitnessQuestionId {
1886 pub fn new(n: u32) -> Option<Self> {
1888 (n >= 1).then_some(Self(n))
1889 }
1890
1891 pub fn get(&self) -> u32 {
1893 self.0
1894 }
1895
1896 pub fn next(&self) -> Self {
1898 Self(self.0.saturating_add(1))
1899 }
1900}
1901
1902impl Default for WitnessQuestionId {
1903 fn default() -> Self {
1905 Self(1)
1906 }
1907}
1908
1909impl std::fmt::Display for WitnessQuestionId {
1910 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1911 self.0.fmt(f)
1912 }
1913}
1914
1915impl std::str::FromStr for WitnessQuestionId {
1918 type Err = WitnessQuestionIdError;
1919
1920 fn from_str(s: &str) -> Result<Self, Self::Err> {
1921 let t = s.trim();
1922 Some(t)
1923 .filter(|n| {
1924 !n.is_empty()
1925 && !n.starts_with('0')
1926 && n.bytes().all(|b| b.is_ascii_digit())
1927 })
1928 .and_then(|n| n.parse::<u32>().ok())
1929 .and_then(Self::new)
1930 .ok_or_else(|| WitnessQuestionIdError(s.to_string()))
1931 }
1932}
1933
1934impl TryFrom<u32> for WitnessQuestionId {
1935 type Error = WitnessQuestionIdError;
1936
1937 fn try_from(n: u32) -> Result<Self, Self::Error> {
1938 Self::new(n).ok_or_else(|| WitnessQuestionIdError(n.to_string()))
1939 }
1940}
1941
1942impl From<WitnessQuestionId> for u32 {
1943 fn from(id: WitnessQuestionId) -> Self {
1944 id.0
1945 }
1946}
1947
1948#[cfg(feature = "schemars")]
1951impl schemars::JsonSchema for WitnessQuestionId {
1952 fn inline_schema() -> bool {
1953 true
1954 }
1955
1956 fn schema_name() -> std::borrow::Cow<'static, str> {
1957 std::borrow::Cow::Borrowed("WitnessQuestionId")
1958 }
1959
1960 fn schema_id() -> std::borrow::Cow<'static, str> {
1961 std::borrow::Cow::Borrowed(concat!(
1962 module_path!(),
1963 "::WitnessQuestionId"
1964 ))
1965 }
1966
1967 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1968 schemars::json_schema!({
1969 "type": "integer",
1970 "description": "A witness question's number on the item, e.g. 3",
1971 })
1972 }
1973}
1974
1975#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1991pub struct ContentIdPrefix(u32);
1992
1993#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1995#[error(
1996 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1997)]
1998pub struct ContentIdPrefixError(pub String);
1999
2000impl ContentIdPrefix {
2001 pub const LEN: usize = 8;
2003
2004 pub fn of(id: &Uuid) -> Self {
2006 Self((id.as_u128() >> 96) as u32)
2007 }
2008
2009 pub fn bounds(&self) -> (Uuid, Uuid) {
2011 let lo = (self.0 as u128) << 96;
2012 let hi = lo | ((1u128 << 96) - 1);
2013 (Uuid::from_u128(lo), Uuid::from_u128(hi))
2014 }
2015
2016 pub fn matches(&self, id: &Uuid) -> bool {
2018 Self::of(id) == *self
2019 }
2020}
2021
2022impl std::fmt::Display for ContentIdPrefix {
2023 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2024 write!(f, "{:08x}", self.0)
2025 }
2026}
2027
2028impl std::str::FromStr for ContentIdPrefix {
2029 type Err = ContentIdPrefixError;
2030
2031 fn from_str(s: &str) -> Result<Self, Self::Err> {
2032 let t = s.trim();
2033 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
2034 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
2036 } else {
2037 Err(ContentIdPrefixError(s.to_string()))
2038 }
2039 }
2040}
2041
2042impl TryFrom<String> for ContentIdPrefix {
2043 type Error = ContentIdPrefixError;
2044
2045 fn try_from(s: String) -> Result<Self, Self::Error> {
2046 s.parse()
2047 }
2048}
2049
2050impl Serialize for ContentIdPrefix {
2051 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2052 s.collect_str(self)
2053 }
2054}
2055
2056impl<'de> Deserialize<'de> for ContentIdPrefix {
2057 fn deserialize<D: serde::Deserializer<'de>>(
2058 d: D,
2059 ) -> Result<Self, D::Error> {
2060 let raw = String::deserialize(d)?;
2061 raw.parse().map_err(serde::de::Error::custom)
2062 }
2063}
2064
2065impl From<ContentId> for ContentIdPrefix {
2066 fn from(id: ContentId) -> Self {
2067 Self::of(id.as_uuid())
2068 }
2069}
2070
2071#[cfg(feature = "schemars")]
2072impl schemars::JsonSchema for ContentIdPrefix {
2073 fn inline_schema() -> bool {
2074 true
2075 }
2076
2077 fn schema_name() -> std::borrow::Cow<'static, str> {
2078 std::borrow::Cow::Borrowed("ContentIdPrefix")
2079 }
2080
2081 fn schema_id() -> std::borrow::Cow<'static, str> {
2082 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
2083 }
2084
2085 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2086 schemars::json_schema!({
2087 "type": "string",
2088 "pattern": CONTENT_ID_PREFIX_PATTERN,
2089 "description": "A short post or comment id: the first eight hex \
2090 digits of its UUID, e.g. \"7ad26ccd\".",
2091 })
2092 }
2093}
2094
2095pub 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})$";
2100
2101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2110pub enum ContentTarget {
2111 Id(ContentId),
2113 Prefix(ContentIdPrefix),
2115}
2116
2117impl ContentTarget {
2118 pub fn full(&self) -> Option<ContentId> {
2120 match self {
2121 ContentTarget::Id(id) => Some(*id),
2122 ContentTarget::Prefix(_) => None,
2123 }
2124 }
2125}
2126
2127impl From<ContentId> for ContentTarget {
2128 fn from(id: ContentId) -> Self {
2129 ContentTarget::Id(id)
2130 }
2131}
2132
2133impl std::fmt::Display for ContentTarget {
2134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2135 match self {
2136 ContentTarget::Id(id) => id.fmt(f),
2137 ContentTarget::Prefix(prefix) => prefix.fmt(f),
2138 }
2139 }
2140}
2141
2142fn ascii_dashes(s: &str) -> String {
2147 s.chars()
2148 .map(|c| match c {
2149 '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
2150 c => c,
2151 })
2152 .collect()
2153}
2154
2155impl std::str::FromStr for ContentTarget {
2156 type Err = ContentTargetError;
2157
2158 fn from_str(s: &str) -> Result<Self, Self::Err> {
2159 let t = ascii_dashes(s.trim());
2160 if let Ok(id) = t.parse::<Uuid>() {
2161 return Ok(ContentTarget::Id(id.into()));
2162 }
2163 if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
2164 return Ok(ContentTarget::Prefix(prefix));
2165 }
2166 const UUID_LEN: usize = 36;
2169 let leading = t
2170 .get(..UUID_LEN)
2171 .and_then(|head| head.parse::<Uuid>().ok())
2172 .map(ContentId::from);
2173 Err(ContentTargetError {
2174 field: None,
2175 input: s.to_string(),
2176 leading,
2177 })
2178 }
2179}
2180
2181#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2187pub struct ContentTargetError {
2188 field: Option<&'static str>,
2189 input: String,
2190 leading: Option<ContentId>,
2191}
2192
2193impl ContentTargetError {
2194 pub fn in_field(mut self, field: &'static str) -> Self {
2196 self.field = Some(field);
2197 self
2198 }
2199}
2200
2201impl std::fmt::Display for ContentTargetError {
2202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2203 match self.field {
2204 Some(field) => write!(f, "`{field}` must be")?,
2205 None => f.write_str("Expected")?,
2206 }
2207 f.write_str(
2208 " a post or comment id: a full UUID or its first 8 hex digits \
2209 (e.g. \"7ad26ccd\")",
2210 )?;
2211 write!(f, ", not \"{}\"", echo(&self.input))?;
2212 if let Some(id) = self.leading {
2213 write!(
2214 f,
2215 ". It starts with the id {id} followed by extra text; pass only the id"
2216 )?;
2217 }
2218 f.write_str(".")
2219 }
2220}
2221
2222impl Serialize for ContentTarget {
2223 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2224 s.collect_str(self)
2225 }
2226}
2227
2228impl<'de> Deserialize<'de> for ContentTarget {
2229 fn deserialize<D: serde::Deserializer<'de>>(
2230 d: D,
2231 ) -> Result<Self, D::Error> {
2232 content_target::deserialize_field(d, "id")
2233 }
2234}
2235
2236pub mod content_target {
2239 use serde::de::{self, Deserializer, Visitor};
2240
2241 use super::{ContentTarget, ContentTargetError};
2242
2243 struct TargetVisitor(&'static str);
2247
2248 impl Visitor<'_> for TargetVisitor {
2249 type Value = ContentTarget;
2250
2251 fn expecting(
2252 &self,
2253 f: &mut std::fmt::Formatter<'_>,
2254 ) -> std::fmt::Result {
2255 write!(
2256 f,
2257 "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
2258 (e.g. \"7ad26ccd\")",
2259 self.0
2260 )
2261 }
2262
2263 fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
2264 v.parse()
2265 .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
2266 }
2267 }
2268
2269 pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
2270 d: D,
2271 field: &'static str,
2272 ) -> Result<ContentTarget, D::Error> {
2273 d.deserialize_str(TargetVisitor(field))
2274 }
2275
2276 pub trait ContentField: Sized {
2281 fn reply_to<'de, D: Deserializer<'de>>(d: D) -> Result<Self, D::Error>;
2283 fn target<'de, D: Deserializer<'de>>(d: D) -> Result<Self, D::Error>;
2285 }
2286
2287 impl ContentField for ContentTarget {
2288 fn reply_to<'de, D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2289 reply_to(d)
2290 }
2291
2292 fn target<'de, D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2293 target(d)
2294 }
2295 }
2296
2297 impl ContentField for super::ContentId {
2298 fn reply_to<'de, D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2299 serde::Deserialize::deserialize(d)
2300 }
2301
2302 fn target<'de, D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2303 serde::Deserialize::deserialize(d)
2304 }
2305 }
2306
2307 pub fn reply_to<'de, D: Deserializer<'de>>(
2309 d: D,
2310 ) -> Result<ContentTarget, D::Error> {
2311 deserialize_field(d, "reply_to")
2312 }
2313
2314 pub fn target<'de, D: Deserializer<'de>>(
2316 d: D,
2317 ) -> Result<ContentTarget, D::Error> {
2318 deserialize_field(d, "target")
2319 }
2320
2321 pub fn post_id<'de, D: Deserializer<'de>>(
2323 d: D,
2324 ) -> Result<ContentTarget, D::Error> {
2325 deserialize_field(d, "post_id")
2326 }
2327
2328 pub fn optional_reply_to<'de, D: Deserializer<'de>>(
2330 d: D,
2331 ) -> Result<Option<ContentTarget>, D::Error> {
2332 reply_to(d).map(Some)
2333 }
2334
2335 pub fn optional_target<'de, D: Deserializer<'de>>(
2339 d: D,
2340 ) -> Result<Option<ContentTarget>, D::Error> {
2341 d.deserialize_option(OptionalVisitor("target"))
2342 }
2343
2344 struct OptionalVisitor(&'static str);
2346
2347 impl<'de> Visitor<'de> for OptionalVisitor {
2348 type Value = Option<ContentTarget>;
2349
2350 fn expecting(
2351 &self,
2352 f: &mut std::fmt::Formatter<'_>,
2353 ) -> std::fmt::Result {
2354 TargetVisitor(self.0).expecting(f)?;
2355 f.write_str(", or nothing")
2356 }
2357
2358 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
2359 Ok(None)
2360 }
2361
2362 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
2363 Ok(None)
2364 }
2365
2366 fn visit_some<D: Deserializer<'de>>(
2367 self,
2368 d: D,
2369 ) -> Result<Self::Value, D::Error> {
2370 d.deserialize_str(self)
2371 }
2372
2373 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
2374 if v.is_empty() || v.trim().eq_ignore_ascii_case("null") {
2375 return Ok(None);
2376 }
2377 TargetVisitor(self.0).visit_str(v).map(Some)
2378 }
2379 }
2380}
2381
2382#[cfg(feature = "schemars")]
2386impl schemars::JsonSchema for ContentTarget {
2387 fn inline_schema() -> bool {
2388 true
2389 }
2390
2391 fn schema_name() -> std::borrow::Cow<'static, str> {
2392 std::borrow::Cow::Borrowed("ContentTarget")
2393 }
2394
2395 fn schema_id() -> std::borrow::Cow<'static, str> {
2396 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
2397 }
2398
2399 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2400 schemars::json_schema!({
2401 "type": "string",
2402 "pattern": CONTENT_TARGET_PATTERN,
2403 "description": "A post or comment id: its full UUID, or its first \
2404 8 hex digits (e.g. \"7ad26ccd\") as shown by \
2405 get_content and the dashboard. A short id works \
2406 only on unsigned calls; a signed call must use \
2407 the full UUID.",
2408 })
2409 }
2410}
2411
2412#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2415pub struct ContentRefError(pub String);
2416
2417impl ContentRefError {
2418 pub fn leading_ref(&self) -> Option<ContentRef> {
2422 const UUID_LEN: usize = 36;
2423 const CITATION_LENS: [usize; 2] =
2427 ["DATA-2026-0001".len(), "GOV-2026-0006".len()];
2428 let s = self.0.trim_start();
2429 if crate::govlog::DataCitation::is_citation_shaped(s) {
2433 return None;
2434 }
2435 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
2437 let head = s.get(..prefix.len())?;
2438 head.eq_ignore_ascii_case(prefix).then(|| {
2439 prefix.len()
2440 + s[prefix.len()..]
2441 .find(|c: char| {
2442 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
2443 })
2444 .unwrap_or(s.len() - prefix.len())
2445 })
2446 });
2447 prompt_len
2449 .into_iter()
2450 .chain([
2451 UUID_LEN,
2452 CITATION_LENS[0],
2453 CITATION_LENS[1],
2454 "constitution".len(),
2455 "protocol".len(),
2456 "prompts".len(),
2457 ])
2458 .filter(|&n| s.len() > n && s.is_char_boundary(n))
2459 .find_map(|n| {
2463 s[..n]
2464 .parse()
2465 .ok()
2466 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
2467 })
2468 }
2469}
2470
2471impl std::fmt::Display for ContentRefError {
2472 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2473 if self
2475 .0
2476 .trim_start()
2477 .get(..crate::links::SCHEME.len() + 1)
2478 .is_some_and(|s| s.eq_ignore_ascii_case("agora:"))
2479 {
2480 return write!(
2481 f,
2482 "{:?} is an agora:// link, which fetch_agora_link reads; \
2483 get_content takes an id",
2484 self.0
2485 );
2486 }
2487 let lower = self.0.trim_start().to_ascii_lowercase();
2489 if ["https://", "http://"].iter().any(|s| lower.starts_with(s)) {
2490 return write!(
2491 f,
2492 "{:?} is a URL: fetch_agora_link reads this site's page URLs \
2493 and agora:// links; get_content takes an id",
2494 self.0
2495 );
2496 }
2497 if crate::govlog::DataCitation::is_citation_shaped(&self.0)
2498 && let Err(e) = self.0.parse::<crate::govlog::DataCitation>()
2499 {
2500 return write!(f, "not a data set citation: {e} ({:?})", self.0);
2501 }
2502 write!(
2503 f,
2504 "not a content reference (expected a post/comment UUID or its \
2505 first eight hex digits, a governance id ({}), a data set's file \
2506 as DATA-YYYY-NNNN:<commit>:<path> or DATA-YYYY-NNNN:/<path>, or \
2507 a document: {}): {:?}",
2508 GovernanceLogPrefix::ALL
2511 .iter()
2512 .map(|p| format!("{p}-YYYY-NNNN"))
2513 .collect::<Vec<_>>()
2514 .join(", "),
2515 PlatformDoc::SLUGS
2516 .iter()
2517 .map(|s| format!("{s:?}"))
2518 .collect::<Vec<_>>()
2519 .join(", "),
2520 self.0
2521 )?;
2522 if let Some(id) = self.leading_ref() {
2523 write!(
2524 f,
2525 ". It starts with the valid id {id} followed by extra text; \
2526 pass only the id"
2527 )?;
2528 }
2529 Ok(())
2530 }
2531}
2532
2533#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2551pub enum ContentRef {
2552 Content(ContentId),
2554 ContentPrefix(ContentIdPrefix),
2557 Governance(GovernanceLogId),
2559 DataCitation(crate::govlog::DataCitation),
2563 Document(PlatformDoc),
2565}
2566
2567impl ContentRef {
2568 pub fn as_content(&self) -> Option<ContentId> {
2570 match self {
2571 ContentRef::Content(id) => Some(*id),
2572 _ => None,
2573 }
2574 }
2575
2576 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
2578 match self {
2579 ContentRef::Governance(id) => Some(id),
2580 _ => None,
2581 }
2582 }
2583
2584 pub fn as_document(&self) -> Option<&PlatformDoc> {
2586 match self {
2587 ContentRef::Document(doc) => Some(doc),
2588 _ => None,
2589 }
2590 }
2591
2592 pub fn as_data_citation(&self) -> Option<&crate::govlog::DataCitation> {
2595 match self {
2596 ContentRef::DataCitation(c) => Some(c),
2597 _ => None,
2598 }
2599 }
2600
2601 pub fn data_set(&self) -> Option<&GovernanceLogId> {
2604 match self {
2605 ContentRef::Governance(id)
2606 if id.prefix() == GovernanceLogPrefix::Data =>
2607 {
2608 Some(id)
2609 }
2610 ContentRef::DataCitation(c) => Some(&c.set),
2611 _ => None,
2612 }
2613 }
2614
2615 pub fn is_governance(&self) -> bool {
2619 matches!(self, ContentRef::Governance(_))
2620 }
2621
2622 pub fn kind_str(&self) -> &'static str {
2625 match self {
2626 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
2627 ContentRef::Governance(_) => "governance",
2628 ContentRef::DataCitation(_) => "data file",
2629 ContentRef::Document(_) => "document",
2630 }
2631 }
2632}
2633
2634impl std::fmt::Display for ContentRef {
2635 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2636 match self {
2637 ContentRef::Content(id) => id.fmt(f),
2638 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
2639 ContentRef::Governance(id) => id.fmt(f),
2640 ContentRef::DataCitation(c) => c.fmt(f),
2641 ContentRef::Document(doc) => doc.fmt(f),
2642 }
2643 }
2644}
2645
2646impl std::str::FromStr for ContentRef {
2647 type Err = ContentRefError;
2648
2649 fn from_str(s: &str) -> Result<Self, Self::Err> {
2650 if let Ok(id) = s.parse::<ContentId>() {
2651 return Ok(ContentRef::Content(id));
2652 }
2653 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
2654 return Ok(ContentRef::ContentPrefix(prefix));
2655 }
2656 if let Ok(id) = s.parse::<GovernanceLogId>() {
2657 return Ok(ContentRef::Governance(id));
2658 }
2659 if crate::govlog::DataCitation::is_citation_shaped(s) {
2660 return s
2661 .parse()
2662 .map(ContentRef::DataCitation)
2663 .map_err(|_| ContentRefError(s.to_string()));
2664 }
2665 if let Ok(doc) = s.parse::<PlatformDoc>() {
2666 return Ok(ContentRef::Document(doc));
2667 }
2668 Err(ContentRefError(s.to_string()))
2669 }
2670}
2671
2672impl TryFrom<String> for ContentRef {
2673 type Error = ContentRefError;
2674
2675 fn try_from(s: String) -> Result<Self, Self::Error> {
2676 s.parse()
2677 }
2678}
2679
2680impl From<ContentId> for ContentRef {
2681 fn from(id: ContentId) -> Self {
2682 ContentRef::Content(id)
2683 }
2684}
2685
2686impl From<PostId> for ContentRef {
2687 fn from(id: PostId) -> Self {
2688 ContentRef::Content(id.into())
2689 }
2690}
2691
2692impl From<CommentId> for ContentRef {
2693 fn from(id: CommentId) -> Self {
2694 ContentRef::Content(id.into())
2695 }
2696}
2697
2698impl From<ContentIdPrefix> for ContentRef {
2699 fn from(prefix: ContentIdPrefix) -> Self {
2700 ContentRef::ContentPrefix(prefix)
2701 }
2702}
2703
2704impl From<GovernanceLogId> for ContentRef {
2705 fn from(id: GovernanceLogId) -> Self {
2706 ContentRef::Governance(id)
2707 }
2708}
2709
2710impl From<crate::govlog::DataCitation> for ContentRef {
2711 fn from(c: crate::govlog::DataCitation) -> Self {
2712 ContentRef::DataCitation(c)
2713 }
2714}
2715
2716impl From<PlatformDoc> for ContentRef {
2717 fn from(doc: PlatformDoc) -> Self {
2718 ContentRef::Document(doc)
2719 }
2720}
2721
2722impl Serialize for ContentRef {
2723 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2724 s.collect_str(self)
2725 }
2726}
2727
2728impl<'de> Deserialize<'de> for ContentRef {
2729 fn deserialize<D: serde::Deserializer<'de>>(
2730 d: D,
2731 ) -> Result<Self, D::Error> {
2732 let raw = String::deserialize(d)?;
2733 raw.parse().map_err(serde::de::Error::custom)
2734 }
2735}
2736
2737#[cfg(feature = "schemars")]
2742impl schemars::JsonSchema for ContentRef {
2743 fn inline_schema() -> bool {
2744 true
2745 }
2746
2747 fn schema_name() -> std::borrow::Cow<'static, str> {
2748 std::borrow::Cow::Borrowed("ContentRef")
2749 }
2750
2751 fn schema_id() -> std::borrow::Cow<'static, str> {
2752 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
2753 }
2754
2755 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2756 schemars::json_schema!({
2757 "type": "string",
2758 "pattern": CONTENT_REF_PATTERN,
2759 "description": "A post or comment UUID, or its first eight \
2760 hex digits (e.g. \"7ad26ccd\"); a governance log id \
2761 such as \"GOV-2026-0006\" (Council decision) \
2762 or \"APP-2026-0003\" (appeals ruling); a data \
2763 set's file cited at a commit, \
2764 \"DATA-2026-0001:<commit>:<path>\" (or \
2765 \"…:<path>:110-300\" for some of its lines; \
2766 without the commit, for the one the set pins, \
2767 \"DATA-2026-0001:/<path>\"); or a \
2768 document slug — \"constitution\", \
2769 \"protocol\" (the Governance Protocol), \
2770 \"prompts\" (the index of the model prompts \
2771 moderation, appeals and the Council run on) or \
2772 \"prompt:<name>\" (one of them).",
2773 })
2774 }
2775}
2776
2777#[cfg(test)]
2778mod tests {
2779 #[test]
2782 fn rewritten_in_finds_the_spelling_read() {
2783 let id: super::GovernanceLogId = "GOV-2026-0005".parse().unwrap();
2784 for (written, part) in [
2785 ("GOV-2026-5", Some("GOV-2026-5")),
2786 ("gov-2026-0005", Some("gov-2026-0005")),
2787 (" GOV 2026 5 ", Some("GOV 2026 5")),
2788 ("GOV-2026.5", Some("GOV-2026.5")),
2789 ("agora://gov/GOV-2026-5?round=2", Some("GOV-2026-5")),
2790 ("`agora://gov/gov-2026-05/clerk-reads`", Some("gov-2026-05")),
2791 ("GOV-2026-0005", None),
2792 ("agora://gov/GOV-2026-0005?round=2", None),
2793 ("GOV-2026-6", None),
2794 ] {
2795 assert_eq!(id.rewritten_in(written), part, "{written}");
2796 }
2797 let set: super::GovernanceLogId = "DATA-2026-0001".parse().unwrap();
2798 assert_eq!(
2799 set.rewritten_in("DATA-2026-1:/votes/index.md"),
2800 Some("DATA-2026-1")
2801 );
2802 }
2803 use super::*;
2804
2805 #[test]
2806 fn witness_ids_fold_and_refuse() {
2807 let id: WitnessId = " Qwen_3-8 ".parse().unwrap();
2808 assert_eq!(id.as_str(), "qwen-3-8");
2809 for bad in ["", "3qwen", "-qwen", "qwen 3", "qwen.3", &"q".repeat(65)] {
2810 assert!(bad.parse::<WitnessId>().is_err(), "{bad:?}");
2811 }
2812 let json: WitnessId = serde_json::from_str("\"FACT_checker\"").unwrap();
2813 assert_eq!(json.as_str(), "fact-checker");
2814 assert!(serde_json::from_str::<WitnessId>("\"no way\"").is_err());
2815 }
2816
2817 #[test]
2818 fn witness_question_ids_are_bare_numbers() {
2819 let id: WitnessQuestionId = " 12 ".parse().unwrap();
2820 assert_eq!(id.get(), 12);
2821 assert_eq!(id.to_string(), "12");
2822 assert_eq!(id.next().to_string(), "13");
2823 assert_eq!(WitnessQuestionId::default().to_string(), "1");
2824 for bad in ["", "0", "012", "q12", "-1", "1.5", "1", "99999999999"] {
2825 assert!(bad.parse::<WitnessQuestionId>().is_err(), "{bad:?}");
2826 }
2827 assert_eq!(serde_json::to_string(&id).unwrap(), "12");
2828 assert_eq!(
2829 serde_json::from_str::<WitnessQuestionId>("12").unwrap(),
2830 id
2831 );
2832 assert!(serde_json::from_str::<WitnessQuestionId>("0").is_err());
2833 assert!(serde_json::from_str::<WitnessQuestionId>("\"12\"").is_err());
2834 }
2835
2836 #[cfg(feature = "schemars")]
2837 #[test]
2838 fn witness_question_id_schema_is_inline_and_patternless() {
2839 let schema =
2840 serde_json::to_value(schemars::schema_for!(WitnessQuestionId))
2841 .unwrap();
2842 let rendered = schema.to_string();
2843 assert!(!rendered.contains("pattern"), "{rendered}");
2844 assert!(!rendered.contains("$ref"), "{rendered}");
2845 }
2846
2847 #[cfg(feature = "schemars")]
2848 #[test]
2849 fn witness_id_schema_is_inline_and_patternless() {
2850 let schema =
2851 serde_json::to_value(schemars::schema_for!(WitnessId)).unwrap();
2852 let rendered = schema.to_string();
2853 assert!(!rendered.contains("pattern"), "{rendered}");
2854 assert!(!rendered.contains("$ref"), "{rendered}");
2855 }
2856
2857 #[test]
2858 fn governance_log_ids_from_parts() {
2859 for prefix in GovernanceLogPrefix::ALL {
2860 let id = GovernanceLogId::from_parts(prefix, 2026, 42).unwrap();
2861 assert_eq!(id.as_str(), format!("{prefix}-2026-0042"));
2862 assert_eq!(id.prefix(), prefix);
2863 assert_eq!(id.as_str().parse::<GovernanceLogId>().unwrap(), id);
2864 }
2865 let gov = GovernanceLogPrefix::Gov;
2866 assert!(GovernanceLogId::from_parts(gov, 2026, 9999).is_some());
2867 assert!(GovernanceLogId::from_parts(gov, 2026, 10_000).is_none());
2868 assert!(GovernanceLogId::from_parts(gov, 2026, 0).is_none());
2869 assert!(GovernanceLogId::from_parts(gov, 999, 1).is_none());
2870 }
2871
2872 #[test]
2874 fn archive_contexts_round_trip_through_their_columns() {
2875 use crate::enums::ArchiveContextKind;
2876 let contexts = [
2877 ArchiveContext::from(AgendaItemId::new()),
2878 ArchiveContext::from(CouncilMeetingId::new()),
2879 ArchiveContext::from(AppealId::new()),
2880 ArchiveContext::from(FlagId::new()),
2881 ArchiveContext::from(InterviewRunId::new()),
2882 ];
2883 let kinds: Vec<_> = contexts.iter().map(ArchiveContext::kind).collect();
2884 assert_eq!(kinds, ArchiveContextKind::ALL);
2885 for c in contexts {
2886 assert_eq!(ArchiveContext::from_stored(c.kind(), c.as_uuid()), c);
2887 }
2888 let id = AgendaItemId::new();
2889 assert_eq!(
2890 ArchiveContext::from(id).to_string(),
2891 format!("agenda item {id}")
2892 );
2893 }
2894
2895 #[test]
2896 fn byte_ids_debug() {
2897 let handle = WebAuthnUserHandle::from(vec![7; 64]);
2898 assert_eq!(format!("{handle:?}"), "WebAuthnUserHandle(..)");
2899 let credential = PasskeyCredentialId::from(vec![0xab; 2]);
2900 assert_eq!(format!("{credential:?}"), "PasskeyCredentialId(abab)");
2901 assert_eq!(credential.as_bytes(), &[0xab, 0xab]);
2902 }
2903
2904 #[test]
2905 fn oauth_client_id_round_trips() {
2906 let id = OAuthClientId::generate();
2907 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
2908 let json = serde_json::to_string(&id).unwrap();
2909 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
2910 }
2911
2912 #[test]
2913 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
2914 assert!("".parse::<OAuthClientId>().is_err());
2915 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
2916 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
2917 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
2918 }
2919
2920 #[test]
2924 fn a_data_citation_is_a_content_ref() {
2925 let written = "DATA-2026-0001:89fe7c6:votes/ballots.csv:110-300";
2926 let r: ContentRef = written.parse().unwrap();
2927 let c = r.as_data_citation().expect("a citation");
2928 assert_eq!(c.path.as_str(), "votes/ballots.csv");
2929 assert_eq!(r.to_string(), written);
2930 assert_eq!(
2931 r.data_set().map(GovernanceLogId::as_str),
2932 Some("DATA-2026-0001")
2933 );
2934 assert!(!r.is_governance());
2935 assert_eq!(r.kind_str(), "data file");
2936 let json = serde_json::to_value(&r).unwrap();
2937 assert_eq!(json, written);
2938 assert_eq!(serde_json::from_value::<ContentRef>(json).unwrap(), r);
2939 let whole: ContentRef =
2940 "DATA-2026-0001:89fe7c6:votes/ballots.csv".parse().unwrap();
2941 assert_eq!(whole.as_data_citation().unwrap().lines, None);
2942 let pinned: ContentRef =
2944 "DATA-2026-0001:/votes/ballots.csv:110-300".parse().unwrap();
2945 let p = pinned.as_data_citation().expect("a citation");
2946 assert_eq!(
2947 (p.commit.as_ref(), p.path.as_str()),
2948 (None, "votes/ballots.csv")
2949 );
2950 assert_eq!(
2951 pinned.to_string(),
2952 "DATA-2026-0001:/votes/ballots.csv:110-300"
2953 );
2954 let json = serde_json::to_value(&pinned).unwrap();
2955 assert_eq!(serde_json::from_value::<ContentRef>(json).unwrap(), pinned);
2956 let hexdir: ContentRef =
2957 "DATA-2026-0001:/abcdef1/file.md".parse().unwrap();
2958 assert_eq!(hexdir.as_data_citation().unwrap().commit, None);
2959 for other in
2961 ["GOV-2026-0006:votes/a.md", "TEST-2026-0001:votes/a.md:1-2"]
2962 {
2963 let err = other.parse::<ContentRef>().unwrap_err();
2964 assert!(!crate::govlog::DataCitation::is_citation_shaped(other));
2965 assert!(
2966 !err.to_string().contains("not a data set citation"),
2967 "{err}"
2968 );
2969 }
2970 let set: ContentRef = "DATA-2026-0001".parse().unwrap();
2971 assert_eq!(set.data_set(), Some(&"DATA-2026-0001".parse().unwrap()));
2972 assert!(set.as_data_citation().is_none());
2973
2974 for bad in [
2975 "DATA-2026-0001:89fe7c6",
2976 "DATA-2026-0001::",
2977 "DATA-2026-0001: the vote tallies",
2978 ] {
2979 let err = bad.parse::<ContentRef>().unwrap_err();
2980 assert_eq!(err.leading_ref(), None, "{bad}");
2981 let text = err.to_string();
2982 assert!(text.contains("not a data set citation"), "{text}");
2983 assert!(text.contains("DATA-YYYY-NNNN:<commit>:<path>"), "{text}");
2984 assert!(!text.contains("starts with the valid id"), "{text}");
2985 }
2986 let test: ContentRef = "TEST-2026-0001".parse().unwrap();
2990 assert!(test.is_governance() && test.as_data_citation().is_none());
2991 assert_eq!(test.data_set(), None);
2992 assert!(!crate::govlog::DataCitation::is_citation_shaped(
2993 "TEST-2026-0001:89fe7c6:votes/a.md"
2994 ));
2995 let err = "TEST-2026-0001:89fe7c6:votes/a.md"
2996 .parse::<ContentRef>()
2997 .unwrap_err();
2998 assert!(
2999 !err.to_string().contains("not a data set citation"),
3000 "{err}"
3001 );
3002 assert_eq!(err.leading_ref(), Some("TEST-2026-0001".parse().unwrap()));
3003 assert!(matches!(
3004 "TEST-2026-0001:89fe7c6:votes/a.md"
3005 .parse::<crate::govlog::DataCitation>(),
3006 Err(crate::govlog::DataCitationError::NotDataSet(_))
3007 ));
3008 let data: ContentRef =
3009 "DATA-2026-0001:89fe7c6:votes/a.md".parse().unwrap();
3010 assert!(data.as_data_citation().is_some() && !data.is_governance());
3011 let err = "GOV-2026-0006: the ratification"
3013 .parse::<ContentRef>()
3014 .unwrap_err();
3015 assert_eq!(err.leading_ref(), Some("GOV-2026-0006".parse().unwrap()));
3016 }
3017
3018 #[test]
3019 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
3020 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
3021 for (input, lead) in [
3022 (format!("{uuid} and the safe-space proposal"), uuid),
3023 (format!("{uuid}b0518e42"), uuid),
3024 (
3025 "GOV-2026-0006 (the ratification)".to_string(),
3026 "GOV-2026-0006",
3027 ),
3028 (
3031 "DATA-2026-0001 (the vote tallies)".to_string(),
3032 "DATA-2026-0001",
3033 ),
3034 ("DATA-2026-0012, index.md".to_string(), "DATA-2026-0012"),
3035 ("TEST-2026-0001 (the dry run)".to_string(), "TEST-2026-0001"),
3037 ("OBL-2026-0001 and more".to_string(), "OBL-2026-0001"),
3038 ("protocol, section 3".to_string(), "protocol"),
3039 ("prompts, please".to_string(), "prompts"),
3040 (
3041 "prompt:tier2_reviewer and the juror's".to_string(),
3042 "prompt:tier2_reviewer",
3043 ),
3044 (
3045 "prompts/appeals_juror.md, line 3".to_string(),
3046 "prompt:appeals_juror",
3047 ),
3048 ] {
3049 let err = input.parse::<ContentRef>().unwrap_err();
3050 assert_eq!(
3051 err.leading_ref(),
3052 Some(lead.parse().unwrap()),
3053 "{input}"
3054 );
3055 let msg = err.to_string();
3056 assert!(
3057 msg.contains(&format!("valid id {lead} followed")),
3058 "{msg}"
3059 );
3060 }
3061
3062 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
3064 let err = short.parse::<ContentRef>().unwrap_err();
3065 assert_eq!(err.leading_ref(), None);
3066 assert!(!err.to_string().contains("followed by"));
3067 assert_eq!(
3069 "GOV-2026-1".parse::<ContentRef>().unwrap(),
3070 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
3071 );
3072 }
3073
3074 #[test]
3075 fn citations_normalize_as_written() {
3076 for (written, canonical) in [
3077 ("GOV-2026-0006", "GOV-2026-0006"),
3078 ("GOV-2026.6", "GOV-2026-0006"),
3079 ("gov-2026-6", "GOV-2026-0006"),
3080 ("GOV 2026 6", "GOV-2026-0006"),
3081 ("app/2026/03", "APP-2026-0003"),
3082 (" REC-2026-0002 ", "REC-2026-0002"),
3083 ("obl-2026-12", "OBL-2026-0012"),
3084 ("OBL.2026.7", "OBL-2026-0007"),
3085 ("data-2026-3", "DATA-2026-0003"),
3086 ("DATA.2026.12", "DATA-2026-0012"),
3087 (" Data/2026/0001 ", "DATA-2026-0001"),
3088 ("DATA 2026 7", "DATA-2026-0007"),
3089 ("test-2026-3", "TEST-2026-0003"),
3090 ("Test.2026.12", "TEST-2026-0012"),
3091 ] {
3092 let id: GovernanceLogId = written.parse().unwrap();
3093 assert_eq!(id.as_str(), canonical, "{written}");
3094 let json: GovernanceLogId =
3095 serde_json::from_value(serde_json::json!(written)).unwrap();
3096 assert_eq!(json.as_str(), canonical, "{written} via serde");
3097 let content: ContentRef = written.parse().unwrap();
3098 assert_eq!(content, ContentRef::Governance(id), "{written}");
3099 }
3100 for rejected in [
3101 "GOV-2026-N",
3102 "GOV-2026/GOVG=6",
3103 "GOV-2026-00006",
3104 "GOV-26-0006",
3105 "GOV-2026-",
3106 "XYZ-2026-0006",
3107 "GOV-2026-0006-1",
3108 ] {
3109 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
3110 }
3111 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
3113 }
3114
3115 #[test]
3116 fn content_ref_parses_document_slugs() {
3117 assert_eq!(
3118 "constitution".parse(),
3119 Ok(ContentRef::Document(PlatformDoc::Constitution))
3120 );
3121 assert_eq!(
3122 "protocol".parse(),
3123 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
3124 );
3125 assert_eq!(
3127 "governance-protocol".parse(),
3128 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
3129 );
3130 assert_eq!(
3131 "Constitution".parse(),
3132 Ok(ContentRef::Document(PlatformDoc::Constitution))
3133 );
3134 assert!("proto".parse::<ContentRef>().is_err());
3135 }
3136
3137 #[test]
3138 fn content_ref_parses_prompt_slugs() {
3139 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
3140 for written in [
3141 "prompt:appeals_juror",
3142 "Prompt:Appeals_Juror",
3143 "prompt/appeals-juror",
3144 "prompts/appeals_juror.md",
3145 "prompts:appeals_juror",
3146 ] {
3147 assert_eq!(
3148 written.parse(),
3149 Ok(ContentRef::Document(juror.clone())),
3150 "{written}"
3151 );
3152 }
3153 assert_eq!(juror.to_string(), "prompt:appeals_juror");
3154 for index in ["prompts", "Prompts", "prompt"] {
3155 assert_eq!(
3156 index.parse(),
3157 Ok(ContentRef::Document(PlatformDoc::Prompts)),
3158 "{index}"
3159 );
3160 }
3161 for rejected in [
3162 "prompt:",
3163 "prompt:2fast",
3164 "prompt:tier 2",
3165 "prompt:../secrets",
3166 "prompt:tier2_reviewer/x",
3167 "promptly",
3168 ] {
3169 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
3170 }
3171 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
3172 assert!(long.parse::<ContentRef>().is_err());
3173 }
3174
3175 #[test]
3176 fn content_id_prefix_parses_eight_hex_digits_leniently() {
3177 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
3178 assert_eq!(p.to_string(), "7ad26ccd");
3179 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
3180 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
3181 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
3182 }
3183 }
3184
3185 #[test]
3186 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
3187 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
3188 let p = ContentIdPrefix::of(&id);
3189 assert_eq!(p.to_string(), "7ad26ccd");
3190 assert!(p.matches(&id));
3191 let (lo, hi) = p.bounds();
3192 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
3193 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
3194 assert!(lo <= id && id <= hi);
3195 let next: Uuid =
3196 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
3197 assert!(!p.matches(&next) && next > hi);
3198 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
3200 assert_eq!(top.bounds().1, Uuid::max());
3201 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
3202 assert_eq!(bottom.bounds().0, Uuid::nil());
3203 }
3204
3205 #[test]
3206 fn content_ref_reads_a_short_id_and_only_eight_digits() {
3207 let r: ContentRef = "7ad26ccd".parse().unwrap();
3208 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
3209 assert_eq!(r.kind_str(), "content");
3210 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
3211 let full: ContentRef =
3213 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
3214 assert!(matches!(full, ContentRef::Content(_)));
3215 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
3220 assert_eq!(err.leading_ref(), None);
3221 }
3222
3223 #[test]
3224 fn content_ref_pattern_admits_short_ids() {
3225 let short = CONTENT_ID_PREFIX_PATTERN
3226 .strip_prefix('^')
3227 .and_then(|p| p.strip_suffix('$'))
3228 .unwrap();
3229 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
3230 }
3231
3232 #[test]
3234 fn document_patterns_agree() {
3235 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
3236 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
3237 let name = PROMPT_NAME_PATTERN
3238 .strip_prefix('^')
3239 .and_then(|p| p.strip_suffix('$'))
3240 .unwrap();
3241 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
3242 }
3243
3244 #[test]
3245 fn calendar_is_a_document_slug() {
3246 assert_eq!(
3247 "calendar".parse::<ContentRef>().unwrap(),
3248 ContentRef::Document(PlatformDoc::Calendar)
3249 );
3250 assert_eq!(
3251 "Calendar".parse::<PlatformDoc>().unwrap(),
3252 PlatformDoc::Calendar
3253 );
3254 assert_eq!(PlatformDoc::Calendar.to_string(), "calendar");
3255 assert_eq!(
3256 "OBL-2026-0001".parse::<ContentRef>().unwrap(),
3257 ContentRef::Governance("OBL-2026-0001".parse().unwrap())
3258 );
3259 assert_eq!(
3260 "DATA-2026-0001".parse::<ContentRef>().unwrap(),
3261 ContentRef::Governance("DATA-2026-0001".parse().unwrap())
3262 );
3263 assert_eq!(
3264 "TEST-2026-0001".parse::<ContentRef>().unwrap(),
3265 ContentRef::Governance("TEST-2026-0001".parse().unwrap())
3266 );
3267 }
3268
3269 #[test]
3270 fn platform_doc_serde_uses_the_canonical_slug() {
3271 let json =
3272 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
3273 assert_eq!(json, "\"protocol\"");
3274 let doc: PlatformDoc =
3275 serde_json::from_str("\"governance-protocol\"").unwrap();
3276 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
3277 }
3278
3279 #[test]
3280 fn ids_are_unique() {
3281 let a = AgentId::new();
3282 let b = AgentId::new();
3283 assert_ne!(a, b);
3284 }
3285
3286 #[test]
3287 fn serde_round_trip() {
3288 let id = PostId::new();
3289 let json = serde_json::to_string(&id).unwrap();
3290 let deserialized: PostId = serde_json::from_str(&json).unwrap();
3291 assert_eq!(id, deserialized);
3292 }
3293
3294 #[test]
3295 fn display_shows_uuid() {
3296 let id = CommunityId::new();
3297 let display = id.to_string();
3298 assert_eq!(display.len(), 36);
3300 assert!(display.contains('-'));
3301 }
3302
3303 #[test]
3304 fn from_uuid_round_trip() {
3305 let uuid = Uuid::new_v4();
3306 let id = AgentId::from(uuid);
3307 let back: Uuid = id.into();
3308 assert_eq!(uuid, back);
3309 }
3310
3311 #[test]
3315 fn every_id_round_trips_through_its_own_display() {
3316 let agent = AgentId::new();
3317 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
3318
3319 let action = ModerationActionId::new();
3320 assert_eq!(
3321 action.to_string().parse::<ModerationActionId>().unwrap(),
3322 action
3323 );
3324
3325 let content = ContentId::new();
3326 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
3327
3328 let feedback = FeedbackId::new();
3329 assert_eq!(
3330 feedback.to_string().parse::<FeedbackId>().unwrap(),
3331 feedback
3332 );
3333 }
3334
3335 #[test]
3336 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
3337 assert!("not-a-uuid".parse::<ContentId>().is_err());
3338 assert!("".parse::<ContentId>().is_err());
3339 }
3340
3341 #[test]
3346 fn content_id_is_wire_compatible_with_a_bare_uuid() {
3347 let uuid = Uuid::new_v4();
3348 let typed = ContentId::from(uuid);
3349 assert_eq!(
3350 serde_json::to_string(&typed).unwrap(),
3351 serde_json::to_string(&uuid).unwrap()
3352 );
3353 }
3354
3355 #[test]
3358 fn every_moderation_target_narrows_losslessly() {
3359 let uuid = Uuid::new_v4();
3360
3361 for (label, got) in [
3362 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
3363 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
3364 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
3365 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
3366 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
3367 ] {
3368 assert_eq!(
3369 got.as_uuid(),
3370 &uuid,
3371 "{label} -> ModerationTargetId lost the uuid"
3372 );
3373 }
3374 }
3375
3376 #[test]
3380 fn resolved_ids_narrow_to_content_id_losslessly() {
3381 let uuid = Uuid::new_v4();
3382
3383 assert_eq!(
3384 ContentId::from(PostId::from(uuid)).as_uuid(),
3385 &uuid,
3386 "PostId -> ContentId lost the uuid"
3387 );
3388 assert_eq!(
3389 ContentId::from(CommentId::from(uuid)).as_uuid(),
3390 &uuid,
3391 "CommentId -> ContentId lost the uuid"
3392 );
3393 assert_eq!(
3394 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
3395 .as_uuid(),
3396 &uuid,
3397 "PostOrCommentId -> ContentId lost the uuid"
3398 );
3399 }
3400
3401 #[test]
3402 fn json_is_plain_uuid_string() {
3403 let uuid = Uuid::new_v4();
3404 let id = AgentId::from(uuid);
3405 let id_json = serde_json::to_string(&id).unwrap();
3407 let uuid_json = serde_json::to_string(&uuid).unwrap();
3408 assert_eq!(id_json, uuid_json);
3409 }
3410
3411 #[cfg(feature = "schemars")]
3416 #[test]
3417 fn id_json_schema_is_inlined() {
3418 use schemars::JsonSchema;
3419
3420 assert!(
3421 <PostId as JsonSchema>::inline_schema(),
3422 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
3423 );
3424 assert!(<AgentId as JsonSchema>::inline_schema());
3425 assert!(<CommentId as JsonSchema>::inline_schema());
3426 assert!(<CommunityId as JsonSchema>::inline_schema());
3427 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
3428 assert!(<ContentRef as JsonSchema>::inline_schema());
3429 assert!(<FeedbackId as JsonSchema>::inline_schema());
3430 assert!(<ContactRequestId as JsonSchema>::inline_schema());
3431
3432 #[derive(schemars::JsonSchema)]
3436 #[allow(dead_code)]
3437 struct Container {
3438 post_id: PostId,
3440 agent_id: Option<AgentId>,
3442 gov_id: GovernanceLogId,
3444 maybe_gov_id: Option<GovernanceLogId>,
3446 content_ref: ContentRef,
3448 maybe_content_ref: Option<ContentRef>,
3450 feedback_id: FeedbackId,
3452 }
3453
3454 let schema = schemars::schema_for!(Container);
3455 let value = serde_json::to_value(&schema).unwrap();
3456
3457 assert!(
3459 value.get("$defs").is_none(),
3460 "no $defs should be emitted for ID-only container; got schema: {value}"
3461 );
3462
3463 let post_id = &value["properties"]["post_id"];
3465 assert!(
3466 post_id.get("$ref").is_none(),
3467 "post_id must not be a $ref; got: {post_id}"
3468 );
3469 assert_eq!(post_id["type"], "string");
3470 assert_eq!(post_id["format"], "uuid");
3471
3472 let agent_id = &value["properties"]["agent_id"];
3477 assert!(
3478 agent_id.get("$ref").is_none(),
3479 "agent_id must not be a $ref; got: {agent_id}"
3480 );
3481 let agent_id_str = agent_id.to_string();
3482 assert!(
3483 !agent_id_str.contains("$ref"),
3484 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
3485 );
3486 assert!(
3487 agent_id_str.contains("\"format\":\"uuid\""),
3488 "agent_id should still carry format=uuid; got: {agent_id}"
3489 );
3490
3491 for field in
3496 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
3497 {
3498 let f = &value["properties"][field];
3499 assert!(
3500 !f.to_string().contains("$ref"),
3501 "{field} must contain no $ref anywhere; got: {f}"
3502 );
3503 }
3504 assert_eq!(value["properties"]["gov_id"]["type"], "string");
3505 assert_eq!(
3506 value["properties"]["gov_id"]["pattern"],
3507 GOVERNANCE_LOG_ID_PATTERN
3508 );
3509 assert!(
3510 value["properties"]["maybe_gov_id"]
3511 .to_string()
3512 .contains("GOV|APP"),
3513 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
3514 value["properties"]["maybe_gov_id"]
3515 );
3516 assert_eq!(value["properties"]["content_ref"]["type"], "string");
3517 }
3518
3519 #[test]
3520 fn governance_log_id_accepts_only_citation_shapes() {
3521 for (good, prefix) in [
3522 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
3523 ("APP-2026-0003", GovernanceLogPrefix::App),
3524 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
3525 ("KEY-2026-0001", GovernanceLogPrefix::Key),
3526 ("REC-2026-0001", GovernanceLogPrefix::Rec),
3527 ("OBL-2026-0001", GovernanceLogPrefix::Obl),
3528 ("DATA-2026-0001", GovernanceLogPrefix::Data),
3529 ("TEST-2026-0001", GovernanceLogPrefix::Test),
3530 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
3531 ] {
3532 let id = good.parse::<GovernanceLogId>().unwrap();
3533 assert_eq!(id.as_str(), good, "{good} should parse");
3534 assert_eq!(id.prefix(), prefix);
3535 let letters = good.split('-').next().unwrap();
3536 assert_eq!(id.prefix().as_str(), letters);
3537 assert_eq!(id.prefix().to_string().parse(), Ok(prefix));
3538 }
3539 let newer: GovernanceLogId =
3541 serde_json::from_value(serde_json::json!("ZZZ-2026-0001")).unwrap();
3542 assert_eq!(newer.prefix(), GovernanceLogPrefix::Unknown);
3543 assert_eq!(newer.as_str(), "ZZZ-2026-0001");
3544 assert!("ZZZ-2026-0001".parse::<GovernanceLogId>().is_err());
3545 assert!(!GovernanceLogId::is_citation_shaped("ZZZ-2026-0001"));
3546 for junk in [
3547 "zzz-2026-0001",
3548 "ZZZZZ-2026-0001",
3549 "ZZ-2026-0001",
3550 "ZZZ-2026-1",
3551 ] {
3552 assert!(
3553 serde_json::from_value::<GovernanceLogId>(serde_json::json!(
3554 junk
3555 ))
3556 .is_err(),
3557 "{junk}"
3558 );
3559 }
3560 assert_eq!(
3561 GovernanceLogId::from_parts(GovernanceLogPrefix::Unknown, 2026, 1),
3562 None
3563 );
3564 for prefix in GovernanceLogPrefix::ALL {
3567 let want = if matches!(
3568 prefix,
3569 GovernanceLogPrefix::Data | GovernanceLogPrefix::Test
3570 ) {
3571 4
3572 } else {
3573 3
3574 };
3575 assert_eq!(prefix.as_str().len(), want, "{prefix}");
3576 }
3577 let series = GovernanceLogPrefix::ALL.map(|p| p.as_str()).join("|");
3579 assert!(
3580 GOVERNANCE_LOG_ID_PATTERN.contains(&format!("({series})")),
3581 "{GOVERNANCE_LOG_ID_PATTERN}"
3582 );
3583 assert!(
3584 CONTENT_REF_PATTERN.contains(&format!("({series})")),
3585 "{CONTENT_REF_PATTERN}"
3586 );
3587 assert_eq!(
3589 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
3590 "GOV-2026-0006"
3591 );
3592 assert_eq!(
3593 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
3594 "GOV-2026-0006"
3595 );
3596 for bad in [
3597 "",
3598 "GOV-26-0006",
3599 "MOD-2026-0006",
3600 "GOV-2026-0006-1",
3601 "GOV-202X-0006",
3602 "DAT-2026-0001",
3603 "DATAS-2026-0001",
3604 "DATA-2026-00001",
3605 "DATA-2026",
3606 "TES-2026-0001",
3607 "TESTS-2026-0001",
3608 "TEST-2026-00001",
3609 "3f1a0000-0000-0000-0000-000000000000",
3610 ] {
3611 assert!(
3612 bad.parse::<GovernanceLogId>().is_err(),
3613 "{bad:?} should not parse as a GovernanceLogId"
3614 );
3615 }
3616 }
3617
3618 #[test]
3622 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
3623 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3624 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
3625 let back: GovernanceLogId =
3626 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
3627 assert_eq!(back, id);
3628 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
3630 }
3631
3632 #[test]
3635 fn content_ref_round_trips_as_a_bare_string() {
3636 let uuid = Uuid::new_v4();
3637 let content = ContentRef::from(ContentId::from(uuid));
3638 assert_eq!(
3639 serde_json::to_value(&content).unwrap(),
3640 serde_json::json!(uuid.to_string())
3641 );
3642 assert_eq!(
3643 serde_json::from_value::<ContentRef>(serde_json::json!(
3644 uuid.to_string()
3645 ))
3646 .unwrap(),
3647 content
3648 );
3649
3650 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
3651 assert_eq!(
3652 serde_json::to_value(&gov).unwrap(),
3653 serde_json::json!("APP-2026-0003")
3654 );
3655 assert_eq!(
3656 serde_json::from_value::<ContentRef>(serde_json::json!(
3657 "APP-2026-0003"
3658 ))
3659 .unwrap(),
3660 gov
3661 );
3662
3663 assert!(gov.is_governance());
3664 assert!(!content.is_governance());
3665 assert_eq!(gov.kind_str(), "governance");
3666 assert_eq!(content.kind_str(), "content");
3667 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
3668 assert!(content.as_governance().is_none());
3669
3670 assert!("not-an-id".parse::<ContentRef>().is_err());
3672 assert!(
3673 serde_json::from_value::<ContentRef>(serde_json::json!(
3674 "not-an-id"
3675 ))
3676 .is_err()
3677 );
3678 }
3679
3680 #[test]
3682 fn every_readable_id_narrows_to_a_content_ref() {
3683 let uuid = Uuid::new_v4();
3684 for (label, got) in [
3685 ("PostId", ContentRef::from(PostId::from(uuid))),
3686 ("CommentId", ContentRef::from(CommentId::from(uuid))),
3687 ("ContentId", ContentRef::from(ContentId::from(uuid))),
3688 ] {
3689 assert_eq!(
3690 got,
3691 ContentRef::Content(ContentId::from(uuid)),
3692 "{label} -> ContentRef lost the uuid"
3693 );
3694 }
3695 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3696 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
3697 }
3698
3699 #[test]
3702 fn string_shaped_ids_round_trip_through_display() {
3703 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3704 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
3705
3706 let r = ContentRef::Governance(gov);
3707 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3708
3709 let r = ContentRef::Content(ContentId::new());
3710 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3711 }
3712
3713 #[test]
3714 fn post_or_comment_post_variant() {
3715 let inner = PostId::new();
3716 let tagged = PostOrCommentId::Post(inner);
3717 assert!(tagged.is_post());
3718 assert!(!tagged.is_comment());
3719 assert_eq!(tagged.as_post(), Some(inner));
3720 assert_eq!(tagged.as_comment(), None);
3721 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3722 assert_eq!(tagged.kind_str(), "post");
3723 }
3724
3725 #[test]
3726 fn post_or_comment_comment_variant() {
3727 let inner = CommentId::new();
3728 let tagged = PostOrCommentId::Comment(inner);
3729 assert!(tagged.is_comment());
3730 assert!(!tagged.is_post());
3731 assert_eq!(tagged.as_comment(), Some(inner));
3732 assert_eq!(tagged.as_post(), None);
3733 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3734 assert_eq!(tagged.kind_str(), "comment");
3735 }
3736
3737 #[test]
3738 fn post_or_comment_from_conversions() {
3739 let post = PostId::new();
3740 let comment = CommentId::new();
3741 let via_post: PostOrCommentId = post.into();
3742 let via_comment: PostOrCommentId = comment.into();
3743 assert_eq!(via_post, PostOrCommentId::Post(post));
3744 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
3745 }
3746
3747 #[test]
3748 fn post_or_comment_display_is_kind_colon_uuid() {
3749 let post = PostId::new();
3750 let tagged = PostOrCommentId::Post(post);
3751 let rendered = tagged.to_string();
3752 assert!(rendered.starts_with("post:"));
3753 assert!(rendered.contains(&post.to_string()));
3754 }
3755
3756 const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
3759
3760 #[test]
3763 fn a_bad_id_fails_in_plain_words() {
3764 let bad = "7ad26ccd-922f-484a-a37c-51777344a";
3765 let want = format!(
3766 "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
3767 );
3768 assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
3769 let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
3770 .unwrap_err()
3771 .to_string();
3772 assert!(err.starts_with(&want), "{err}");
3773 assert!(!err.contains("group"), "{err}");
3774 let err = serde_json::from_str::<AgentId>("5")
3775 .unwrap_err()
3776 .to_string();
3777 assert!(err.contains("expected AgentId as a UUID string"), "{err}");
3778 let long = "x".repeat(500);
3780 let msg = long.parse::<CommentId>().unwrap_err().to_string();
3781 assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
3782 }
3783
3784 #[test]
3786 fn ids_still_round_trip() {
3787 let id: PostId = FULL.parse().unwrap();
3788 let json = serde_json::to_string(&id).unwrap();
3789 assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
3790 let map: std::collections::HashMap<PostId, i64> =
3791 [(id, 3)].into_iter().collect();
3792 let json = serde_json::to_string(&map).unwrap();
3793 let back: std::collections::HashMap<PostId, i64> =
3794 serde_json::from_str(&json).unwrap();
3795 assert_eq!(back, map);
3796 }
3797
3798 fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
3799 s.parse()
3800 }
3801
3802 #[test]
3803 fn full_and_short_targets_parse() {
3804 let full = target(FULL).unwrap();
3805 assert_eq!(full.full().unwrap().to_string(), FULL);
3806 assert_eq!(full.to_string(), FULL);
3807 let short = target(" 7AD26CCD ").unwrap();
3808 assert!(short.full().is_none());
3809 assert_eq!(short.to_string(), "7ad26ccd");
3810 for variant in [
3812 FULL.replace('-', ""),
3813 FULL.to_uppercase(),
3814 FULL.replace('-', "\u{2011}"),
3815 ] {
3816 assert_eq!(target(&variant).unwrap(), full, "{variant}");
3817 }
3818 }
3819
3820 #[test]
3821 fn a_truncated_uuid_is_an_error_not_its_prefix() {
3822 let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
3823 let msg = err.in_field("reply_to").to_string();
3824 assert!(
3825 msg.starts_with(
3826 "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
3827 ),
3828 "{msg}"
3829 );
3830 for internal in [
3831 "group",
3832 "UUID parsing",
3833 "invalid length",
3834 "invalid character",
3835 ] {
3836 assert!(!msg.contains(internal), "{internal} leaked: {msg}");
3837 }
3838 assert!(target("7ad26cc").is_err());
3839 assert!(target("7ad26ccd9").is_err());
3840 }
3841
3842 #[test]
3843 fn trailing_text_names_the_id_it_starts_with() {
3844 let msg = target(&format!("{FULL} (the reply above)"))
3845 .unwrap_err()
3846 .to_string();
3847 assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
3848 }
3849
3850 #[derive(Debug, serde::Deserialize)]
3851 struct Probe {
3852 #[serde(deserialize_with = "content_target::reply_to")]
3853 #[allow(dead_code)]
3854 reply_to: ContentTarget,
3855 }
3856
3857 #[test]
3858 fn target_errors_name_the_field_for_strings_and_other_types() {
3859 let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
3860 .unwrap_err();
3861 assert!(
3862 err.to_string()
3863 .starts_with("`reply_to` must be a post or comment id"),
3864 "{err}"
3865 );
3866 let err =
3867 serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
3868 assert!(
3869 err.to_string()
3870 .contains("expected `reply_to` to be a post or comment id"),
3871 "{err}"
3872 );
3873 }
3874
3875 #[cfg(feature = "schemars")]
3876 #[test]
3877 fn target_schema_is_inline_and_admits_both_forms() {
3878 let schema = schemars::schema_for!(ContentTarget);
3879 let v = schema.as_value();
3880 assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
3881 assert!(v.get("format").is_none(), "a short id is not format: uuid");
3882 assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
3883 }
3884}