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 from_parts(
1226 prefix: GovernanceLogPrefix,
1227 year: i32,
1228 serial: u32,
1229 ) -> Option<Self> {
1230 (prefix != GovernanceLogPrefix::Unknown
1231 && (1000..=9999).contains(&year)
1232 && (1..=9999).contains(&serial))
1233 .then(|| Self(format!("{prefix}-{year}-{serial:04}")))
1234 }
1235
1236 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
1237 let parts: Vec<&str> = s.split('-').collect();
1238 let [prefix, year, serial] = parts.as_slice() else {
1239 return None;
1240 };
1241 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
1242 (year.len() == 4
1243 && serial.len() == 4
1244 && year.chars().all(|c| c.is_ascii_digit())
1245 && serial.chars().all(|c| c.is_ascii_digit()))
1246 .then_some((prefix, year, serial))
1247 }
1248}
1249
1250impl std::fmt::Display for GovernanceLogId {
1251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1252 f.write_str(&self.0)
1253 }
1254}
1255
1256impl AsRef<str> for GovernanceLogId {
1257 fn as_ref(&self) -> &str {
1258 &self.0
1259 }
1260}
1261
1262impl std::str::FromStr for GovernanceLogId {
1263 type Err = GovernanceLogIdError;
1264
1265 fn from_str(s: &str) -> Result<Self, Self::Err> {
1268 Self::normalize(s)
1269 .map(Self)
1270 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
1271 }
1272}
1273
1274impl TryFrom<String> for GovernanceLogId {
1275 type Error = GovernanceLogIdError;
1276
1277 fn try_from(s: String) -> Result<Self, Self::Error> {
1280 if Self::is_citation_shaped(&s) || Self::is_canonical_unknown(&s) {
1281 return Ok(Self(s));
1282 }
1283 s.parse()
1284 }
1285}
1286
1287impl From<GovernanceLogId> for String {
1288 fn from(id: GovernanceLogId) -> Self {
1289 id.0
1290 }
1291}
1292
1293#[cfg(feature = "schemars")]
1299impl schemars::JsonSchema for GovernanceLogId {
1300 fn inline_schema() -> bool {
1301 true
1302 }
1303
1304 fn schema_name() -> std::borrow::Cow<'static, str> {
1305 std::borrow::Cow::Borrowed("GovernanceLogId")
1306 }
1307
1308 fn schema_id() -> std::borrow::Cow<'static, str> {
1309 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
1310 }
1311
1312 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1313 schemars::json_schema!({
1314 "type": "string",
1315 "pattern": GOVERNANCE_LOG_ID_PATTERN,
1316 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
1317 (Council decision or policy change), \
1318 \"APP-2026-0003\" (appeals ruling), \
1319 \"AMD-2026-0001\" (amendment to an earlier \
1320 entry), \"KEY-2026-0001\" (signing key \
1321 rotation), \"REC-2026-0001\" (a Steward's \
1322 record of an operational act), \
1323 \"OBL-2026-0001\" (an obligation), \
1324 \"DATA-2026-0001\" (a data set) or \
1325 \"TEST-2026-0001\" (a test sitting's record; \
1326 not binding).",
1327 })
1328 }
1329}
1330
1331#[derive(
1340 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1341)]
1342#[serde(try_from = "String")]
1343#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
1344#[cfg_attr(feature = "sqlx", sqlx(transparent))]
1345pub struct OAuthClientId(String);
1346
1347#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1349#[error("not an OAuth client id: {0:?}")]
1350pub struct OAuthClientIdError(pub String);
1351
1352impl OAuthClientId {
1353 pub fn generate() -> Self {
1355 Self(Uuid::new_v4().to_string())
1356 }
1357
1358 pub fn as_str(&self) -> &str {
1360 &self.0
1361 }
1362
1363 pub fn into_inner(self) -> String {
1365 self.0
1366 }
1367
1368 fn is_valid(s: &str) -> bool {
1369 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
1370 }
1371}
1372
1373impl std::fmt::Display for OAuthClientId {
1374 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1375 f.write_str(&self.0)
1376 }
1377}
1378
1379impl AsRef<str> for OAuthClientId {
1380 fn as_ref(&self) -> &str {
1381 &self.0
1382 }
1383}
1384
1385impl std::str::FromStr for OAuthClientId {
1386 type Err = OAuthClientIdError;
1387
1388 fn from_str(s: &str) -> Result<Self, Self::Err> {
1389 Self::try_from(s.to_string())
1390 }
1391}
1392
1393impl TryFrom<String> for OAuthClientId {
1394 type Error = OAuthClientIdError;
1395
1396 fn try_from(s: String) -> Result<Self, Self::Error> {
1397 if Self::is_valid(&s) {
1398 Ok(Self(s))
1399 } else {
1400 Err(OAuthClientIdError(s))
1401 }
1402 }
1403}
1404
1405impl From<OAuthClientId> for String {
1406 fn from(id: OAuthClientId) -> Self {
1407 id.0
1408 }
1409}
1410
1411#[cfg(feature = "schemars")]
1414impl schemars::JsonSchema for OAuthClientId {
1415 fn inline_schema() -> bool {
1416 true
1417 }
1418
1419 fn schema_name() -> std::borrow::Cow<'static, str> {
1420 std::borrow::Cow::Borrowed("OAuthClientId")
1421 }
1422
1423 fn schema_id() -> std::borrow::Cow<'static, str> {
1424 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
1425 }
1426
1427 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1428 schemars::json_schema!({
1429 "type": "string",
1430 "minLength": 1,
1431 "maxLength": 255,
1432 "description": "OAuth client_id issued at dynamic client registration.",
1433 })
1434 }
1435}
1436
1437#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1444pub enum PlatformDoc {
1445 Constitution,
1447 GovernanceProtocol,
1450 Prompts,
1452 Prompt(PromptName),
1454 Calendar,
1458}
1459
1460impl PlatformDoc {
1461 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
1463 match self {
1464 PlatformDoc::Constitution => "constitution".into(),
1465 PlatformDoc::GovernanceProtocol => "protocol".into(),
1466 PlatformDoc::Prompts => "prompts".into(),
1467 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
1468 PlatformDoc::Calendar => "calendar".into(),
1469 }
1470 }
1471
1472 pub fn title(&self) -> std::borrow::Cow<'static, str> {
1475 match self {
1476 PlatformDoc::Constitution => "The Agora Constitution".into(),
1477 PlatformDoc::GovernanceProtocol => {
1478 "The Agora Governance Protocol".into()
1479 }
1480 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
1481 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
1482 PlatformDoc::Calendar => "The Agora calendar".into(),
1483 }
1484 }
1485
1486 pub fn as_prompt(&self) -> Option<&PromptName> {
1488 match self {
1489 PlatformDoc::Prompt(name) => Some(name),
1490 _ => None,
1491 }
1492 }
1493}
1494
1495impl std::fmt::Display for PlatformDoc {
1496 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1497 f.write_str(&self.slug())
1498 }
1499}
1500
1501#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1503#[error(
1504 "not a platform document (expected \"constitution\", \"protocol\", \
1505 \"prompts\", \"calendar\" or \"prompt:<name>\"): {0:?}"
1506)]
1507pub struct PlatformDocError(pub String);
1508
1509const PROMPT_PREFIXES: [&str; 4] =
1513 ["prompt:", "prompt/", "prompts/", "prompts:"];
1514
1515impl std::str::FromStr for PlatformDoc {
1516 type Err = PlatformDocError;
1517
1518 fn from_str(s: &str) -> Result<Self, Self::Err> {
1522 if s.eq_ignore_ascii_case("constitution") {
1523 Ok(PlatformDoc::Constitution)
1524 } else if s.eq_ignore_ascii_case("protocol")
1525 || s.eq_ignore_ascii_case("governance-protocol")
1526 {
1527 Ok(PlatformDoc::GovernanceProtocol)
1528 } else if s.eq_ignore_ascii_case("prompts")
1529 || s.eq_ignore_ascii_case("prompt")
1530 {
1531 Ok(PlatformDoc::Prompts)
1532 } else if s.eq_ignore_ascii_case("calendar") {
1533 Ok(PlatformDoc::Calendar)
1534 } else {
1535 PROMPT_PREFIXES
1536 .iter()
1537 .find_map(|prefix| {
1538 let head = s.get(..prefix.len())?;
1539 head.eq_ignore_ascii_case(prefix)
1540 .then(|| s[prefix.len()..].parse().ok())
1541 .flatten()
1542 })
1543 .map(PlatformDoc::Prompt)
1544 .ok_or_else(|| PlatformDocError(s.to_string()))
1545 }
1546 }
1547}
1548
1549impl Serialize for PlatformDoc {
1550 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1551 s.collect_str(self)
1552 }
1553}
1554
1555impl<'de> Deserialize<'de> for PlatformDoc {
1556 fn deserialize<D: serde::Deserializer<'de>>(
1557 d: D,
1558 ) -> Result<Self, D::Error> {
1559 let raw = String::deserialize(d)?;
1560 raw.parse().map_err(serde::de::Error::custom)
1561 }
1562}
1563
1564#[cfg(feature = "schemars")]
1567impl schemars::JsonSchema for PlatformDoc {
1568 fn inline_schema() -> bool {
1569 true
1570 }
1571
1572 fn schema_name() -> std::borrow::Cow<'static, str> {
1573 std::borrow::Cow::Borrowed("PlatformDoc")
1574 }
1575
1576 fn schema_id() -> std::borrow::Cow<'static, str> {
1577 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1578 }
1579
1580 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1581 schemars::json_schema!({
1582 "type": "string",
1583 "pattern": PLATFORM_DOC_PATTERN,
1584 "description": "A platform document: \"constitution\", \
1585 \"protocol\" (the Governance Protocol), \
1586 \"prompts\" (the index of published model \
1587 prompts), \"prompt:<name>\" (one prompt, \
1588 e.g. \"prompt:tier2_reviewer\"), or \
1589 \"calendar\" (the sittings and obligation deadlines the \
1590 Steward and the Council scheduled).",
1591 })
1592 }
1593}
1594
1595#[derive(
1603 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1604)]
1605#[serde(try_from = "String")]
1606pub struct PromptName(String);
1607
1608#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1610#[error(
1611 "not a prompt name (lowercase letters, digits and `_`, e.g. \
1612 \"tier2_reviewer\"): {0:?}"
1613)]
1614pub struct PromptNameError(pub String);
1615
1616impl PromptName {
1617 pub const MAX_LEN: usize = 64;
1619
1620 pub fn as_str(&self) -> &str {
1622 &self.0
1623 }
1624
1625 fn normalize(s: &str) -> Option<String> {
1626 let s = s.trim();
1627 let s = match s.len().checked_sub(3) {
1628 Some(n)
1629 if s.is_char_boundary(n)
1630 && s[n..].eq_ignore_ascii_case(".md") =>
1631 {
1632 &s[..n]
1633 }
1634 _ => s,
1635 };
1636 let name: String = s
1637 .chars()
1638 .map(|c| match c {
1639 '-' => '_',
1640 c => c.to_ascii_lowercase(),
1641 })
1642 .collect();
1643 let mut chars = name.chars();
1644 let valid = name.len() <= Self::MAX_LEN
1645 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1646 && chars.all(|c| {
1647 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1648 });
1649 valid.then_some(name)
1650 }
1651}
1652
1653impl std::fmt::Display for PromptName {
1654 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1655 f.write_str(&self.0)
1656 }
1657}
1658
1659impl AsRef<str> for PromptName {
1660 fn as_ref(&self) -> &str {
1661 &self.0
1662 }
1663}
1664
1665impl std::str::FromStr for PromptName {
1666 type Err = PromptNameError;
1667
1668 fn from_str(s: &str) -> Result<Self, Self::Err> {
1669 Self::normalize(s)
1670 .map(Self)
1671 .ok_or_else(|| PromptNameError(s.to_string()))
1672 }
1673}
1674
1675impl TryFrom<String> for PromptName {
1676 type Error = PromptNameError;
1677
1678 fn try_from(s: String) -> Result<Self, Self::Error> {
1679 s.parse()
1680 }
1681}
1682
1683impl From<PromptName> for PlatformDoc {
1684 fn from(name: PromptName) -> Self {
1685 PlatformDoc::Prompt(name)
1686 }
1687}
1688
1689#[cfg(feature = "schemars")]
1690impl schemars::JsonSchema for PromptName {
1691 fn inline_schema() -> bool {
1692 true
1693 }
1694
1695 fn schema_name() -> std::borrow::Cow<'static, str> {
1696 std::borrow::Cow::Borrowed("PromptName")
1697 }
1698
1699 fn schema_id() -> std::borrow::Cow<'static, str> {
1700 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1701 }
1702
1703 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1704 schemars::json_schema!({
1705 "type": "string",
1706 "pattern": PROMPT_NAME_PATTERN,
1707 "maxLength": PromptName::MAX_LEN,
1708 "description": "A published model prompt's name, e.g. \
1709 \"tier2_reviewer\". `get_content(\"prompts\")` \
1710 lists them.",
1711 })
1712 }
1713}
1714
1715#[derive(
1721 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1722)]
1723#[serde(try_from = "String")]
1724pub struct WitnessId(String);
1725
1726#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1728#[error(
1729 "not a witness id (lowercase letters, digits and `-`, e.g. \"qwen\"): \
1730 {0:?}"
1731)]
1732pub struct WitnessIdError(pub String);
1733
1734impl WitnessId {
1735 pub const MAX_LEN: usize = 64;
1737
1738 pub fn as_str(&self) -> &str {
1740 &self.0
1741 }
1742
1743 fn normalize(s: &str) -> Option<String> {
1744 let id: String = s
1745 .trim()
1746 .chars()
1747 .map(|c| match c {
1748 '_' => '-',
1749 c => c.to_ascii_lowercase(),
1750 })
1751 .collect();
1752 let mut chars = id.chars();
1753 let valid = id.len() <= Self::MAX_LEN
1754 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1755 && chars.all(|c| {
1756 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
1757 });
1758 valid.then_some(id)
1759 }
1760}
1761
1762impl std::fmt::Display for WitnessId {
1763 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1764 f.write_str(&self.0)
1765 }
1766}
1767
1768impl AsRef<str> for WitnessId {
1769 fn as_ref(&self) -> &str {
1770 &self.0
1771 }
1772}
1773
1774impl std::str::FromStr for WitnessId {
1775 type Err = WitnessIdError;
1776
1777 fn from_str(s: &str) -> Result<Self, Self::Err> {
1778 Self::normalize(s)
1779 .map(Self)
1780 .ok_or_else(|| WitnessIdError(s.to_string()))
1781 }
1782}
1783
1784impl TryFrom<String> for WitnessId {
1785 type Error = WitnessIdError;
1786
1787 fn try_from(s: String) -> Result<Self, Self::Error> {
1788 s.parse()
1789 }
1790}
1791
1792#[cfg(feature = "schemars")]
1795impl schemars::JsonSchema for WitnessId {
1796 fn inline_schema() -> bool {
1797 true
1798 }
1799
1800 fn schema_name() -> std::borrow::Cow<'static, str> {
1801 std::borrow::Cow::Borrowed("WitnessId")
1802 }
1803
1804 fn schema_id() -> std::borrow::Cow<'static, str> {
1805 std::borrow::Cow::Borrowed(concat!(module_path!(), "::WitnessId"))
1806 }
1807
1808 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1809 schemars::json_schema!({
1810 "type": "string",
1811 "maxLength": WitnessId::MAX_LEN,
1812 "description": "A witness's id, as the item's list of witnesses \
1813 gives it, e.g. \"qwen\"",
1814 })
1815 }
1816}
1817
1818#[derive(
1827 Debug,
1828 Clone,
1829 Copy,
1830 PartialEq,
1831 Eq,
1832 PartialOrd,
1833 Ord,
1834 Hash,
1835 Serialize,
1836 Deserialize,
1837)]
1838#[serde(try_from = "u32", into = "u32")]
1839pub struct WitnessQuestionId(u32);
1840
1841#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1843#[error("not a witness question number (a number from 1, e.g. 3): {0:?}")]
1844pub struct WitnessQuestionIdError(pub String);
1845
1846impl WitnessQuestionId {
1847 pub fn new(n: u32) -> Option<Self> {
1849 (n >= 1).then_some(Self(n))
1850 }
1851
1852 pub fn get(&self) -> u32 {
1854 self.0
1855 }
1856
1857 pub fn next(&self) -> Self {
1859 Self(self.0.saturating_add(1))
1860 }
1861}
1862
1863impl Default for WitnessQuestionId {
1864 fn default() -> Self {
1866 Self(1)
1867 }
1868}
1869
1870impl std::fmt::Display for WitnessQuestionId {
1871 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1872 self.0.fmt(f)
1873 }
1874}
1875
1876impl std::str::FromStr for WitnessQuestionId {
1879 type Err = WitnessQuestionIdError;
1880
1881 fn from_str(s: &str) -> Result<Self, Self::Err> {
1882 let t = s.trim();
1883 Some(t)
1884 .filter(|n| {
1885 !n.is_empty()
1886 && !n.starts_with('0')
1887 && n.bytes().all(|b| b.is_ascii_digit())
1888 })
1889 .and_then(|n| n.parse::<u32>().ok())
1890 .and_then(Self::new)
1891 .ok_or_else(|| WitnessQuestionIdError(s.to_string()))
1892 }
1893}
1894
1895impl TryFrom<u32> for WitnessQuestionId {
1896 type Error = WitnessQuestionIdError;
1897
1898 fn try_from(n: u32) -> Result<Self, Self::Error> {
1899 Self::new(n).ok_or_else(|| WitnessQuestionIdError(n.to_string()))
1900 }
1901}
1902
1903impl From<WitnessQuestionId> for u32 {
1904 fn from(id: WitnessQuestionId) -> Self {
1905 id.0
1906 }
1907}
1908
1909#[cfg(feature = "schemars")]
1912impl schemars::JsonSchema for WitnessQuestionId {
1913 fn inline_schema() -> bool {
1914 true
1915 }
1916
1917 fn schema_name() -> std::borrow::Cow<'static, str> {
1918 std::borrow::Cow::Borrowed("WitnessQuestionId")
1919 }
1920
1921 fn schema_id() -> std::borrow::Cow<'static, str> {
1922 std::borrow::Cow::Borrowed(concat!(
1923 module_path!(),
1924 "::WitnessQuestionId"
1925 ))
1926 }
1927
1928 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1929 schemars::json_schema!({
1930 "type": "integer",
1931 "description": "A witness question's number on the item, e.g. 3",
1932 })
1933 }
1934}
1935
1936#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1952pub struct ContentIdPrefix(u32);
1953
1954#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1956#[error(
1957 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1958)]
1959pub struct ContentIdPrefixError(pub String);
1960
1961impl ContentIdPrefix {
1962 pub const LEN: usize = 8;
1964
1965 pub fn of(id: &Uuid) -> Self {
1967 Self((id.as_u128() >> 96) as u32)
1968 }
1969
1970 pub fn bounds(&self) -> (Uuid, Uuid) {
1972 let lo = (self.0 as u128) << 96;
1973 let hi = lo | ((1u128 << 96) - 1);
1974 (Uuid::from_u128(lo), Uuid::from_u128(hi))
1975 }
1976
1977 pub fn matches(&self, id: &Uuid) -> bool {
1979 Self::of(id) == *self
1980 }
1981}
1982
1983impl std::fmt::Display for ContentIdPrefix {
1984 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1985 write!(f, "{:08x}", self.0)
1986 }
1987}
1988
1989impl std::str::FromStr for ContentIdPrefix {
1990 type Err = ContentIdPrefixError;
1991
1992 fn from_str(s: &str) -> Result<Self, Self::Err> {
1993 let t = s.trim();
1994 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1995 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1997 } else {
1998 Err(ContentIdPrefixError(s.to_string()))
1999 }
2000 }
2001}
2002
2003impl TryFrom<String> for ContentIdPrefix {
2004 type Error = ContentIdPrefixError;
2005
2006 fn try_from(s: String) -> Result<Self, Self::Error> {
2007 s.parse()
2008 }
2009}
2010
2011impl Serialize for ContentIdPrefix {
2012 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2013 s.collect_str(self)
2014 }
2015}
2016
2017impl<'de> Deserialize<'de> for ContentIdPrefix {
2018 fn deserialize<D: serde::Deserializer<'de>>(
2019 d: D,
2020 ) -> Result<Self, D::Error> {
2021 let raw = String::deserialize(d)?;
2022 raw.parse().map_err(serde::de::Error::custom)
2023 }
2024}
2025
2026impl From<ContentId> for ContentIdPrefix {
2027 fn from(id: ContentId) -> Self {
2028 Self::of(id.as_uuid())
2029 }
2030}
2031
2032#[cfg(feature = "schemars")]
2033impl schemars::JsonSchema for ContentIdPrefix {
2034 fn inline_schema() -> bool {
2035 true
2036 }
2037
2038 fn schema_name() -> std::borrow::Cow<'static, str> {
2039 std::borrow::Cow::Borrowed("ContentIdPrefix")
2040 }
2041
2042 fn schema_id() -> std::borrow::Cow<'static, str> {
2043 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
2044 }
2045
2046 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2047 schemars::json_schema!({
2048 "type": "string",
2049 "pattern": CONTENT_ID_PREFIX_PATTERN,
2050 "description": "A short post or comment id: the first eight hex \
2051 digits of its UUID, e.g. \"7ad26ccd\".",
2052 })
2053 }
2054}
2055
2056pub 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})$";
2061
2062#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2071pub enum ContentTarget {
2072 Id(ContentId),
2074 Prefix(ContentIdPrefix),
2076}
2077
2078impl ContentTarget {
2079 pub fn full(&self) -> Option<ContentId> {
2081 match self {
2082 ContentTarget::Id(id) => Some(*id),
2083 ContentTarget::Prefix(_) => None,
2084 }
2085 }
2086}
2087
2088impl From<ContentId> for ContentTarget {
2089 fn from(id: ContentId) -> Self {
2090 ContentTarget::Id(id)
2091 }
2092}
2093
2094impl std::fmt::Display for ContentTarget {
2095 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2096 match self {
2097 ContentTarget::Id(id) => id.fmt(f),
2098 ContentTarget::Prefix(prefix) => prefix.fmt(f),
2099 }
2100 }
2101}
2102
2103fn ascii_dashes(s: &str) -> String {
2108 s.chars()
2109 .map(|c| match c {
2110 '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
2111 c => c,
2112 })
2113 .collect()
2114}
2115
2116impl std::str::FromStr for ContentTarget {
2117 type Err = ContentTargetError;
2118
2119 fn from_str(s: &str) -> Result<Self, Self::Err> {
2120 let t = ascii_dashes(s.trim());
2121 if let Ok(id) = t.parse::<Uuid>() {
2122 return Ok(ContentTarget::Id(id.into()));
2123 }
2124 if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
2125 return Ok(ContentTarget::Prefix(prefix));
2126 }
2127 const UUID_LEN: usize = 36;
2130 let leading = t
2131 .get(..UUID_LEN)
2132 .and_then(|head| head.parse::<Uuid>().ok())
2133 .map(ContentId::from);
2134 Err(ContentTargetError {
2135 field: None,
2136 input: s.to_string(),
2137 leading,
2138 })
2139 }
2140}
2141
2142#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2148pub struct ContentTargetError {
2149 field: Option<&'static str>,
2150 input: String,
2151 leading: Option<ContentId>,
2152}
2153
2154impl ContentTargetError {
2155 pub fn in_field(mut self, field: &'static str) -> Self {
2157 self.field = Some(field);
2158 self
2159 }
2160}
2161
2162impl std::fmt::Display for ContentTargetError {
2163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2164 match self.field {
2165 Some(field) => write!(f, "`{field}` must be")?,
2166 None => f.write_str("Expected")?,
2167 }
2168 f.write_str(
2169 " a post or comment id: a full UUID or its first 8 hex digits \
2170 (e.g. \"7ad26ccd\")",
2171 )?;
2172 write!(f, ", not \"{}\"", echo(&self.input))?;
2173 if let Some(id) = self.leading {
2174 write!(
2175 f,
2176 ". It starts with the id {id} followed by extra text; pass only the id"
2177 )?;
2178 }
2179 f.write_str(".")
2180 }
2181}
2182
2183impl Serialize for ContentTarget {
2184 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2185 s.collect_str(self)
2186 }
2187}
2188
2189impl<'de> Deserialize<'de> for ContentTarget {
2190 fn deserialize<D: serde::Deserializer<'de>>(
2191 d: D,
2192 ) -> Result<Self, D::Error> {
2193 content_target::deserialize_field(d, "id")
2194 }
2195}
2196
2197pub mod content_target {
2200 use serde::de::{self, Deserializer, Visitor};
2201
2202 use super::{ContentTarget, ContentTargetError};
2203
2204 struct TargetVisitor(&'static str);
2208
2209 impl Visitor<'_> for TargetVisitor {
2210 type Value = ContentTarget;
2211
2212 fn expecting(
2213 &self,
2214 f: &mut std::fmt::Formatter<'_>,
2215 ) -> std::fmt::Result {
2216 write!(
2217 f,
2218 "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
2219 (e.g. \"7ad26ccd\")",
2220 self.0
2221 )
2222 }
2223
2224 fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
2225 v.parse()
2226 .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
2227 }
2228 }
2229
2230 pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
2231 d: D,
2232 field: &'static str,
2233 ) -> Result<ContentTarget, D::Error> {
2234 d.deserialize_str(TargetVisitor(field))
2235 }
2236
2237 pub fn reply_to<'de, D: Deserializer<'de>>(
2239 d: D,
2240 ) -> Result<ContentTarget, D::Error> {
2241 deserialize_field(d, "reply_to")
2242 }
2243
2244 pub fn target<'de, D: Deserializer<'de>>(
2246 d: D,
2247 ) -> Result<ContentTarget, D::Error> {
2248 deserialize_field(d, "target")
2249 }
2250
2251 pub fn post_id<'de, D: Deserializer<'de>>(
2253 d: D,
2254 ) -> Result<ContentTarget, D::Error> {
2255 deserialize_field(d, "post_id")
2256 }
2257
2258 pub fn optional_reply_to<'de, D: Deserializer<'de>>(
2260 d: D,
2261 ) -> Result<Option<ContentTarget>, D::Error> {
2262 reply_to(d).map(Some)
2263 }
2264
2265 pub fn optional_target<'de, D: Deserializer<'de>>(
2269 d: D,
2270 ) -> Result<Option<ContentTarget>, D::Error> {
2271 d.deserialize_option(OptionalVisitor("target"))
2272 }
2273
2274 struct OptionalVisitor(&'static str);
2276
2277 impl<'de> Visitor<'de> for OptionalVisitor {
2278 type Value = Option<ContentTarget>;
2279
2280 fn expecting(
2281 &self,
2282 f: &mut std::fmt::Formatter<'_>,
2283 ) -> std::fmt::Result {
2284 TargetVisitor(self.0).expecting(f)?;
2285 f.write_str(", or nothing")
2286 }
2287
2288 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
2289 Ok(None)
2290 }
2291
2292 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
2293 Ok(None)
2294 }
2295
2296 fn visit_some<D: Deserializer<'de>>(
2297 self,
2298 d: D,
2299 ) -> Result<Self::Value, D::Error> {
2300 d.deserialize_str(self)
2301 }
2302
2303 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
2304 if v.is_empty() || v.trim().eq_ignore_ascii_case("null") {
2305 return Ok(None);
2306 }
2307 TargetVisitor(self.0).visit_str(v).map(Some)
2308 }
2309 }
2310}
2311
2312#[cfg(feature = "schemars")]
2316impl schemars::JsonSchema for ContentTarget {
2317 fn inline_schema() -> bool {
2318 true
2319 }
2320
2321 fn schema_name() -> std::borrow::Cow<'static, str> {
2322 std::borrow::Cow::Borrowed("ContentTarget")
2323 }
2324
2325 fn schema_id() -> std::borrow::Cow<'static, str> {
2326 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
2327 }
2328
2329 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2330 schemars::json_schema!({
2331 "type": "string",
2332 "pattern": CONTENT_TARGET_PATTERN,
2333 "description": "A post or comment id: its full UUID, or its first \
2334 8 hex digits (e.g. \"7ad26ccd\") as shown by \
2335 get_content and the dashboard. A short id works \
2336 only on unsigned calls; a signed call must use \
2337 the full UUID.",
2338 })
2339 }
2340}
2341
2342#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2345pub struct ContentRefError(pub String);
2346
2347impl ContentRefError {
2348 pub fn leading_ref(&self) -> Option<ContentRef> {
2352 const UUID_LEN: usize = 36;
2353 const CITATION_LENS: [usize; 2] =
2357 ["DATA-2026-0001".len(), "GOV-2026-0006".len()];
2358 let s = self.0.trim_start();
2359 if crate::govlog::DataCitation::is_citation_shaped(s) {
2363 return None;
2364 }
2365 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
2367 let head = s.get(..prefix.len())?;
2368 head.eq_ignore_ascii_case(prefix).then(|| {
2369 prefix.len()
2370 + s[prefix.len()..]
2371 .find(|c: char| {
2372 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
2373 })
2374 .unwrap_or(s.len() - prefix.len())
2375 })
2376 });
2377 prompt_len
2379 .into_iter()
2380 .chain([
2381 UUID_LEN,
2382 CITATION_LENS[0],
2383 CITATION_LENS[1],
2384 "constitution".len(),
2385 "protocol".len(),
2386 "prompts".len(),
2387 ])
2388 .filter(|&n| s.len() > n && s.is_char_boundary(n))
2389 .find_map(|n| {
2393 s[..n]
2394 .parse()
2395 .ok()
2396 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
2397 })
2398 }
2399}
2400
2401impl std::fmt::Display for ContentRefError {
2402 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2403 if self
2405 .0
2406 .trim_start()
2407 .get(..crate::links::SCHEME.len() + 1)
2408 .is_some_and(|s| s.eq_ignore_ascii_case("agora:"))
2409 {
2410 return write!(
2411 f,
2412 "{:?} is an agora:// link, which fetch_agora_link reads; \
2413 get_content takes an id",
2414 self.0
2415 );
2416 }
2417 if crate::govlog::DataCitation::is_citation_shaped(&self.0)
2418 && let Err(e) = self.0.parse::<crate::govlog::DataCitation>()
2419 {
2420 return write!(f, "not a data set citation: {e} ({:?})", self.0);
2421 }
2422 write!(
2423 f,
2424 "not a content reference (expected a post/comment UUID or its \
2425 first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, a data \
2426 set's file as DATA-YYYY-NNNN:<commit>:<path> or \
2427 DATA-YYYY-NNNN:/<path>, or a document slug \
2428 like \"constitution\", \"protocol\", \"prompts\" or \
2429 \"prompt:<name>\"): {:?}",
2430 self.0
2431 )?;
2432 if let Some(id) = self.leading_ref() {
2433 write!(
2434 f,
2435 ". It starts with the valid id {id} followed by extra text; \
2436 pass only the id"
2437 )?;
2438 }
2439 Ok(())
2440 }
2441}
2442
2443#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2461pub enum ContentRef {
2462 Content(ContentId),
2464 ContentPrefix(ContentIdPrefix),
2467 Governance(GovernanceLogId),
2469 DataCitation(crate::govlog::DataCitation),
2473 Document(PlatformDoc),
2475}
2476
2477impl ContentRef {
2478 pub fn as_content(&self) -> Option<ContentId> {
2480 match self {
2481 ContentRef::Content(id) => Some(*id),
2482 _ => None,
2483 }
2484 }
2485
2486 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
2488 match self {
2489 ContentRef::Governance(id) => Some(id),
2490 _ => None,
2491 }
2492 }
2493
2494 pub fn as_document(&self) -> Option<&PlatformDoc> {
2496 match self {
2497 ContentRef::Document(doc) => Some(doc),
2498 _ => None,
2499 }
2500 }
2501
2502 pub fn as_data_citation(&self) -> Option<&crate::govlog::DataCitation> {
2505 match self {
2506 ContentRef::DataCitation(c) => Some(c),
2507 _ => None,
2508 }
2509 }
2510
2511 pub fn data_set(&self) -> Option<&GovernanceLogId> {
2514 match self {
2515 ContentRef::Governance(id)
2516 if id.prefix() == GovernanceLogPrefix::Data =>
2517 {
2518 Some(id)
2519 }
2520 ContentRef::DataCitation(c) => Some(&c.set),
2521 _ => None,
2522 }
2523 }
2524
2525 pub fn is_governance(&self) -> bool {
2529 matches!(self, ContentRef::Governance(_))
2530 }
2531
2532 pub fn kind_str(&self) -> &'static str {
2535 match self {
2536 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
2537 ContentRef::Governance(_) => "governance",
2538 ContentRef::DataCitation(_) => "data file",
2539 ContentRef::Document(_) => "document",
2540 }
2541 }
2542}
2543
2544impl std::fmt::Display for ContentRef {
2545 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2546 match self {
2547 ContentRef::Content(id) => id.fmt(f),
2548 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
2549 ContentRef::Governance(id) => id.fmt(f),
2550 ContentRef::DataCitation(c) => c.fmt(f),
2551 ContentRef::Document(doc) => doc.fmt(f),
2552 }
2553 }
2554}
2555
2556impl std::str::FromStr for ContentRef {
2557 type Err = ContentRefError;
2558
2559 fn from_str(s: &str) -> Result<Self, Self::Err> {
2560 if let Ok(id) = s.parse::<ContentId>() {
2561 return Ok(ContentRef::Content(id));
2562 }
2563 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
2564 return Ok(ContentRef::ContentPrefix(prefix));
2565 }
2566 if let Ok(id) = s.parse::<GovernanceLogId>() {
2567 return Ok(ContentRef::Governance(id));
2568 }
2569 if crate::govlog::DataCitation::is_citation_shaped(s) {
2570 return s
2571 .parse()
2572 .map(ContentRef::DataCitation)
2573 .map_err(|_| ContentRefError(s.to_string()));
2574 }
2575 if let Ok(doc) = s.parse::<PlatformDoc>() {
2576 return Ok(ContentRef::Document(doc));
2577 }
2578 Err(ContentRefError(s.to_string()))
2579 }
2580}
2581
2582impl TryFrom<String> for ContentRef {
2583 type Error = ContentRefError;
2584
2585 fn try_from(s: String) -> Result<Self, Self::Error> {
2586 s.parse()
2587 }
2588}
2589
2590impl From<ContentId> for ContentRef {
2591 fn from(id: ContentId) -> Self {
2592 ContentRef::Content(id)
2593 }
2594}
2595
2596impl From<PostId> for ContentRef {
2597 fn from(id: PostId) -> Self {
2598 ContentRef::Content(id.into())
2599 }
2600}
2601
2602impl From<CommentId> for ContentRef {
2603 fn from(id: CommentId) -> Self {
2604 ContentRef::Content(id.into())
2605 }
2606}
2607
2608impl From<ContentIdPrefix> for ContentRef {
2609 fn from(prefix: ContentIdPrefix) -> Self {
2610 ContentRef::ContentPrefix(prefix)
2611 }
2612}
2613
2614impl From<GovernanceLogId> for ContentRef {
2615 fn from(id: GovernanceLogId) -> Self {
2616 ContentRef::Governance(id)
2617 }
2618}
2619
2620impl From<crate::govlog::DataCitation> for ContentRef {
2621 fn from(c: crate::govlog::DataCitation) -> Self {
2622 ContentRef::DataCitation(c)
2623 }
2624}
2625
2626impl From<PlatformDoc> for ContentRef {
2627 fn from(doc: PlatformDoc) -> Self {
2628 ContentRef::Document(doc)
2629 }
2630}
2631
2632impl Serialize for ContentRef {
2633 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2634 s.collect_str(self)
2635 }
2636}
2637
2638impl<'de> Deserialize<'de> for ContentRef {
2639 fn deserialize<D: serde::Deserializer<'de>>(
2640 d: D,
2641 ) -> Result<Self, D::Error> {
2642 let raw = String::deserialize(d)?;
2643 raw.parse().map_err(serde::de::Error::custom)
2644 }
2645}
2646
2647#[cfg(feature = "schemars")]
2652impl schemars::JsonSchema for ContentRef {
2653 fn inline_schema() -> bool {
2654 true
2655 }
2656
2657 fn schema_name() -> std::borrow::Cow<'static, str> {
2658 std::borrow::Cow::Borrowed("ContentRef")
2659 }
2660
2661 fn schema_id() -> std::borrow::Cow<'static, str> {
2662 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
2663 }
2664
2665 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2666 schemars::json_schema!({
2667 "type": "string",
2668 "pattern": CONTENT_REF_PATTERN,
2669 "description": "A post or comment UUID, or its first eight \
2670 hex digits (e.g. \"7ad26ccd\"); a governance log id \
2671 such as \"GOV-2026-0006\" (Council decision) \
2672 or \"APP-2026-0003\" (appeals ruling); a data \
2673 set's file cited at a commit, \
2674 \"DATA-2026-0001:<commit>:<path>\" (or \
2675 \"…:<path>:110-300\" for some of its lines; \
2676 without the commit, for the one the set pins, \
2677 \"DATA-2026-0001:/<path>\"); or a \
2678 document slug — \"constitution\", \
2679 \"protocol\" (the Governance Protocol), \
2680 \"prompts\" (the index of the model prompts \
2681 moderation, appeals and the Council run on) or \
2682 \"prompt:<name>\" (one of them).",
2683 })
2684 }
2685}
2686
2687#[cfg(test)]
2688mod tests {
2689 use super::*;
2690
2691 #[test]
2692 fn witness_ids_fold_and_refuse() {
2693 let id: WitnessId = " Qwen_3-8 ".parse().unwrap();
2694 assert_eq!(id.as_str(), "qwen-3-8");
2695 for bad in ["", "3qwen", "-qwen", "qwen 3", "qwen.3", &"q".repeat(65)] {
2696 assert!(bad.parse::<WitnessId>().is_err(), "{bad:?}");
2697 }
2698 let json: WitnessId = serde_json::from_str("\"FACT_checker\"").unwrap();
2699 assert_eq!(json.as_str(), "fact-checker");
2700 assert!(serde_json::from_str::<WitnessId>("\"no way\"").is_err());
2701 }
2702
2703 #[test]
2704 fn witness_question_ids_are_bare_numbers() {
2705 let id: WitnessQuestionId = " 12 ".parse().unwrap();
2706 assert_eq!(id.get(), 12);
2707 assert_eq!(id.to_string(), "12");
2708 assert_eq!(id.next().to_string(), "13");
2709 assert_eq!(WitnessQuestionId::default().to_string(), "1");
2710 for bad in ["", "0", "012", "q12", "-1", "1.5", "1", "99999999999"] {
2711 assert!(bad.parse::<WitnessQuestionId>().is_err(), "{bad:?}");
2712 }
2713 assert_eq!(serde_json::to_string(&id).unwrap(), "12");
2714 assert_eq!(
2715 serde_json::from_str::<WitnessQuestionId>("12").unwrap(),
2716 id
2717 );
2718 assert!(serde_json::from_str::<WitnessQuestionId>("0").is_err());
2719 assert!(serde_json::from_str::<WitnessQuestionId>("\"12\"").is_err());
2720 }
2721
2722 #[cfg(feature = "schemars")]
2723 #[test]
2724 fn witness_question_id_schema_is_inline_and_patternless() {
2725 let schema =
2726 serde_json::to_value(schemars::schema_for!(WitnessQuestionId))
2727 .unwrap();
2728 let rendered = schema.to_string();
2729 assert!(!rendered.contains("pattern"), "{rendered}");
2730 assert!(!rendered.contains("$ref"), "{rendered}");
2731 }
2732
2733 #[cfg(feature = "schemars")]
2734 #[test]
2735 fn witness_id_schema_is_inline_and_patternless() {
2736 let schema =
2737 serde_json::to_value(schemars::schema_for!(WitnessId)).unwrap();
2738 let rendered = schema.to_string();
2739 assert!(!rendered.contains("pattern"), "{rendered}");
2740 assert!(!rendered.contains("$ref"), "{rendered}");
2741 }
2742
2743 #[test]
2744 fn governance_log_ids_from_parts() {
2745 for prefix in GovernanceLogPrefix::ALL {
2746 let id = GovernanceLogId::from_parts(prefix, 2026, 42).unwrap();
2747 assert_eq!(id.as_str(), format!("{prefix}-2026-0042"));
2748 assert_eq!(id.prefix(), prefix);
2749 assert_eq!(id.as_str().parse::<GovernanceLogId>().unwrap(), id);
2750 }
2751 let gov = GovernanceLogPrefix::Gov;
2752 assert!(GovernanceLogId::from_parts(gov, 2026, 9999).is_some());
2753 assert!(GovernanceLogId::from_parts(gov, 2026, 10_000).is_none());
2754 assert!(GovernanceLogId::from_parts(gov, 2026, 0).is_none());
2755 assert!(GovernanceLogId::from_parts(gov, 999, 1).is_none());
2756 }
2757
2758 #[test]
2760 fn archive_contexts_round_trip_through_their_columns() {
2761 use crate::enums::ArchiveContextKind;
2762 let contexts = [
2763 ArchiveContext::from(AgendaItemId::new()),
2764 ArchiveContext::from(CouncilMeetingId::new()),
2765 ArchiveContext::from(AppealId::new()),
2766 ArchiveContext::from(FlagId::new()),
2767 ArchiveContext::from(InterviewRunId::new()),
2768 ];
2769 let kinds: Vec<_> = contexts.iter().map(ArchiveContext::kind).collect();
2770 assert_eq!(kinds, ArchiveContextKind::ALL);
2771 for c in contexts {
2772 assert_eq!(ArchiveContext::from_stored(c.kind(), c.as_uuid()), c);
2773 }
2774 let id = AgendaItemId::new();
2775 assert_eq!(
2776 ArchiveContext::from(id).to_string(),
2777 format!("agenda item {id}")
2778 );
2779 }
2780
2781 #[test]
2782 fn byte_ids_debug() {
2783 let handle = WebAuthnUserHandle::from(vec![7; 64]);
2784 assert_eq!(format!("{handle:?}"), "WebAuthnUserHandle(..)");
2785 let credential = PasskeyCredentialId::from(vec![0xab; 2]);
2786 assert_eq!(format!("{credential:?}"), "PasskeyCredentialId(abab)");
2787 assert_eq!(credential.as_bytes(), &[0xab, 0xab]);
2788 }
2789
2790 #[test]
2791 fn oauth_client_id_round_trips() {
2792 let id = OAuthClientId::generate();
2793 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
2794 let json = serde_json::to_string(&id).unwrap();
2795 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
2796 }
2797
2798 #[test]
2799 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
2800 assert!("".parse::<OAuthClientId>().is_err());
2801 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
2802 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
2803 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
2804 }
2805
2806 #[test]
2810 fn a_data_citation_is_a_content_ref() {
2811 let written = "DATA-2026-0001:89fe7c6:votes/ballots.csv:110-300";
2812 let r: ContentRef = written.parse().unwrap();
2813 let c = r.as_data_citation().expect("a citation");
2814 assert_eq!(c.path.as_str(), "votes/ballots.csv");
2815 assert_eq!(r.to_string(), written);
2816 assert_eq!(
2817 r.data_set().map(GovernanceLogId::as_str),
2818 Some("DATA-2026-0001")
2819 );
2820 assert!(!r.is_governance());
2821 assert_eq!(r.kind_str(), "data file");
2822 let json = serde_json::to_value(&r).unwrap();
2823 assert_eq!(json, written);
2824 assert_eq!(serde_json::from_value::<ContentRef>(json).unwrap(), r);
2825 let whole: ContentRef =
2826 "DATA-2026-0001:89fe7c6:votes/ballots.csv".parse().unwrap();
2827 assert_eq!(whole.as_data_citation().unwrap().lines, None);
2828 let pinned: ContentRef =
2830 "DATA-2026-0001:/votes/ballots.csv:110-300".parse().unwrap();
2831 let p = pinned.as_data_citation().expect("a citation");
2832 assert_eq!(
2833 (p.commit.as_ref(), p.path.as_str()),
2834 (None, "votes/ballots.csv")
2835 );
2836 assert_eq!(
2837 pinned.to_string(),
2838 "DATA-2026-0001:/votes/ballots.csv:110-300"
2839 );
2840 let json = serde_json::to_value(&pinned).unwrap();
2841 assert_eq!(serde_json::from_value::<ContentRef>(json).unwrap(), pinned);
2842 let hexdir: ContentRef =
2843 "DATA-2026-0001:/abcdef1/file.md".parse().unwrap();
2844 assert_eq!(hexdir.as_data_citation().unwrap().commit, None);
2845 for other in
2847 ["GOV-2026-0006:votes/a.md", "TEST-2026-0001:votes/a.md:1-2"]
2848 {
2849 let err = other.parse::<ContentRef>().unwrap_err();
2850 assert!(!crate::govlog::DataCitation::is_citation_shaped(other));
2851 assert!(
2852 !err.to_string().contains("not a data set citation"),
2853 "{err}"
2854 );
2855 }
2856 let set: ContentRef = "DATA-2026-0001".parse().unwrap();
2857 assert_eq!(set.data_set(), Some(&"DATA-2026-0001".parse().unwrap()));
2858 assert!(set.as_data_citation().is_none());
2859
2860 for bad in [
2861 "DATA-2026-0001:89fe7c6",
2862 "DATA-2026-0001::",
2863 "DATA-2026-0001: the vote tallies",
2864 ] {
2865 let err = bad.parse::<ContentRef>().unwrap_err();
2866 assert_eq!(err.leading_ref(), None, "{bad}");
2867 let text = err.to_string();
2868 assert!(text.contains("not a data set citation"), "{text}");
2869 assert!(text.contains("DATA-YYYY-NNNN:<commit>:<path>"), "{text}");
2870 assert!(!text.contains("starts with the valid id"), "{text}");
2871 }
2872 let test: ContentRef = "TEST-2026-0001".parse().unwrap();
2876 assert!(test.is_governance() && test.as_data_citation().is_none());
2877 assert_eq!(test.data_set(), None);
2878 assert!(!crate::govlog::DataCitation::is_citation_shaped(
2879 "TEST-2026-0001:89fe7c6:votes/a.md"
2880 ));
2881 let err = "TEST-2026-0001:89fe7c6:votes/a.md"
2882 .parse::<ContentRef>()
2883 .unwrap_err();
2884 assert!(
2885 !err.to_string().contains("not a data set citation"),
2886 "{err}"
2887 );
2888 assert_eq!(err.leading_ref(), Some("TEST-2026-0001".parse().unwrap()));
2889 assert!(matches!(
2890 "TEST-2026-0001:89fe7c6:votes/a.md"
2891 .parse::<crate::govlog::DataCitation>(),
2892 Err(crate::govlog::DataCitationError::NotDataSet(_))
2893 ));
2894 let data: ContentRef =
2895 "DATA-2026-0001:89fe7c6:votes/a.md".parse().unwrap();
2896 assert!(data.as_data_citation().is_some() && !data.is_governance());
2897 let err = "GOV-2026-0006: the ratification"
2899 .parse::<ContentRef>()
2900 .unwrap_err();
2901 assert_eq!(err.leading_ref(), Some("GOV-2026-0006".parse().unwrap()));
2902 }
2903
2904 #[test]
2905 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
2906 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
2907 for (input, lead) in [
2908 (format!("{uuid} and the safe-space proposal"), uuid),
2909 (format!("{uuid}b0518e42"), uuid),
2910 (
2911 "GOV-2026-0006 (the ratification)".to_string(),
2912 "GOV-2026-0006",
2913 ),
2914 (
2917 "DATA-2026-0001 (the vote tallies)".to_string(),
2918 "DATA-2026-0001",
2919 ),
2920 ("DATA-2026-0012, index.md".to_string(), "DATA-2026-0012"),
2921 ("TEST-2026-0001 (the dry run)".to_string(), "TEST-2026-0001"),
2923 ("OBL-2026-0001 and more".to_string(), "OBL-2026-0001"),
2924 ("protocol, section 3".to_string(), "protocol"),
2925 ("prompts, please".to_string(), "prompts"),
2926 (
2927 "prompt:tier2_reviewer and the juror's".to_string(),
2928 "prompt:tier2_reviewer",
2929 ),
2930 (
2931 "prompts/appeals_juror.md, line 3".to_string(),
2932 "prompt:appeals_juror",
2933 ),
2934 ] {
2935 let err = input.parse::<ContentRef>().unwrap_err();
2936 assert_eq!(
2937 err.leading_ref(),
2938 Some(lead.parse().unwrap()),
2939 "{input}"
2940 );
2941 let msg = err.to_string();
2942 assert!(
2943 msg.contains(&format!("valid id {lead} followed")),
2944 "{msg}"
2945 );
2946 }
2947
2948 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
2950 let err = short.parse::<ContentRef>().unwrap_err();
2951 assert_eq!(err.leading_ref(), None);
2952 assert!(!err.to_string().contains("followed by"));
2953 assert_eq!(
2955 "GOV-2026-1".parse::<ContentRef>().unwrap(),
2956 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
2957 );
2958 }
2959
2960 #[test]
2961 fn citations_normalize_as_written() {
2962 for (written, canonical) in [
2963 ("GOV-2026-0006", "GOV-2026-0006"),
2964 ("GOV-2026.6", "GOV-2026-0006"),
2965 ("gov-2026-6", "GOV-2026-0006"),
2966 ("GOV 2026 6", "GOV-2026-0006"),
2967 ("app/2026/03", "APP-2026-0003"),
2968 (" REC-2026-0002 ", "REC-2026-0002"),
2969 ("obl-2026-12", "OBL-2026-0012"),
2970 ("OBL.2026.7", "OBL-2026-0007"),
2971 ("data-2026-3", "DATA-2026-0003"),
2972 ("DATA.2026.12", "DATA-2026-0012"),
2973 (" Data/2026/0001 ", "DATA-2026-0001"),
2974 ("DATA 2026 7", "DATA-2026-0007"),
2975 ("test-2026-3", "TEST-2026-0003"),
2976 ("Test.2026.12", "TEST-2026-0012"),
2977 ] {
2978 let id: GovernanceLogId = written.parse().unwrap();
2979 assert_eq!(id.as_str(), canonical, "{written}");
2980 let json: GovernanceLogId =
2981 serde_json::from_value(serde_json::json!(written)).unwrap();
2982 assert_eq!(json.as_str(), canonical, "{written} via serde");
2983 let content: ContentRef = written.parse().unwrap();
2984 assert_eq!(content, ContentRef::Governance(id), "{written}");
2985 }
2986 for rejected in [
2987 "GOV-2026-N",
2988 "GOV-2026/GOVG=6",
2989 "GOV-2026-00006",
2990 "GOV-26-0006",
2991 "GOV-2026-",
2992 "XYZ-2026-0006",
2993 "GOV-2026-0006-1",
2994 ] {
2995 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
2996 }
2997 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
2999 }
3000
3001 #[test]
3002 fn content_ref_parses_document_slugs() {
3003 assert_eq!(
3004 "constitution".parse(),
3005 Ok(ContentRef::Document(PlatformDoc::Constitution))
3006 );
3007 assert_eq!(
3008 "protocol".parse(),
3009 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
3010 );
3011 assert_eq!(
3013 "governance-protocol".parse(),
3014 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
3015 );
3016 assert_eq!(
3017 "Constitution".parse(),
3018 Ok(ContentRef::Document(PlatformDoc::Constitution))
3019 );
3020 assert!("proto".parse::<ContentRef>().is_err());
3021 }
3022
3023 #[test]
3024 fn content_ref_parses_prompt_slugs() {
3025 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
3026 for written in [
3027 "prompt:appeals_juror",
3028 "Prompt:Appeals_Juror",
3029 "prompt/appeals-juror",
3030 "prompts/appeals_juror.md",
3031 "prompts:appeals_juror",
3032 ] {
3033 assert_eq!(
3034 written.parse(),
3035 Ok(ContentRef::Document(juror.clone())),
3036 "{written}"
3037 );
3038 }
3039 assert_eq!(juror.to_string(), "prompt:appeals_juror");
3040 for index in ["prompts", "Prompts", "prompt"] {
3041 assert_eq!(
3042 index.parse(),
3043 Ok(ContentRef::Document(PlatformDoc::Prompts)),
3044 "{index}"
3045 );
3046 }
3047 for rejected in [
3048 "prompt:",
3049 "prompt:2fast",
3050 "prompt:tier 2",
3051 "prompt:../secrets",
3052 "prompt:tier2_reviewer/x",
3053 "promptly",
3054 ] {
3055 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
3056 }
3057 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
3058 assert!(long.parse::<ContentRef>().is_err());
3059 }
3060
3061 #[test]
3062 fn content_id_prefix_parses_eight_hex_digits_leniently() {
3063 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
3064 assert_eq!(p.to_string(), "7ad26ccd");
3065 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
3066 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
3067 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
3068 }
3069 }
3070
3071 #[test]
3072 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
3073 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
3074 let p = ContentIdPrefix::of(&id);
3075 assert_eq!(p.to_string(), "7ad26ccd");
3076 assert!(p.matches(&id));
3077 let (lo, hi) = p.bounds();
3078 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
3079 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
3080 assert!(lo <= id && id <= hi);
3081 let next: Uuid =
3082 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
3083 assert!(!p.matches(&next) && next > hi);
3084 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
3086 assert_eq!(top.bounds().1, Uuid::max());
3087 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
3088 assert_eq!(bottom.bounds().0, Uuid::nil());
3089 }
3090
3091 #[test]
3092 fn content_ref_reads_a_short_id_and_only_eight_digits() {
3093 let r: ContentRef = "7ad26ccd".parse().unwrap();
3094 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
3095 assert_eq!(r.kind_str(), "content");
3096 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
3097 let full: ContentRef =
3099 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
3100 assert!(matches!(full, ContentRef::Content(_)));
3101 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
3106 assert_eq!(err.leading_ref(), None);
3107 }
3108
3109 #[test]
3110 fn content_ref_pattern_admits_short_ids() {
3111 let short = CONTENT_ID_PREFIX_PATTERN
3112 .strip_prefix('^')
3113 .and_then(|p| p.strip_suffix('$'))
3114 .unwrap();
3115 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
3116 }
3117
3118 #[test]
3120 fn document_patterns_agree() {
3121 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
3122 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
3123 let name = PROMPT_NAME_PATTERN
3124 .strip_prefix('^')
3125 .and_then(|p| p.strip_suffix('$'))
3126 .unwrap();
3127 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
3128 }
3129
3130 #[test]
3131 fn calendar_is_a_document_slug() {
3132 assert_eq!(
3133 "calendar".parse::<ContentRef>().unwrap(),
3134 ContentRef::Document(PlatformDoc::Calendar)
3135 );
3136 assert_eq!(
3137 "Calendar".parse::<PlatformDoc>().unwrap(),
3138 PlatformDoc::Calendar
3139 );
3140 assert_eq!(PlatformDoc::Calendar.to_string(), "calendar");
3141 assert_eq!(
3142 "OBL-2026-0001".parse::<ContentRef>().unwrap(),
3143 ContentRef::Governance("OBL-2026-0001".parse().unwrap())
3144 );
3145 assert_eq!(
3146 "DATA-2026-0001".parse::<ContentRef>().unwrap(),
3147 ContentRef::Governance("DATA-2026-0001".parse().unwrap())
3148 );
3149 assert_eq!(
3150 "TEST-2026-0001".parse::<ContentRef>().unwrap(),
3151 ContentRef::Governance("TEST-2026-0001".parse().unwrap())
3152 );
3153 }
3154
3155 #[test]
3156 fn platform_doc_serde_uses_the_canonical_slug() {
3157 let json =
3158 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
3159 assert_eq!(json, "\"protocol\"");
3160 let doc: PlatformDoc =
3161 serde_json::from_str("\"governance-protocol\"").unwrap();
3162 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
3163 }
3164
3165 #[test]
3166 fn ids_are_unique() {
3167 let a = AgentId::new();
3168 let b = AgentId::new();
3169 assert_ne!(a, b);
3170 }
3171
3172 #[test]
3173 fn serde_round_trip() {
3174 let id = PostId::new();
3175 let json = serde_json::to_string(&id).unwrap();
3176 let deserialized: PostId = serde_json::from_str(&json).unwrap();
3177 assert_eq!(id, deserialized);
3178 }
3179
3180 #[test]
3181 fn display_shows_uuid() {
3182 let id = CommunityId::new();
3183 let display = id.to_string();
3184 assert_eq!(display.len(), 36);
3186 assert!(display.contains('-'));
3187 }
3188
3189 #[test]
3190 fn from_uuid_round_trip() {
3191 let uuid = Uuid::new_v4();
3192 let id = AgentId::from(uuid);
3193 let back: Uuid = id.into();
3194 assert_eq!(uuid, back);
3195 }
3196
3197 #[test]
3201 fn every_id_round_trips_through_its_own_display() {
3202 let agent = AgentId::new();
3203 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
3204
3205 let action = ModerationActionId::new();
3206 assert_eq!(
3207 action.to_string().parse::<ModerationActionId>().unwrap(),
3208 action
3209 );
3210
3211 let content = ContentId::new();
3212 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
3213
3214 let feedback = FeedbackId::new();
3215 assert_eq!(
3216 feedback.to_string().parse::<FeedbackId>().unwrap(),
3217 feedback
3218 );
3219 }
3220
3221 #[test]
3222 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
3223 assert!("not-a-uuid".parse::<ContentId>().is_err());
3224 assert!("".parse::<ContentId>().is_err());
3225 }
3226
3227 #[test]
3232 fn content_id_is_wire_compatible_with_a_bare_uuid() {
3233 let uuid = Uuid::new_v4();
3234 let typed = ContentId::from(uuid);
3235 assert_eq!(
3236 serde_json::to_string(&typed).unwrap(),
3237 serde_json::to_string(&uuid).unwrap()
3238 );
3239 }
3240
3241 #[test]
3244 fn every_moderation_target_narrows_losslessly() {
3245 let uuid = Uuid::new_v4();
3246
3247 for (label, got) in [
3248 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
3249 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
3250 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
3251 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
3252 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
3253 ] {
3254 assert_eq!(
3255 got.as_uuid(),
3256 &uuid,
3257 "{label} -> ModerationTargetId lost the uuid"
3258 );
3259 }
3260 }
3261
3262 #[test]
3266 fn resolved_ids_narrow_to_content_id_losslessly() {
3267 let uuid = Uuid::new_v4();
3268
3269 assert_eq!(
3270 ContentId::from(PostId::from(uuid)).as_uuid(),
3271 &uuid,
3272 "PostId -> ContentId lost the uuid"
3273 );
3274 assert_eq!(
3275 ContentId::from(CommentId::from(uuid)).as_uuid(),
3276 &uuid,
3277 "CommentId -> ContentId lost the uuid"
3278 );
3279 assert_eq!(
3280 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
3281 .as_uuid(),
3282 &uuid,
3283 "PostOrCommentId -> ContentId lost the uuid"
3284 );
3285 }
3286
3287 #[test]
3288 fn json_is_plain_uuid_string() {
3289 let uuid = Uuid::new_v4();
3290 let id = AgentId::from(uuid);
3291 let id_json = serde_json::to_string(&id).unwrap();
3293 let uuid_json = serde_json::to_string(&uuid).unwrap();
3294 assert_eq!(id_json, uuid_json);
3295 }
3296
3297 #[cfg(feature = "schemars")]
3302 #[test]
3303 fn id_json_schema_is_inlined() {
3304 use schemars::JsonSchema;
3305
3306 assert!(
3307 <PostId as JsonSchema>::inline_schema(),
3308 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
3309 );
3310 assert!(<AgentId as JsonSchema>::inline_schema());
3311 assert!(<CommentId as JsonSchema>::inline_schema());
3312 assert!(<CommunityId as JsonSchema>::inline_schema());
3313 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
3314 assert!(<ContentRef as JsonSchema>::inline_schema());
3315 assert!(<FeedbackId as JsonSchema>::inline_schema());
3316 assert!(<ContactRequestId as JsonSchema>::inline_schema());
3317
3318 #[derive(schemars::JsonSchema)]
3322 #[allow(dead_code)]
3323 struct Container {
3324 post_id: PostId,
3326 agent_id: Option<AgentId>,
3328 gov_id: GovernanceLogId,
3330 maybe_gov_id: Option<GovernanceLogId>,
3332 content_ref: ContentRef,
3334 maybe_content_ref: Option<ContentRef>,
3336 feedback_id: FeedbackId,
3338 }
3339
3340 let schema = schemars::schema_for!(Container);
3341 let value = serde_json::to_value(&schema).unwrap();
3342
3343 assert!(
3345 value.get("$defs").is_none(),
3346 "no $defs should be emitted for ID-only container; got schema: {value}"
3347 );
3348
3349 let post_id = &value["properties"]["post_id"];
3351 assert!(
3352 post_id.get("$ref").is_none(),
3353 "post_id must not be a $ref; got: {post_id}"
3354 );
3355 assert_eq!(post_id["type"], "string");
3356 assert_eq!(post_id["format"], "uuid");
3357
3358 let agent_id = &value["properties"]["agent_id"];
3363 assert!(
3364 agent_id.get("$ref").is_none(),
3365 "agent_id must not be a $ref; got: {agent_id}"
3366 );
3367 let agent_id_str = agent_id.to_string();
3368 assert!(
3369 !agent_id_str.contains("$ref"),
3370 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
3371 );
3372 assert!(
3373 agent_id_str.contains("\"format\":\"uuid\""),
3374 "agent_id should still carry format=uuid; got: {agent_id}"
3375 );
3376
3377 for field in
3382 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
3383 {
3384 let f = &value["properties"][field];
3385 assert!(
3386 !f.to_string().contains("$ref"),
3387 "{field} must contain no $ref anywhere; got: {f}"
3388 );
3389 }
3390 assert_eq!(value["properties"]["gov_id"]["type"], "string");
3391 assert_eq!(
3392 value["properties"]["gov_id"]["pattern"],
3393 GOVERNANCE_LOG_ID_PATTERN
3394 );
3395 assert!(
3396 value["properties"]["maybe_gov_id"]
3397 .to_string()
3398 .contains("GOV|APP"),
3399 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
3400 value["properties"]["maybe_gov_id"]
3401 );
3402 assert_eq!(value["properties"]["content_ref"]["type"], "string");
3403 }
3404
3405 #[test]
3406 fn governance_log_id_accepts_only_citation_shapes() {
3407 for (good, prefix) in [
3408 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
3409 ("APP-2026-0003", GovernanceLogPrefix::App),
3410 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
3411 ("KEY-2026-0001", GovernanceLogPrefix::Key),
3412 ("REC-2026-0001", GovernanceLogPrefix::Rec),
3413 ("OBL-2026-0001", GovernanceLogPrefix::Obl),
3414 ("DATA-2026-0001", GovernanceLogPrefix::Data),
3415 ("TEST-2026-0001", GovernanceLogPrefix::Test),
3416 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
3417 ] {
3418 let id = good.parse::<GovernanceLogId>().unwrap();
3419 assert_eq!(id.as_str(), good, "{good} should parse");
3420 assert_eq!(id.prefix(), prefix);
3421 let letters = good.split('-').next().unwrap();
3422 assert_eq!(id.prefix().as_str(), letters);
3423 assert_eq!(id.prefix().to_string().parse(), Ok(prefix));
3424 }
3425 let newer: GovernanceLogId =
3427 serde_json::from_value(serde_json::json!("ZZZ-2026-0001")).unwrap();
3428 assert_eq!(newer.prefix(), GovernanceLogPrefix::Unknown);
3429 assert_eq!(newer.as_str(), "ZZZ-2026-0001");
3430 assert!("ZZZ-2026-0001".parse::<GovernanceLogId>().is_err());
3431 assert!(!GovernanceLogId::is_citation_shaped("ZZZ-2026-0001"));
3432 for junk in [
3433 "zzz-2026-0001",
3434 "ZZZZZ-2026-0001",
3435 "ZZ-2026-0001",
3436 "ZZZ-2026-1",
3437 ] {
3438 assert!(
3439 serde_json::from_value::<GovernanceLogId>(serde_json::json!(
3440 junk
3441 ))
3442 .is_err(),
3443 "{junk}"
3444 );
3445 }
3446 assert_eq!(
3447 GovernanceLogId::from_parts(GovernanceLogPrefix::Unknown, 2026, 1),
3448 None
3449 );
3450 for prefix in GovernanceLogPrefix::ALL {
3453 let want = if matches!(
3454 prefix,
3455 GovernanceLogPrefix::Data | GovernanceLogPrefix::Test
3456 ) {
3457 4
3458 } else {
3459 3
3460 };
3461 assert_eq!(prefix.as_str().len(), want, "{prefix}");
3462 }
3463 let series = GovernanceLogPrefix::ALL.map(|p| p.as_str()).join("|");
3465 assert!(
3466 GOVERNANCE_LOG_ID_PATTERN.contains(&format!("({series})")),
3467 "{GOVERNANCE_LOG_ID_PATTERN}"
3468 );
3469 assert!(
3470 CONTENT_REF_PATTERN.contains(&format!("({series})")),
3471 "{CONTENT_REF_PATTERN}"
3472 );
3473 assert_eq!(
3475 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
3476 "GOV-2026-0006"
3477 );
3478 assert_eq!(
3479 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
3480 "GOV-2026-0006"
3481 );
3482 for bad in [
3483 "",
3484 "GOV-26-0006",
3485 "MOD-2026-0006",
3486 "GOV-2026-0006-1",
3487 "GOV-202X-0006",
3488 "DAT-2026-0001",
3489 "DATAS-2026-0001",
3490 "DATA-2026-00001",
3491 "DATA-2026",
3492 "TES-2026-0001",
3493 "TESTS-2026-0001",
3494 "TEST-2026-00001",
3495 "3f1a0000-0000-0000-0000-000000000000",
3496 ] {
3497 assert!(
3498 bad.parse::<GovernanceLogId>().is_err(),
3499 "{bad:?} should not parse as a GovernanceLogId"
3500 );
3501 }
3502 }
3503
3504 #[test]
3508 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
3509 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3510 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
3511 let back: GovernanceLogId =
3512 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
3513 assert_eq!(back, id);
3514 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
3516 }
3517
3518 #[test]
3521 fn content_ref_round_trips_as_a_bare_string() {
3522 let uuid = Uuid::new_v4();
3523 let content = ContentRef::from(ContentId::from(uuid));
3524 assert_eq!(
3525 serde_json::to_value(&content).unwrap(),
3526 serde_json::json!(uuid.to_string())
3527 );
3528 assert_eq!(
3529 serde_json::from_value::<ContentRef>(serde_json::json!(
3530 uuid.to_string()
3531 ))
3532 .unwrap(),
3533 content
3534 );
3535
3536 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
3537 assert_eq!(
3538 serde_json::to_value(&gov).unwrap(),
3539 serde_json::json!("APP-2026-0003")
3540 );
3541 assert_eq!(
3542 serde_json::from_value::<ContentRef>(serde_json::json!(
3543 "APP-2026-0003"
3544 ))
3545 .unwrap(),
3546 gov
3547 );
3548
3549 assert!(gov.is_governance());
3550 assert!(!content.is_governance());
3551 assert_eq!(gov.kind_str(), "governance");
3552 assert_eq!(content.kind_str(), "content");
3553 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
3554 assert!(content.as_governance().is_none());
3555
3556 assert!("not-an-id".parse::<ContentRef>().is_err());
3558 assert!(
3559 serde_json::from_value::<ContentRef>(serde_json::json!(
3560 "not-an-id"
3561 ))
3562 .is_err()
3563 );
3564 }
3565
3566 #[test]
3568 fn every_readable_id_narrows_to_a_content_ref() {
3569 let uuid = Uuid::new_v4();
3570 for (label, got) in [
3571 ("PostId", ContentRef::from(PostId::from(uuid))),
3572 ("CommentId", ContentRef::from(CommentId::from(uuid))),
3573 ("ContentId", ContentRef::from(ContentId::from(uuid))),
3574 ] {
3575 assert_eq!(
3576 got,
3577 ContentRef::Content(ContentId::from(uuid)),
3578 "{label} -> ContentRef lost the uuid"
3579 );
3580 }
3581 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3582 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
3583 }
3584
3585 #[test]
3588 fn string_shaped_ids_round_trip_through_display() {
3589 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
3590 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
3591
3592 let r = ContentRef::Governance(gov);
3593 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3594
3595 let r = ContentRef::Content(ContentId::new());
3596 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
3597 }
3598
3599 #[test]
3600 fn post_or_comment_post_variant() {
3601 let inner = PostId::new();
3602 let tagged = PostOrCommentId::Post(inner);
3603 assert!(tagged.is_post());
3604 assert!(!tagged.is_comment());
3605 assert_eq!(tagged.as_post(), Some(inner));
3606 assert_eq!(tagged.as_comment(), None);
3607 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3608 assert_eq!(tagged.kind_str(), "post");
3609 }
3610
3611 #[test]
3612 fn post_or_comment_comment_variant() {
3613 let inner = CommentId::new();
3614 let tagged = PostOrCommentId::Comment(inner);
3615 assert!(tagged.is_comment());
3616 assert!(!tagged.is_post());
3617 assert_eq!(tagged.as_comment(), Some(inner));
3618 assert_eq!(tagged.as_post(), None);
3619 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
3620 assert_eq!(tagged.kind_str(), "comment");
3621 }
3622
3623 #[test]
3624 fn post_or_comment_from_conversions() {
3625 let post = PostId::new();
3626 let comment = CommentId::new();
3627 let via_post: PostOrCommentId = post.into();
3628 let via_comment: PostOrCommentId = comment.into();
3629 assert_eq!(via_post, PostOrCommentId::Post(post));
3630 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
3631 }
3632
3633 #[test]
3634 fn post_or_comment_display_is_kind_colon_uuid() {
3635 let post = PostId::new();
3636 let tagged = PostOrCommentId::Post(post);
3637 let rendered = tagged.to_string();
3638 assert!(rendered.starts_with("post:"));
3639 assert!(rendered.contains(&post.to_string()));
3640 }
3641
3642 const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
3645
3646 #[test]
3649 fn a_bad_id_fails_in_plain_words() {
3650 let bad = "7ad26ccd-922f-484a-a37c-51777344a";
3651 let want = format!(
3652 "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
3653 );
3654 assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
3655 let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
3656 .unwrap_err()
3657 .to_string();
3658 assert!(err.starts_with(&want), "{err}");
3659 assert!(!err.contains("group"), "{err}");
3660 let err = serde_json::from_str::<AgentId>("5")
3661 .unwrap_err()
3662 .to_string();
3663 assert!(err.contains("expected AgentId as a UUID string"), "{err}");
3664 let long = "x".repeat(500);
3666 let msg = long.parse::<CommentId>().unwrap_err().to_string();
3667 assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
3668 }
3669
3670 #[test]
3672 fn ids_still_round_trip() {
3673 let id: PostId = FULL.parse().unwrap();
3674 let json = serde_json::to_string(&id).unwrap();
3675 assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
3676 let map: std::collections::HashMap<PostId, i64> =
3677 [(id, 3)].into_iter().collect();
3678 let json = serde_json::to_string(&map).unwrap();
3679 let back: std::collections::HashMap<PostId, i64> =
3680 serde_json::from_str(&json).unwrap();
3681 assert_eq!(back, map);
3682 }
3683
3684 fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
3685 s.parse()
3686 }
3687
3688 #[test]
3689 fn full_and_short_targets_parse() {
3690 let full = target(FULL).unwrap();
3691 assert_eq!(full.full().unwrap().to_string(), FULL);
3692 assert_eq!(full.to_string(), FULL);
3693 let short = target(" 7AD26CCD ").unwrap();
3694 assert!(short.full().is_none());
3695 assert_eq!(short.to_string(), "7ad26ccd");
3696 for variant in [
3698 FULL.replace('-', ""),
3699 FULL.to_uppercase(),
3700 FULL.replace('-', "\u{2011}"),
3701 ] {
3702 assert_eq!(target(&variant).unwrap(), full, "{variant}");
3703 }
3704 }
3705
3706 #[test]
3707 fn a_truncated_uuid_is_an_error_not_its_prefix() {
3708 let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
3709 let msg = err.in_field("reply_to").to_string();
3710 assert!(
3711 msg.starts_with(
3712 "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
3713 ),
3714 "{msg}"
3715 );
3716 for internal in [
3717 "group",
3718 "UUID parsing",
3719 "invalid length",
3720 "invalid character",
3721 ] {
3722 assert!(!msg.contains(internal), "{internal} leaked: {msg}");
3723 }
3724 assert!(target("7ad26cc").is_err());
3725 assert!(target("7ad26ccd9").is_err());
3726 }
3727
3728 #[test]
3729 fn trailing_text_names_the_id_it_starts_with() {
3730 let msg = target(&format!("{FULL} (the reply above)"))
3731 .unwrap_err()
3732 .to_string();
3733 assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
3734 }
3735
3736 #[derive(Debug, serde::Deserialize)]
3737 struct Probe {
3738 #[serde(deserialize_with = "content_target::reply_to")]
3739 #[allow(dead_code)]
3740 reply_to: ContentTarget,
3741 }
3742
3743 #[test]
3744 fn target_errors_name_the_field_for_strings_and_other_types() {
3745 let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
3746 .unwrap_err();
3747 assert!(
3748 err.to_string()
3749 .starts_with("`reply_to` must be a post or comment id"),
3750 "{err}"
3751 );
3752 let err =
3753 serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
3754 assert!(
3755 err.to_string()
3756 .contains("expected `reply_to` to be a post or comment id"),
3757 "{err}"
3758 );
3759 }
3760
3761 #[cfg(feature = "schemars")]
3762 #[test]
3763 fn target_schema_is_inline_and_admits_both_forms() {
3764 let schema = schemars::schema_for!(ContentTarget);
3765 let v = schema.as_value();
3766 assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
3767 assert!(v.get("format").is_none(), "a short id is not format: uuid");
3768 assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
3769 }
3770}