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)-[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)-[0-9]{4}-[0-9]{4}|constitution|protocol|prompts|calendar|prompt:[a-z][a-z0-9_]*)$";
33
34pub const PLATFORM_DOC_PATTERN: &str =
36 "^(constitution|protocol|prompts|calendar|prompt:[a-z][a-z0-9_]*)$";
37
38pub const PROMPT_NAME_PATTERN: &str = "^[a-z][a-z0-9_]*$";
40
41macro_rules! define_id {
42 ($(#[doc = $doc:expr])* $name:ident) => {
43 $(#[doc = $doc])*
44 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
45 #[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
46 #[cfg_attr(feature = "sqlx", sqlx(transparent))]
47 pub struct $name(Uuid);
48
49 impl $name {
50 pub fn new() -> Self {
52 Self(Uuid::new_v4())
53 }
54
55 pub fn as_uuid(&self) -> &Uuid {
57 &self.0
58 }
59 }
60
61 impl Default for $name {
62 fn default() -> Self {
63 Self::new()
64 }
65 }
66
67 impl std::fmt::Display for $name {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 self.0.fmt(f)
70 }
71 }
72
73 impl From<Uuid> for $name {
74 fn from(uuid: Uuid) -> Self {
75 Self(uuid)
76 }
77 }
78
79 impl From<$name> for Uuid {
80 fn from(id: $name) -> Self {
81 id.0
82 }
83 }
84
85 impl std::str::FromStr for $name {
94 type Err = IdParseError;
95
96 fn from_str(s: &str) -> Result<Self, Self::Err> {
97 s.parse::<Uuid>().map(Self).map_err(|_| IdParseError {
98 name: stringify!($name),
99 input: s.to_string(),
100 })
101 }
102 }
103
104 impl<'de> Deserialize<'de> for $name {
107 fn deserialize<D: serde::Deserializer<'de>>(
108 d: D,
109 ) -> Result<Self, D::Error> {
110 struct V;
111 impl serde::de::Visitor<'_> for V {
112 type Value = $name;
113
114 fn expecting(
115 &self,
116 f: &mut std::fmt::Formatter<'_>,
117 ) -> std::fmt::Result {
118 write!(f, "{} as a UUID string", stringify!($name))
119 }
120
121 fn visit_str<E: serde::de::Error>(
122 self,
123 v: &str,
124 ) -> Result<$name, E> {
125 v.parse().map_err(E::custom)
126 }
127 }
128 d.deserialize_str(V)
129 }
130 }
131
132 #[cfg(feature = "schemars")]
139 impl schemars::JsonSchema for $name {
140 fn inline_schema() -> bool {
141 true
142 }
143
144 fn schema_name() -> std::borrow::Cow<'static, str> {
145 std::borrow::Cow::Borrowed(stringify!($name))
146 }
147
148 fn schema_id() -> std::borrow::Cow<'static, str> {
149 std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
150 }
151
152 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
153 schemars::json_schema!({
154 "type": "string",
155 "format": "uuid",
156 "pattern": UUID_PATTERN,
157 })
158 }
159 }
160 };
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
169#[error(
170 "not a valid {name}: expected a UUID like \
171 \"7ad26ccd-922f-484a-a37c-51777344a98c\", got \"{}\"",
172 echo(input)
173)]
174pub struct IdParseError {
175 pub name: &'static str,
177 pub input: String,
178}
179
180const ECHO_CHARS: usize = 60;
182
183fn echo(input: &str) -> String {
185 let mut out: String = input.chars().take(ECHO_CHARS).collect();
186 if input.chars().count() > ECHO_CHARS {
187 out.push('…');
188 }
189 out
190}
191
192define_id! {
193 AgentId
195}
196
197define_id! {
198 ReactorId
200}
201
202define_id! {
203 OperatorId
205}
206
207define_id! {
208 PostId
210}
211
212define_id! {
213 CommentId
215}
216
217define_id! {
218 CommunityId
220}
221
222define_id! {
223 VoteId
225}
226
227define_id! {
228 ModerationActionId
230}
231
232define_id! {
233 ModerationNoteId
240}
241
242define_id! {
243 PromptArchiveId
250}
251
252define_id! {
253 AppealId
255}
256
257define_id! {
258 FlagId
260}
261
262define_id! {
263 ProposalDesignationId
266}
267
268define_id! {
269 CouncilMeetingId
271}
272
273define_id! {
274 AgendaItemId
276}
277
278define_id! {
279 DecisionId
281}
282
283define_id! {
284 BatchTrackingId
286}
287
288define_id! {
289 ThreadSummaryId
291}
292
293define_id! {
294 McpSessionId
296}
297
298define_id! {
299 EmailVerificationTokenId
301}
302
303define_id! {
304 PostEmbeddingId
306}
307
308define_id! {
309 DataExportId
315}
316
317define_id! {
318 RefreshTokenId
325}
326
327define_id! {
328 MessageId
336}
337
338define_id! {
339 FeedbackId
343}
344
345define_id! {
346 ContactRequestId
349}
350
351define_id! {
352 TrashEntryId
356}
357
358define_id! {
359 PollId
362}
363
364define_id! {
365 PollOptionId
369}
370
371define_id! {
372 BallotId
375}
376
377define_id! {
378 CalendarEventId
382}
383
384define_id! {
385 PromptArchiveResponseId
388}
389
390define_id! {
391 CleanupId
395}
396
397define_id! {
398 OperatorSessionId
401}
402
403define_id! {
404 OperatorPasswordResetId
407}
408
409define_id! {
410 OperatorPasskeyId
413}
414
415define_id! {
416 OperatorPendingLoginId
420}
421
422#[derive(Clone, PartialEq, Eq, Hash)]
426#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
427#[cfg_attr(feature = "sqlx", sqlx(transparent))]
428pub struct PasskeyCredentialId(Vec<u8>);
429
430impl PasskeyCredentialId {
431 pub fn as_bytes(&self) -> &[u8] {
432 &self.0
433 }
434}
435
436impl From<Vec<u8>> for PasskeyCredentialId {
437 fn from(bytes: Vec<u8>) -> Self {
438 Self(bytes)
439 }
440}
441
442impl std::fmt::Debug for PasskeyCredentialId {
443 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444 write!(f, "PasskeyCredentialId({})", hex::encode(&self.0))
445 }
446}
447
448#[derive(Clone, PartialEq, Eq)]
453#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
454#[cfg_attr(feature = "sqlx", sqlx(transparent))]
455pub struct WebAuthnUserHandle(Vec<u8>);
456
457impl WebAuthnUserHandle {
458 pub fn as_bytes(&self) -> &[u8] {
459 &self.0
460 }
461}
462
463impl From<Vec<u8>> for WebAuthnUserHandle {
464 fn from(bytes: Vec<u8>) -> Self {
465 Self(bytes)
466 }
467}
468
469impl std::fmt::Debug for WebAuthnUserHandle {
470 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471 f.write_str("WebAuthnUserHandle(..)")
472 }
473}
474
475#[derive(
489 Debug,
490 Clone,
491 Copy,
492 PartialEq,
493 Eq,
494 PartialOrd,
495 Ord,
496 Hash,
497 Serialize,
498 Deserialize,
499)]
500#[serde(transparent)]
501pub struct PollOptionNumber(i16);
502
503impl PollOptionNumber {
504 pub fn new(n: usize) -> Option<Self> {
506 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
507 }
508
509 pub fn index(&self) -> Option<usize> {
511 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
512 }
513
514 pub fn get(&self) -> i16 {
515 self.0
516 }
517}
518
519impl From<i16> for PollOptionNumber {
520 fn from(n: i16) -> Self {
521 Self(n)
522 }
523}
524
525impl std::fmt::Display for PollOptionNumber {
526 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527 self.0.fmt(f)
528 }
529}
530
531#[cfg(feature = "schemars")]
532impl schemars::JsonSchema for PollOptionNumber {
533 fn inline_schema() -> bool {
534 true
535 }
536
537 fn schema_name() -> std::borrow::Cow<'static, str> {
538 std::borrow::Cow::Borrowed("PollOptionNumber")
539 }
540
541 fn schema_id() -> std::borrow::Cow<'static, str> {
542 std::borrow::Cow::Borrowed(concat!(
543 module_path!(),
544 "::PollOptionNumber"
545 ))
546 }
547
548 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
550 schemars::json_schema!({ "type": "integer" })
551 }
552}
553
554#[derive(
560 Debug,
561 Clone,
562 Copy,
563 PartialEq,
564 Eq,
565 PartialOrd,
566 Ord,
567 Hash,
568 Serialize,
569 Deserialize,
570)]
571#[serde(transparent)]
572#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
573#[cfg_attr(feature = "schemars", schemars(inline))]
574#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
575#[cfg_attr(feature = "sqlx", sqlx(transparent))]
576pub struct PollOptionPosition(i16);
577
578impl PollOptionPosition {
579 pub fn new(n: usize) -> Option<Self> {
581 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
582 }
583
584 pub fn index(&self) -> Option<usize> {
586 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
587 }
588
589 pub fn get(&self) -> i16 {
590 self.0
591 }
592}
593
594impl From<i16> for PollOptionPosition {
595 fn from(n: i16) -> Self {
596 Self(n)
597 }
598}
599
600impl std::fmt::Display for PollOptionPosition {
601 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
602 self.0.fmt(f)
603 }
604}
605
606define_id! {
607 ContentId
628}
629
630define_id! {
631 ModerationTargetId
649}
650
651impl From<PostId> for ModerationTargetId {
657 fn from(id: PostId) -> Self {
658 Self::from(*id.as_uuid())
659 }
660}
661
662impl From<CommentId> for ModerationTargetId {
663 fn from(id: CommentId) -> Self {
664 Self::from(*id.as_uuid())
665 }
666}
667
668impl From<MessageId> for ModerationTargetId {
669 fn from(id: MessageId) -> Self {
670 Self::from(*id.as_uuid())
671 }
672}
673
674impl From<AgentId> for ModerationTargetId {
675 fn from(id: AgentId) -> Self {
676 Self::from(*id.as_uuid())
677 }
678}
679
680impl From<ContentId> for ModerationTargetId {
683 fn from(id: ContentId) -> Self {
684 Self::from(*id.as_uuid())
685 }
686}
687
688impl From<PostId> for ContentId {
693 fn from(id: PostId) -> Self {
694 Self::from(*id.as_uuid())
695 }
696}
697
698impl From<CommentId> for ContentId {
699 fn from(id: CommentId) -> Self {
700 Self::from(*id.as_uuid())
701 }
702}
703
704impl From<PostOrCommentId> for ContentId {
705 fn from(id: PostOrCommentId) -> Self {
706 Self::from(id.as_uuid())
707 }
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
738pub enum PostOrCommentId {
739 Post(PostId),
740 Comment(CommentId),
741}
742
743impl PostOrCommentId {
744 pub fn as_uuid(&self) -> Uuid {
746 match self {
747 PostOrCommentId::Post(id) => *id.as_uuid(),
748 PostOrCommentId::Comment(id) => *id.as_uuid(),
749 }
750 }
751
752 pub fn is_post(&self) -> bool {
754 matches!(self, PostOrCommentId::Post(_))
755 }
756
757 pub fn is_comment(&self) -> bool {
759 matches!(self, PostOrCommentId::Comment(_))
760 }
761
762 pub fn as_post(&self) -> Option<PostId> {
764 match self {
765 PostOrCommentId::Post(id) => Some(*id),
766 PostOrCommentId::Comment(_) => None,
767 }
768 }
769
770 pub fn as_comment(&self) -> Option<CommentId> {
772 match self {
773 PostOrCommentId::Comment(id) => Some(*id),
774 PostOrCommentId::Post(_) => None,
775 }
776 }
777
778 pub fn kind(&self) -> crate::enums::ContentKind {
780 match self {
781 PostOrCommentId::Post(_) => crate::enums::ContentKind::Post,
782 PostOrCommentId::Comment(_) => crate::enums::ContentKind::Comment,
783 }
784 }
785
786 pub fn kind_str(&self) -> &'static str {
789 match self {
790 PostOrCommentId::Post(_) => "post",
791 PostOrCommentId::Comment(_) => "comment",
792 }
793 }
794}
795
796impl From<PostId> for PostOrCommentId {
797 fn from(id: PostId) -> Self {
798 PostOrCommentId::Post(id)
799 }
800}
801
802impl From<CommentId> for PostOrCommentId {
803 fn from(id: CommentId) -> Self {
804 PostOrCommentId::Comment(id)
805 }
806}
807
808impl std::fmt::Display for PostOrCommentId {
809 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
810 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
811 }
812}
813
814#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
820#[error(
821 "not a governance log id (expected GOV-, APP-, AMD-, KEY-, REC- or \
822 OBL-YYYY-NNNN): {0:?}"
823)]
824pub struct GovernanceLogIdError(pub String);
825
826#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
832pub enum GovernanceLogPrefix {
833 Gov,
835 App,
837 Amd,
839 Key,
841 Rec,
843 Obl,
845}
846
847impl GovernanceLogPrefix {
848 pub const ALL: [Self; 6] = [
850 Self::Gov,
851 Self::App,
852 Self::Amd,
853 Self::Key,
854 Self::Rec,
855 Self::Obl,
856 ];
857
858 pub fn as_str(&self) -> &'static str {
860 match self {
861 Self::Gov => "GOV",
862 Self::App => "APP",
863 Self::Amd => "AMD",
864 Self::Key => "KEY",
865 Self::Rec => "REC",
866 Self::Obl => "OBL",
867 }
868 }
869}
870
871impl std::fmt::Display for GovernanceLogPrefix {
872 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
873 f.write_str(self.as_str())
874 }
875}
876
877impl std::str::FromStr for GovernanceLogPrefix {
878 type Err = GovernanceLogIdError;
879
880 fn from_str(s: &str) -> Result<Self, Self::Err> {
881 match s {
882 "GOV" => Ok(Self::Gov),
883 "APP" => Ok(Self::App),
884 "AMD" => Ok(Self::Amd),
885 "KEY" => Ok(Self::Key),
886 "REC" => Ok(Self::Rec),
887 "OBL" => Ok(Self::Obl),
888 _ => Err(GovernanceLogIdError(s.to_string())),
889 }
890 }
891}
892
893#[derive(
912 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
913)]
914#[serde(try_from = "String")]
915#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
916#[cfg_attr(feature = "sqlx", sqlx(transparent))]
917pub struct GovernanceLogId(String);
918
919impl GovernanceLogId {
920 pub fn as_str(&self) -> &str {
922 &self.0
923 }
924
925 pub fn into_inner(self) -> String {
927 self.0
928 }
929
930 pub fn is_citation_shaped(s: &str) -> bool {
938 Self::parts(s).is_some()
939 }
940
941 pub fn prefix(&self) -> GovernanceLogPrefix {
943 Self::parts(&self.0)
944 .expect("a GovernanceLogId is citation-shaped by construction")
945 .0
946 }
947
948 pub fn normalize(s: &str) -> Option<String> {
954 let mut parts = s.trim().split(['-', '.', '/', ' ']);
955 let (prefix, year, serial) =
956 (parts.next()?, parts.next()?, parts.next()?);
957 if parts.next().is_some() {
958 return None;
959 }
960 let prefix: GovernanceLogPrefix =
961 prefix.to_ascii_uppercase().parse().ok()?;
962 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
963 (year.len() == 4
964 && digits(year)
965 && (1..=4).contains(&serial.len())
966 && digits(serial))
967 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
968 }
969
970 pub fn from_parts(
973 prefix: GovernanceLogPrefix,
974 year: i32,
975 serial: u32,
976 ) -> Option<Self> {
977 ((1000..=9999).contains(&year) && (1..=9999).contains(&serial))
978 .then(|| Self(format!("{prefix}-{year}-{serial:04}")))
979 }
980
981 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
982 let parts: Vec<&str> = s.split('-').collect();
983 let [prefix, year, serial] = parts.as_slice() else {
984 return None;
985 };
986 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
987 (year.len() == 4
988 && serial.len() == 4
989 && year.chars().all(|c| c.is_ascii_digit())
990 && serial.chars().all(|c| c.is_ascii_digit()))
991 .then_some((prefix, year, serial))
992 }
993}
994
995impl std::fmt::Display for GovernanceLogId {
996 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
997 f.write_str(&self.0)
998 }
999}
1000
1001impl AsRef<str> for GovernanceLogId {
1002 fn as_ref(&self) -> &str {
1003 &self.0
1004 }
1005}
1006
1007impl std::str::FromStr for GovernanceLogId {
1008 type Err = GovernanceLogIdError;
1009
1010 fn from_str(s: &str) -> Result<Self, Self::Err> {
1013 Self::normalize(s)
1014 .map(Self)
1015 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
1016 }
1017}
1018
1019impl TryFrom<String> for GovernanceLogId {
1020 type Error = GovernanceLogIdError;
1021
1022 fn try_from(s: String) -> Result<Self, Self::Error> {
1023 if Self::is_citation_shaped(&s) {
1024 return Ok(Self(s));
1025 }
1026 s.parse()
1027 }
1028}
1029
1030impl From<GovernanceLogId> for String {
1031 fn from(id: GovernanceLogId) -> Self {
1032 id.0
1033 }
1034}
1035
1036#[cfg(feature = "schemars")]
1042impl schemars::JsonSchema for GovernanceLogId {
1043 fn inline_schema() -> bool {
1044 true
1045 }
1046
1047 fn schema_name() -> std::borrow::Cow<'static, str> {
1048 std::borrow::Cow::Borrowed("GovernanceLogId")
1049 }
1050
1051 fn schema_id() -> std::borrow::Cow<'static, str> {
1052 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
1053 }
1054
1055 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1056 schemars::json_schema!({
1057 "type": "string",
1058 "pattern": GOVERNANCE_LOG_ID_PATTERN,
1059 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
1060 (Council decision or policy change), \
1061 \"APP-2026-0003\" (appeals ruling), \
1062 \"AMD-2026-0001\" (amendment to an earlier \
1063 entry), \"KEY-2026-0001\" (signing key \
1064 rotation), \"REC-2026-0001\" (a Steward's \
1065 record of an operational act) or \
1066 \"OBL-2026-0001\" (an obligation).",
1067 })
1068 }
1069}
1070
1071#[derive(
1080 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1081)]
1082#[serde(try_from = "String")]
1083#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
1084#[cfg_attr(feature = "sqlx", sqlx(transparent))]
1085pub struct OAuthClientId(String);
1086
1087#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1089#[error("not an OAuth client id: {0:?}")]
1090pub struct OAuthClientIdError(pub String);
1091
1092impl OAuthClientId {
1093 pub fn generate() -> Self {
1095 Self(Uuid::new_v4().to_string())
1096 }
1097
1098 pub fn as_str(&self) -> &str {
1100 &self.0
1101 }
1102
1103 pub fn into_inner(self) -> String {
1105 self.0
1106 }
1107
1108 fn is_valid(s: &str) -> bool {
1109 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
1110 }
1111}
1112
1113impl std::fmt::Display for OAuthClientId {
1114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1115 f.write_str(&self.0)
1116 }
1117}
1118
1119impl AsRef<str> for OAuthClientId {
1120 fn as_ref(&self) -> &str {
1121 &self.0
1122 }
1123}
1124
1125impl std::str::FromStr for OAuthClientId {
1126 type Err = OAuthClientIdError;
1127
1128 fn from_str(s: &str) -> Result<Self, Self::Err> {
1129 Self::try_from(s.to_string())
1130 }
1131}
1132
1133impl TryFrom<String> for OAuthClientId {
1134 type Error = OAuthClientIdError;
1135
1136 fn try_from(s: String) -> Result<Self, Self::Error> {
1137 if Self::is_valid(&s) {
1138 Ok(Self(s))
1139 } else {
1140 Err(OAuthClientIdError(s))
1141 }
1142 }
1143}
1144
1145impl From<OAuthClientId> for String {
1146 fn from(id: OAuthClientId) -> Self {
1147 id.0
1148 }
1149}
1150
1151#[cfg(feature = "schemars")]
1154impl schemars::JsonSchema for OAuthClientId {
1155 fn inline_schema() -> bool {
1156 true
1157 }
1158
1159 fn schema_name() -> std::borrow::Cow<'static, str> {
1160 std::borrow::Cow::Borrowed("OAuthClientId")
1161 }
1162
1163 fn schema_id() -> std::borrow::Cow<'static, str> {
1164 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
1165 }
1166
1167 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1168 schemars::json_schema!({
1169 "type": "string",
1170 "minLength": 1,
1171 "maxLength": 255,
1172 "description": "OAuth client_id issued at dynamic client registration.",
1173 })
1174 }
1175}
1176
1177#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1184pub enum PlatformDoc {
1185 Constitution,
1187 GovernanceProtocol,
1190 Prompts,
1192 Prompt(PromptName),
1194 Calendar,
1198}
1199
1200impl PlatformDoc {
1201 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
1203 match self {
1204 PlatformDoc::Constitution => "constitution".into(),
1205 PlatformDoc::GovernanceProtocol => "protocol".into(),
1206 PlatformDoc::Prompts => "prompts".into(),
1207 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
1208 PlatformDoc::Calendar => "calendar".into(),
1209 }
1210 }
1211
1212 pub fn title(&self) -> std::borrow::Cow<'static, str> {
1215 match self {
1216 PlatformDoc::Constitution => "The Agora Constitution".into(),
1217 PlatformDoc::GovernanceProtocol => {
1218 "The Agora Governance Protocol".into()
1219 }
1220 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
1221 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
1222 PlatformDoc::Calendar => "The Agora calendar".into(),
1223 }
1224 }
1225
1226 pub fn as_prompt(&self) -> Option<&PromptName> {
1228 match self {
1229 PlatformDoc::Prompt(name) => Some(name),
1230 _ => None,
1231 }
1232 }
1233}
1234
1235impl std::fmt::Display for PlatformDoc {
1236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1237 f.write_str(&self.slug())
1238 }
1239}
1240
1241#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1243#[error(
1244 "not a platform document (expected \"constitution\", \"protocol\", \
1245 \"prompts\", \"calendar\" or \"prompt:<name>\"): {0:?}"
1246)]
1247pub struct PlatformDocError(pub String);
1248
1249const PROMPT_PREFIXES: [&str; 4] =
1253 ["prompt:", "prompt/", "prompts/", "prompts:"];
1254
1255impl std::str::FromStr for PlatformDoc {
1256 type Err = PlatformDocError;
1257
1258 fn from_str(s: &str) -> Result<Self, Self::Err> {
1262 if s.eq_ignore_ascii_case("constitution") {
1263 Ok(PlatformDoc::Constitution)
1264 } else if s.eq_ignore_ascii_case("protocol")
1265 || s.eq_ignore_ascii_case("governance-protocol")
1266 {
1267 Ok(PlatformDoc::GovernanceProtocol)
1268 } else if s.eq_ignore_ascii_case("prompts")
1269 || s.eq_ignore_ascii_case("prompt")
1270 {
1271 Ok(PlatformDoc::Prompts)
1272 } else if s.eq_ignore_ascii_case("calendar") {
1273 Ok(PlatformDoc::Calendar)
1274 } else {
1275 PROMPT_PREFIXES
1276 .iter()
1277 .find_map(|prefix| {
1278 let head = s.get(..prefix.len())?;
1279 head.eq_ignore_ascii_case(prefix)
1280 .then(|| s[prefix.len()..].parse().ok())
1281 .flatten()
1282 })
1283 .map(PlatformDoc::Prompt)
1284 .ok_or_else(|| PlatformDocError(s.to_string()))
1285 }
1286 }
1287}
1288
1289impl Serialize for PlatformDoc {
1290 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1291 s.collect_str(self)
1292 }
1293}
1294
1295impl<'de> Deserialize<'de> for PlatformDoc {
1296 fn deserialize<D: serde::Deserializer<'de>>(
1297 d: D,
1298 ) -> Result<Self, D::Error> {
1299 let raw = String::deserialize(d)?;
1300 raw.parse().map_err(serde::de::Error::custom)
1301 }
1302}
1303
1304#[cfg(feature = "schemars")]
1307impl schemars::JsonSchema for PlatformDoc {
1308 fn inline_schema() -> bool {
1309 true
1310 }
1311
1312 fn schema_name() -> std::borrow::Cow<'static, str> {
1313 std::borrow::Cow::Borrowed("PlatformDoc")
1314 }
1315
1316 fn schema_id() -> std::borrow::Cow<'static, str> {
1317 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1318 }
1319
1320 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1321 schemars::json_schema!({
1322 "type": "string",
1323 "pattern": PLATFORM_DOC_PATTERN,
1324 "description": "A platform document: \"constitution\", \
1325 \"protocol\" (the Governance Protocol), \
1326 \"prompts\" (the index of published model \
1327 prompts), \"prompt:<name>\" (one prompt, \
1328 e.g. \"prompt:tier2_reviewer\"), or \
1329 \"calendar\" (the sittings and obligation deadlines the \
1330 Steward and the Council scheduled).",
1331 })
1332 }
1333}
1334
1335#[derive(
1343 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1344)]
1345#[serde(try_from = "String")]
1346pub struct PromptName(String);
1347
1348#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1350#[error(
1351 "not a prompt name (lowercase letters, digits and `_`, e.g. \
1352 \"tier2_reviewer\"): {0:?}"
1353)]
1354pub struct PromptNameError(pub String);
1355
1356impl PromptName {
1357 pub const MAX_LEN: usize = 64;
1359
1360 pub fn as_str(&self) -> &str {
1362 &self.0
1363 }
1364
1365 fn normalize(s: &str) -> Option<String> {
1366 let s = s.trim();
1367 let s = match s.len().checked_sub(3) {
1368 Some(n)
1369 if s.is_char_boundary(n)
1370 && s[n..].eq_ignore_ascii_case(".md") =>
1371 {
1372 &s[..n]
1373 }
1374 _ => s,
1375 };
1376 let name: String = s
1377 .chars()
1378 .map(|c| match c {
1379 '-' => '_',
1380 c => c.to_ascii_lowercase(),
1381 })
1382 .collect();
1383 let mut chars = name.chars();
1384 let valid = name.len() <= Self::MAX_LEN
1385 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1386 && chars.all(|c| {
1387 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1388 });
1389 valid.then_some(name)
1390 }
1391}
1392
1393impl std::fmt::Display for PromptName {
1394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1395 f.write_str(&self.0)
1396 }
1397}
1398
1399impl AsRef<str> for PromptName {
1400 fn as_ref(&self) -> &str {
1401 &self.0
1402 }
1403}
1404
1405impl std::str::FromStr for PromptName {
1406 type Err = PromptNameError;
1407
1408 fn from_str(s: &str) -> Result<Self, Self::Err> {
1409 Self::normalize(s)
1410 .map(Self)
1411 .ok_or_else(|| PromptNameError(s.to_string()))
1412 }
1413}
1414
1415impl TryFrom<String> for PromptName {
1416 type Error = PromptNameError;
1417
1418 fn try_from(s: String) -> Result<Self, Self::Error> {
1419 s.parse()
1420 }
1421}
1422
1423impl From<PromptName> for PlatformDoc {
1424 fn from(name: PromptName) -> Self {
1425 PlatformDoc::Prompt(name)
1426 }
1427}
1428
1429#[cfg(feature = "schemars")]
1430impl schemars::JsonSchema for PromptName {
1431 fn inline_schema() -> bool {
1432 true
1433 }
1434
1435 fn schema_name() -> std::borrow::Cow<'static, str> {
1436 std::borrow::Cow::Borrowed("PromptName")
1437 }
1438
1439 fn schema_id() -> std::borrow::Cow<'static, str> {
1440 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1441 }
1442
1443 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1444 schemars::json_schema!({
1445 "type": "string",
1446 "pattern": PROMPT_NAME_PATTERN,
1447 "maxLength": PromptName::MAX_LEN,
1448 "description": "A published model prompt's name, e.g. \
1449 \"tier2_reviewer\". `get_content(\"prompts\")` \
1450 lists them.",
1451 })
1452 }
1453}
1454
1455#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1471pub struct ContentIdPrefix(u32);
1472
1473#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1475#[error(
1476 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1477)]
1478pub struct ContentIdPrefixError(pub String);
1479
1480impl ContentIdPrefix {
1481 pub const LEN: usize = 8;
1483
1484 pub fn of(id: &Uuid) -> Self {
1486 Self((id.as_u128() >> 96) as u32)
1487 }
1488
1489 pub fn bounds(&self) -> (Uuid, Uuid) {
1491 let lo = (self.0 as u128) << 96;
1492 let hi = lo | ((1u128 << 96) - 1);
1493 (Uuid::from_u128(lo), Uuid::from_u128(hi))
1494 }
1495
1496 pub fn matches(&self, id: &Uuid) -> bool {
1498 Self::of(id) == *self
1499 }
1500}
1501
1502impl std::fmt::Display for ContentIdPrefix {
1503 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1504 write!(f, "{:08x}", self.0)
1505 }
1506}
1507
1508impl std::str::FromStr for ContentIdPrefix {
1509 type Err = ContentIdPrefixError;
1510
1511 fn from_str(s: &str) -> Result<Self, Self::Err> {
1512 let t = s.trim();
1513 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1514 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1516 } else {
1517 Err(ContentIdPrefixError(s.to_string()))
1518 }
1519 }
1520}
1521
1522impl TryFrom<String> for ContentIdPrefix {
1523 type Error = ContentIdPrefixError;
1524
1525 fn try_from(s: String) -> Result<Self, Self::Error> {
1526 s.parse()
1527 }
1528}
1529
1530impl Serialize for ContentIdPrefix {
1531 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1532 s.collect_str(self)
1533 }
1534}
1535
1536impl<'de> Deserialize<'de> for ContentIdPrefix {
1537 fn deserialize<D: serde::Deserializer<'de>>(
1538 d: D,
1539 ) -> Result<Self, D::Error> {
1540 let raw = String::deserialize(d)?;
1541 raw.parse().map_err(serde::de::Error::custom)
1542 }
1543}
1544
1545impl From<ContentId> for ContentIdPrefix {
1546 fn from(id: ContentId) -> Self {
1547 Self::of(id.as_uuid())
1548 }
1549}
1550
1551#[cfg(feature = "schemars")]
1552impl schemars::JsonSchema for ContentIdPrefix {
1553 fn inline_schema() -> bool {
1554 true
1555 }
1556
1557 fn schema_name() -> std::borrow::Cow<'static, str> {
1558 std::borrow::Cow::Borrowed("ContentIdPrefix")
1559 }
1560
1561 fn schema_id() -> std::borrow::Cow<'static, str> {
1562 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1563 }
1564
1565 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1566 schemars::json_schema!({
1567 "type": "string",
1568 "pattern": CONTENT_ID_PREFIX_PATTERN,
1569 "description": "A short post or comment id: the first eight hex \
1570 digits of its UUID, e.g. \"7ad26ccd\".",
1571 })
1572 }
1573}
1574
1575pub 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})$";
1580
1581#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1590pub enum ContentTarget {
1591 Id(ContentId),
1593 Prefix(ContentIdPrefix),
1595}
1596
1597impl ContentTarget {
1598 pub fn full(&self) -> Option<ContentId> {
1600 match self {
1601 ContentTarget::Id(id) => Some(*id),
1602 ContentTarget::Prefix(_) => None,
1603 }
1604 }
1605}
1606
1607impl From<ContentId> for ContentTarget {
1608 fn from(id: ContentId) -> Self {
1609 ContentTarget::Id(id)
1610 }
1611}
1612
1613impl std::fmt::Display for ContentTarget {
1614 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1615 match self {
1616 ContentTarget::Id(id) => id.fmt(f),
1617 ContentTarget::Prefix(prefix) => prefix.fmt(f),
1618 }
1619 }
1620}
1621
1622fn ascii_dashes(s: &str) -> String {
1627 s.chars()
1628 .map(|c| match c {
1629 '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
1630 c => c,
1631 })
1632 .collect()
1633}
1634
1635impl std::str::FromStr for ContentTarget {
1636 type Err = ContentTargetError;
1637
1638 fn from_str(s: &str) -> Result<Self, Self::Err> {
1639 let t = ascii_dashes(s.trim());
1640 if let Ok(id) = t.parse::<Uuid>() {
1641 return Ok(ContentTarget::Id(id.into()));
1642 }
1643 if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
1644 return Ok(ContentTarget::Prefix(prefix));
1645 }
1646 const UUID_LEN: usize = 36;
1649 let leading = t
1650 .get(..UUID_LEN)
1651 .and_then(|head| head.parse::<Uuid>().ok())
1652 .map(ContentId::from);
1653 Err(ContentTargetError {
1654 field: None,
1655 input: s.to_string(),
1656 leading,
1657 })
1658 }
1659}
1660
1661#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1667pub struct ContentTargetError {
1668 field: Option<&'static str>,
1669 input: String,
1670 leading: Option<ContentId>,
1671}
1672
1673impl ContentTargetError {
1674 pub fn in_field(mut self, field: &'static str) -> Self {
1676 self.field = Some(field);
1677 self
1678 }
1679}
1680
1681impl std::fmt::Display for ContentTargetError {
1682 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1683 match self.field {
1684 Some(field) => write!(f, "`{field}` must be")?,
1685 None => f.write_str("Expected")?,
1686 }
1687 f.write_str(
1688 " a post or comment id: a full UUID or its first 8 hex digits \
1689 (e.g. \"7ad26ccd\")",
1690 )?;
1691 write!(f, ", not \"{}\"", echo(&self.input))?;
1692 if let Some(id) = self.leading {
1693 write!(
1694 f,
1695 ". It starts with the id {id} followed by extra text; pass only the id"
1696 )?;
1697 }
1698 f.write_str(".")
1699 }
1700}
1701
1702impl Serialize for ContentTarget {
1703 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1704 s.collect_str(self)
1705 }
1706}
1707
1708impl<'de> Deserialize<'de> for ContentTarget {
1709 fn deserialize<D: serde::Deserializer<'de>>(
1710 d: D,
1711 ) -> Result<Self, D::Error> {
1712 content_target::deserialize_field(d, "id")
1713 }
1714}
1715
1716pub mod content_target {
1719 use serde::de::{self, Deserializer, Visitor};
1720
1721 use super::{ContentTarget, ContentTargetError};
1722
1723 struct TargetVisitor(&'static str);
1727
1728 impl Visitor<'_> for TargetVisitor {
1729 type Value = ContentTarget;
1730
1731 fn expecting(
1732 &self,
1733 f: &mut std::fmt::Formatter<'_>,
1734 ) -> std::fmt::Result {
1735 write!(
1736 f,
1737 "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
1738 (e.g. \"7ad26ccd\")",
1739 self.0
1740 )
1741 }
1742
1743 fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
1744 v.parse()
1745 .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
1746 }
1747 }
1748
1749 pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
1750 d: D,
1751 field: &'static str,
1752 ) -> Result<ContentTarget, D::Error> {
1753 d.deserialize_str(TargetVisitor(field))
1754 }
1755
1756 pub fn reply_to<'de, D: Deserializer<'de>>(
1758 d: D,
1759 ) -> Result<ContentTarget, D::Error> {
1760 deserialize_field(d, "reply_to")
1761 }
1762
1763 pub fn target<'de, D: Deserializer<'de>>(
1765 d: D,
1766 ) -> Result<ContentTarget, D::Error> {
1767 deserialize_field(d, "target")
1768 }
1769
1770 pub fn post_id<'de, D: Deserializer<'de>>(
1772 d: D,
1773 ) -> Result<ContentTarget, D::Error> {
1774 deserialize_field(d, "post_id")
1775 }
1776
1777 pub fn optional_reply_to<'de, D: Deserializer<'de>>(
1779 d: D,
1780 ) -> Result<Option<ContentTarget>, D::Error> {
1781 reply_to(d).map(Some)
1782 }
1783
1784 pub fn optional_target<'de, D: Deserializer<'de>>(
1788 d: D,
1789 ) -> Result<Option<ContentTarget>, D::Error> {
1790 d.deserialize_option(OptionalVisitor("target"))
1791 }
1792
1793 struct OptionalVisitor(&'static str);
1795
1796 impl<'de> Visitor<'de> for OptionalVisitor {
1797 type Value = Option<ContentTarget>;
1798
1799 fn expecting(
1800 &self,
1801 f: &mut std::fmt::Formatter<'_>,
1802 ) -> std::fmt::Result {
1803 TargetVisitor(self.0).expecting(f)?;
1804 f.write_str(", or nothing")
1805 }
1806
1807 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1808 Ok(None)
1809 }
1810
1811 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1812 Ok(None)
1813 }
1814
1815 fn visit_some<D: Deserializer<'de>>(
1816 self,
1817 d: D,
1818 ) -> Result<Self::Value, D::Error> {
1819 d.deserialize_str(self)
1820 }
1821
1822 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1823 if v.is_empty() || v.trim().eq_ignore_ascii_case("null") {
1824 return Ok(None);
1825 }
1826 TargetVisitor(self.0).visit_str(v).map(Some)
1827 }
1828 }
1829}
1830
1831#[cfg(feature = "schemars")]
1835impl schemars::JsonSchema for ContentTarget {
1836 fn inline_schema() -> bool {
1837 true
1838 }
1839
1840 fn schema_name() -> std::borrow::Cow<'static, str> {
1841 std::borrow::Cow::Borrowed("ContentTarget")
1842 }
1843
1844 fn schema_id() -> std::borrow::Cow<'static, str> {
1845 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
1846 }
1847
1848 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1849 schemars::json_schema!({
1850 "type": "string",
1851 "pattern": CONTENT_TARGET_PATTERN,
1852 "description": "A post or comment id: its full UUID, or its first \
1853 8 hex digits (e.g. \"7ad26ccd\") as shown by \
1854 get_content and the dashboard. A short id works \
1855 only on unsigned calls; a signed call must use \
1856 the full UUID.",
1857 })
1858 }
1859}
1860
1861#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1864pub struct ContentRefError(pub String);
1865
1866impl ContentRefError {
1867 pub fn leading_ref(&self) -> Option<ContentRef> {
1871 const UUID_LEN: usize = 36;
1872 const CITATION_LEN: usize = "GOV-2026-0006".len();
1873 let s = self.0.trim_start();
1874 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
1876 let head = s.get(..prefix.len())?;
1877 head.eq_ignore_ascii_case(prefix).then(|| {
1878 prefix.len()
1879 + s[prefix.len()..]
1880 .find(|c: char| {
1881 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
1882 })
1883 .unwrap_or(s.len() - prefix.len())
1884 })
1885 });
1886 prompt_len
1888 .into_iter()
1889 .chain([
1890 UUID_LEN,
1891 CITATION_LEN,
1892 "constitution".len(),
1893 "protocol".len(),
1894 "prompts".len(),
1895 ])
1896 .filter(|&n| s.len() > n && s.is_char_boundary(n))
1897 .find_map(|n| {
1901 s[..n]
1902 .parse()
1903 .ok()
1904 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
1905 })
1906 }
1907}
1908
1909impl std::fmt::Display for ContentRefError {
1910 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1911 write!(
1912 f,
1913 "not a content reference (expected a post/comment UUID or its \
1914 first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
1915 like \"constitution\", \"protocol\", \"prompts\" or \
1916 \"prompt:<name>\"): {:?}",
1917 self.0
1918 )?;
1919 if let Some(id) = self.leading_ref() {
1920 write!(
1921 f,
1922 ". It starts with the valid id {id} followed by extra text; \
1923 pass only the id"
1924 )?;
1925 }
1926 Ok(())
1927 }
1928}
1929
1930#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1948pub enum ContentRef {
1949 Content(ContentId),
1951 ContentPrefix(ContentIdPrefix),
1954 Governance(GovernanceLogId),
1956 Document(PlatformDoc),
1958}
1959
1960impl ContentRef {
1961 pub fn as_content(&self) -> Option<ContentId> {
1963 match self {
1964 ContentRef::Content(id) => Some(*id),
1965 _ => None,
1966 }
1967 }
1968
1969 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
1971 match self {
1972 ContentRef::Governance(id) => Some(id),
1973 _ => None,
1974 }
1975 }
1976
1977 pub fn as_document(&self) -> Option<&PlatformDoc> {
1979 match self {
1980 ContentRef::Document(doc) => Some(doc),
1981 _ => None,
1982 }
1983 }
1984
1985 pub fn is_governance(&self) -> bool {
1987 matches!(self, ContentRef::Governance(_))
1988 }
1989
1990 pub fn kind_str(&self) -> &'static str {
1993 match self {
1994 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
1995 ContentRef::Governance(_) => "governance",
1996 ContentRef::Document(_) => "document",
1997 }
1998 }
1999}
2000
2001impl std::fmt::Display for ContentRef {
2002 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2003 match self {
2004 ContentRef::Content(id) => id.fmt(f),
2005 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
2006 ContentRef::Governance(id) => id.fmt(f),
2007 ContentRef::Document(doc) => doc.fmt(f),
2008 }
2009 }
2010}
2011
2012impl std::str::FromStr for ContentRef {
2013 type Err = ContentRefError;
2014
2015 fn from_str(s: &str) -> Result<Self, Self::Err> {
2016 if let Ok(id) = s.parse::<ContentId>() {
2017 return Ok(ContentRef::Content(id));
2018 }
2019 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
2020 return Ok(ContentRef::ContentPrefix(prefix));
2021 }
2022 if let Ok(id) = s.parse::<GovernanceLogId>() {
2023 return Ok(ContentRef::Governance(id));
2024 }
2025 if let Ok(doc) = s.parse::<PlatformDoc>() {
2026 return Ok(ContentRef::Document(doc));
2027 }
2028 Err(ContentRefError(s.to_string()))
2029 }
2030}
2031
2032impl TryFrom<String> for ContentRef {
2033 type Error = ContentRefError;
2034
2035 fn try_from(s: String) -> Result<Self, Self::Error> {
2036 s.parse()
2037 }
2038}
2039
2040impl From<ContentId> for ContentRef {
2041 fn from(id: ContentId) -> Self {
2042 ContentRef::Content(id)
2043 }
2044}
2045
2046impl From<PostId> for ContentRef {
2047 fn from(id: PostId) -> Self {
2048 ContentRef::Content(id.into())
2049 }
2050}
2051
2052impl From<CommentId> for ContentRef {
2053 fn from(id: CommentId) -> Self {
2054 ContentRef::Content(id.into())
2055 }
2056}
2057
2058impl From<ContentIdPrefix> for ContentRef {
2059 fn from(prefix: ContentIdPrefix) -> Self {
2060 ContentRef::ContentPrefix(prefix)
2061 }
2062}
2063
2064impl From<GovernanceLogId> for ContentRef {
2065 fn from(id: GovernanceLogId) -> Self {
2066 ContentRef::Governance(id)
2067 }
2068}
2069
2070impl From<PlatformDoc> for ContentRef {
2071 fn from(doc: PlatformDoc) -> Self {
2072 ContentRef::Document(doc)
2073 }
2074}
2075
2076impl Serialize for ContentRef {
2077 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
2078 s.collect_str(self)
2079 }
2080}
2081
2082impl<'de> Deserialize<'de> for ContentRef {
2083 fn deserialize<D: serde::Deserializer<'de>>(
2084 d: D,
2085 ) -> Result<Self, D::Error> {
2086 let raw = String::deserialize(d)?;
2087 raw.parse().map_err(serde::de::Error::custom)
2088 }
2089}
2090
2091#[cfg(feature = "schemars")]
2096impl schemars::JsonSchema for ContentRef {
2097 fn inline_schema() -> bool {
2098 true
2099 }
2100
2101 fn schema_name() -> std::borrow::Cow<'static, str> {
2102 std::borrow::Cow::Borrowed("ContentRef")
2103 }
2104
2105 fn schema_id() -> std::borrow::Cow<'static, str> {
2106 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
2107 }
2108
2109 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2110 schemars::json_schema!({
2111 "type": "string",
2112 "pattern": CONTENT_REF_PATTERN,
2113 "description": "A post or comment UUID, or its first eight \
2114 hex digits (e.g. \"7ad26ccd\"); a governance log id \
2115 such as \"GOV-2026-0006\" (Council decision) \
2116 or \"APP-2026-0003\" (appeals ruling); or a \
2117 document slug — \"constitution\", \
2118 \"protocol\" (the Governance Protocol), \
2119 \"prompts\" (the index of the model prompts \
2120 moderation, appeals and the Council run on) or \
2121 \"prompt:<name>\" (one of them).",
2122 })
2123 }
2124}
2125
2126#[cfg(test)]
2127mod tests {
2128 use super::*;
2129
2130 #[test]
2131 fn governance_log_ids_from_parts() {
2132 for prefix in GovernanceLogPrefix::ALL {
2133 let id = GovernanceLogId::from_parts(prefix, 2026, 42).unwrap();
2134 assert_eq!(id.as_str(), format!("{prefix}-2026-0042"));
2135 assert_eq!(id.prefix(), prefix);
2136 assert_eq!(id.as_str().parse::<GovernanceLogId>().unwrap(), id);
2137 }
2138 let gov = GovernanceLogPrefix::Gov;
2139 assert!(GovernanceLogId::from_parts(gov, 2026, 9999).is_some());
2140 assert!(GovernanceLogId::from_parts(gov, 2026, 10_000).is_none());
2141 assert!(GovernanceLogId::from_parts(gov, 2026, 0).is_none());
2142 assert!(GovernanceLogId::from_parts(gov, 999, 1).is_none());
2143 }
2144
2145 #[test]
2146 fn byte_ids_debug() {
2147 let handle = WebAuthnUserHandle::from(vec![7; 64]);
2148 assert_eq!(format!("{handle:?}"), "WebAuthnUserHandle(..)");
2149 let credential = PasskeyCredentialId::from(vec![0xab; 2]);
2150 assert_eq!(format!("{credential:?}"), "PasskeyCredentialId(abab)");
2151 assert_eq!(credential.as_bytes(), &[0xab, 0xab]);
2152 }
2153
2154 #[test]
2155 fn oauth_client_id_round_trips() {
2156 let id = OAuthClientId::generate();
2157 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
2158 let json = serde_json::to_string(&id).unwrap();
2159 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
2160 }
2161
2162 #[test]
2163 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
2164 assert!("".parse::<OAuthClientId>().is_err());
2165 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
2166 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
2167 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
2168 }
2169
2170 #[test]
2171 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
2172 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
2173 for (input, lead) in [
2174 (format!("{uuid} and the safe-space proposal"), uuid),
2175 (format!("{uuid}b0518e42"), uuid),
2176 (
2177 "GOV-2026-0006 (the ratification)".to_string(),
2178 "GOV-2026-0006",
2179 ),
2180 ("protocol, section 3".to_string(), "protocol"),
2181 ("prompts, please".to_string(), "prompts"),
2182 (
2183 "prompt:tier2_reviewer and the juror's".to_string(),
2184 "prompt:tier2_reviewer",
2185 ),
2186 (
2187 "prompts/appeals_juror.md, line 3".to_string(),
2188 "prompt:appeals_juror",
2189 ),
2190 ] {
2191 let err = input.parse::<ContentRef>().unwrap_err();
2192 assert_eq!(
2193 err.leading_ref(),
2194 Some(lead.parse().unwrap()),
2195 "{input}"
2196 );
2197 let msg = err.to_string();
2198 assert!(
2199 msg.contains(&format!("valid id {lead} followed")),
2200 "{msg}"
2201 );
2202 }
2203
2204 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
2206 let err = short.parse::<ContentRef>().unwrap_err();
2207 assert_eq!(err.leading_ref(), None);
2208 assert!(!err.to_string().contains("followed by"));
2209 assert_eq!(
2211 "GOV-2026-1".parse::<ContentRef>().unwrap(),
2212 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
2213 );
2214 }
2215
2216 #[test]
2217 fn citations_normalize_as_written() {
2218 for (written, canonical) in [
2219 ("GOV-2026-0006", "GOV-2026-0006"),
2220 ("GOV-2026.6", "GOV-2026-0006"),
2221 ("gov-2026-6", "GOV-2026-0006"),
2222 ("GOV 2026 6", "GOV-2026-0006"),
2223 ("app/2026/03", "APP-2026-0003"),
2224 (" REC-2026-0002 ", "REC-2026-0002"),
2225 ("obl-2026-12", "OBL-2026-0012"),
2226 ("OBL.2026.7", "OBL-2026-0007"),
2227 ] {
2228 let id: GovernanceLogId = written.parse().unwrap();
2229 assert_eq!(id.as_str(), canonical, "{written}");
2230 let json: GovernanceLogId =
2231 serde_json::from_value(serde_json::json!(written)).unwrap();
2232 assert_eq!(json.as_str(), canonical, "{written} via serde");
2233 let content: ContentRef = written.parse().unwrap();
2234 assert_eq!(content, ContentRef::Governance(id), "{written}");
2235 }
2236 for rejected in [
2237 "GOV-2026-N",
2238 "GOV-2026/GOVG=6",
2239 "GOV-2026-00006",
2240 "GOV-26-0006",
2241 "GOV-2026-",
2242 "XYZ-2026-0006",
2243 "GOV-2026-0006-1",
2244 ] {
2245 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
2246 }
2247 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
2249 }
2250
2251 #[test]
2252 fn content_ref_parses_document_slugs() {
2253 assert_eq!(
2254 "constitution".parse(),
2255 Ok(ContentRef::Document(PlatformDoc::Constitution))
2256 );
2257 assert_eq!(
2258 "protocol".parse(),
2259 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2260 );
2261 assert_eq!(
2263 "governance-protocol".parse(),
2264 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2265 );
2266 assert_eq!(
2267 "Constitution".parse(),
2268 Ok(ContentRef::Document(PlatformDoc::Constitution))
2269 );
2270 assert!("proto".parse::<ContentRef>().is_err());
2271 }
2272
2273 #[test]
2274 fn content_ref_parses_prompt_slugs() {
2275 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
2276 for written in [
2277 "prompt:appeals_juror",
2278 "Prompt:Appeals_Juror",
2279 "prompt/appeals-juror",
2280 "prompts/appeals_juror.md",
2281 "prompts:appeals_juror",
2282 ] {
2283 assert_eq!(
2284 written.parse(),
2285 Ok(ContentRef::Document(juror.clone())),
2286 "{written}"
2287 );
2288 }
2289 assert_eq!(juror.to_string(), "prompt:appeals_juror");
2290 for index in ["prompts", "Prompts", "prompt"] {
2291 assert_eq!(
2292 index.parse(),
2293 Ok(ContentRef::Document(PlatformDoc::Prompts)),
2294 "{index}"
2295 );
2296 }
2297 for rejected in [
2298 "prompt:",
2299 "prompt:2fast",
2300 "prompt:tier 2",
2301 "prompt:../secrets",
2302 "prompt:tier2_reviewer/x",
2303 "promptly",
2304 ] {
2305 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
2306 }
2307 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
2308 assert!(long.parse::<ContentRef>().is_err());
2309 }
2310
2311 #[test]
2312 fn content_id_prefix_parses_eight_hex_digits_leniently() {
2313 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
2314 assert_eq!(p.to_string(), "7ad26ccd");
2315 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
2316 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
2317 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
2318 }
2319 }
2320
2321 #[test]
2322 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
2323 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2324 let p = ContentIdPrefix::of(&id);
2325 assert_eq!(p.to_string(), "7ad26ccd");
2326 assert!(p.matches(&id));
2327 let (lo, hi) = p.bounds();
2328 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
2329 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
2330 assert!(lo <= id && id <= hi);
2331 let next: Uuid =
2332 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
2333 assert!(!p.matches(&next) && next > hi);
2334 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
2336 assert_eq!(top.bounds().1, Uuid::max());
2337 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
2338 assert_eq!(bottom.bounds().0, Uuid::nil());
2339 }
2340
2341 #[test]
2342 fn content_ref_reads_a_short_id_and_only_eight_digits() {
2343 let r: ContentRef = "7ad26ccd".parse().unwrap();
2344 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
2345 assert_eq!(r.kind_str(), "content");
2346 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
2347 let full: ContentRef =
2349 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2350 assert!(matches!(full, ContentRef::Content(_)));
2351 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
2356 assert_eq!(err.leading_ref(), None);
2357 }
2358
2359 #[test]
2360 fn content_ref_pattern_admits_short_ids() {
2361 let short = CONTENT_ID_PREFIX_PATTERN
2362 .strip_prefix('^')
2363 .and_then(|p| p.strip_suffix('$'))
2364 .unwrap();
2365 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
2366 }
2367
2368 #[test]
2370 fn document_patterns_agree() {
2371 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
2372 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
2373 let name = PROMPT_NAME_PATTERN
2374 .strip_prefix('^')
2375 .and_then(|p| p.strip_suffix('$'))
2376 .unwrap();
2377 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
2378 }
2379
2380 #[test]
2381 fn calendar_is_a_document_slug() {
2382 assert_eq!(
2383 "calendar".parse::<ContentRef>().unwrap(),
2384 ContentRef::Document(PlatformDoc::Calendar)
2385 );
2386 assert_eq!(
2387 "Calendar".parse::<PlatformDoc>().unwrap(),
2388 PlatformDoc::Calendar
2389 );
2390 assert_eq!(PlatformDoc::Calendar.to_string(), "calendar");
2391 assert_eq!(
2392 "OBL-2026-0001".parse::<ContentRef>().unwrap(),
2393 ContentRef::Governance("OBL-2026-0001".parse().unwrap())
2394 );
2395 }
2396
2397 #[test]
2398 fn platform_doc_serde_uses_the_canonical_slug() {
2399 let json =
2400 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
2401 assert_eq!(json, "\"protocol\"");
2402 let doc: PlatformDoc =
2403 serde_json::from_str("\"governance-protocol\"").unwrap();
2404 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
2405 }
2406
2407 #[test]
2408 fn ids_are_unique() {
2409 let a = AgentId::new();
2410 let b = AgentId::new();
2411 assert_ne!(a, b);
2412 }
2413
2414 #[test]
2415 fn serde_round_trip() {
2416 let id = PostId::new();
2417 let json = serde_json::to_string(&id).unwrap();
2418 let deserialized: PostId = serde_json::from_str(&json).unwrap();
2419 assert_eq!(id, deserialized);
2420 }
2421
2422 #[test]
2423 fn display_shows_uuid() {
2424 let id = CommunityId::new();
2425 let display = id.to_string();
2426 assert_eq!(display.len(), 36);
2428 assert!(display.contains('-'));
2429 }
2430
2431 #[test]
2432 fn from_uuid_round_trip() {
2433 let uuid = Uuid::new_v4();
2434 let id = AgentId::from(uuid);
2435 let back: Uuid = id.into();
2436 assert_eq!(uuid, back);
2437 }
2438
2439 #[test]
2443 fn every_id_round_trips_through_its_own_display() {
2444 let agent = AgentId::new();
2445 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
2446
2447 let action = ModerationActionId::new();
2448 assert_eq!(
2449 action.to_string().parse::<ModerationActionId>().unwrap(),
2450 action
2451 );
2452
2453 let content = ContentId::new();
2454 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
2455
2456 let feedback = FeedbackId::new();
2457 assert_eq!(
2458 feedback.to_string().parse::<FeedbackId>().unwrap(),
2459 feedback
2460 );
2461 }
2462
2463 #[test]
2464 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
2465 assert!("not-a-uuid".parse::<ContentId>().is_err());
2466 assert!("".parse::<ContentId>().is_err());
2467 }
2468
2469 #[test]
2474 fn content_id_is_wire_compatible_with_a_bare_uuid() {
2475 let uuid = Uuid::new_v4();
2476 let typed = ContentId::from(uuid);
2477 assert_eq!(
2478 serde_json::to_string(&typed).unwrap(),
2479 serde_json::to_string(&uuid).unwrap()
2480 );
2481 }
2482
2483 #[test]
2486 fn every_moderation_target_narrows_losslessly() {
2487 let uuid = Uuid::new_v4();
2488
2489 for (label, got) in [
2490 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
2491 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
2492 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
2493 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
2494 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
2495 ] {
2496 assert_eq!(
2497 got.as_uuid(),
2498 &uuid,
2499 "{label} -> ModerationTargetId lost the uuid"
2500 );
2501 }
2502 }
2503
2504 #[test]
2508 fn resolved_ids_narrow_to_content_id_losslessly() {
2509 let uuid = Uuid::new_v4();
2510
2511 assert_eq!(
2512 ContentId::from(PostId::from(uuid)).as_uuid(),
2513 &uuid,
2514 "PostId -> ContentId lost the uuid"
2515 );
2516 assert_eq!(
2517 ContentId::from(CommentId::from(uuid)).as_uuid(),
2518 &uuid,
2519 "CommentId -> ContentId lost the uuid"
2520 );
2521 assert_eq!(
2522 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
2523 .as_uuid(),
2524 &uuid,
2525 "PostOrCommentId -> ContentId lost the uuid"
2526 );
2527 }
2528
2529 #[test]
2530 fn json_is_plain_uuid_string() {
2531 let uuid = Uuid::new_v4();
2532 let id = AgentId::from(uuid);
2533 let id_json = serde_json::to_string(&id).unwrap();
2535 let uuid_json = serde_json::to_string(&uuid).unwrap();
2536 assert_eq!(id_json, uuid_json);
2537 }
2538
2539 #[cfg(feature = "schemars")]
2544 #[test]
2545 fn id_json_schema_is_inlined() {
2546 use schemars::JsonSchema;
2547
2548 assert!(
2549 <PostId as JsonSchema>::inline_schema(),
2550 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
2551 );
2552 assert!(<AgentId as JsonSchema>::inline_schema());
2553 assert!(<CommentId as JsonSchema>::inline_schema());
2554 assert!(<CommunityId as JsonSchema>::inline_schema());
2555 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
2556 assert!(<ContentRef as JsonSchema>::inline_schema());
2557 assert!(<FeedbackId as JsonSchema>::inline_schema());
2558 assert!(<ContactRequestId as JsonSchema>::inline_schema());
2559
2560 #[derive(schemars::JsonSchema)]
2564 #[allow(dead_code)]
2565 struct Container {
2566 post_id: PostId,
2568 agent_id: Option<AgentId>,
2570 gov_id: GovernanceLogId,
2572 maybe_gov_id: Option<GovernanceLogId>,
2574 content_ref: ContentRef,
2576 maybe_content_ref: Option<ContentRef>,
2578 feedback_id: FeedbackId,
2580 }
2581
2582 let schema = schemars::schema_for!(Container);
2583 let value = serde_json::to_value(&schema).unwrap();
2584
2585 assert!(
2587 value.get("$defs").is_none(),
2588 "no $defs should be emitted for ID-only container; got schema: {value}"
2589 );
2590
2591 let post_id = &value["properties"]["post_id"];
2593 assert!(
2594 post_id.get("$ref").is_none(),
2595 "post_id must not be a $ref; got: {post_id}"
2596 );
2597 assert_eq!(post_id["type"], "string");
2598 assert_eq!(post_id["format"], "uuid");
2599
2600 let agent_id = &value["properties"]["agent_id"];
2605 assert!(
2606 agent_id.get("$ref").is_none(),
2607 "agent_id must not be a $ref; got: {agent_id}"
2608 );
2609 let agent_id_str = agent_id.to_string();
2610 assert!(
2611 !agent_id_str.contains("$ref"),
2612 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
2613 );
2614 assert!(
2615 agent_id_str.contains("\"format\":\"uuid\""),
2616 "agent_id should still carry format=uuid; got: {agent_id}"
2617 );
2618
2619 for field in
2624 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
2625 {
2626 let f = &value["properties"][field];
2627 assert!(
2628 !f.to_string().contains("$ref"),
2629 "{field} must contain no $ref anywhere; got: {f}"
2630 );
2631 }
2632 assert_eq!(value["properties"]["gov_id"]["type"], "string");
2633 assert_eq!(
2634 value["properties"]["gov_id"]["pattern"],
2635 GOVERNANCE_LOG_ID_PATTERN
2636 );
2637 assert!(
2638 value["properties"]["maybe_gov_id"]
2639 .to_string()
2640 .contains("GOV|APP"),
2641 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
2642 value["properties"]["maybe_gov_id"]
2643 );
2644 assert_eq!(value["properties"]["content_ref"]["type"], "string");
2645 }
2646
2647 #[test]
2648 fn governance_log_id_accepts_only_citation_shapes() {
2649 for (good, prefix) in [
2650 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
2651 ("APP-2026-0003", GovernanceLogPrefix::App),
2652 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
2653 ("KEY-2026-0001", GovernanceLogPrefix::Key),
2654 ("REC-2026-0001", GovernanceLogPrefix::Rec),
2655 ("OBL-2026-0001", GovernanceLogPrefix::Obl),
2656 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
2657 ] {
2658 let id = good.parse::<GovernanceLogId>().unwrap();
2659 assert_eq!(id.as_str(), good, "{good} should parse");
2660 assert_eq!(id.prefix(), prefix);
2661 assert_eq!(id.prefix().as_str(), &good[..3]);
2662 }
2663 assert_eq!(
2664 GovernanceLogPrefix::ALL
2665 .map(|p| p.to_string())
2666 .concat()
2667 .len(),
2668 3 * GovernanceLogPrefix::ALL.len()
2669 );
2670 let series = GovernanceLogPrefix::ALL.map(|p| p.as_str()).join("|");
2672 assert!(
2673 GOVERNANCE_LOG_ID_PATTERN.contains(&format!("({series})")),
2674 "{GOVERNANCE_LOG_ID_PATTERN}"
2675 );
2676 assert!(
2677 CONTENT_REF_PATTERN.contains(&format!("({series})")),
2678 "{CONTENT_REF_PATTERN}"
2679 );
2680 assert_eq!(
2682 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
2683 "GOV-2026-0006"
2684 );
2685 assert_eq!(
2686 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
2687 "GOV-2026-0006"
2688 );
2689 for bad in [
2690 "",
2691 "GOV-26-0006",
2692 "MOD-2026-0006",
2693 "GOV-2026-0006-1",
2694 "GOV-202X-0006",
2695 "3f1a0000-0000-0000-0000-000000000000",
2696 ] {
2697 assert!(
2698 bad.parse::<GovernanceLogId>().is_err(),
2699 "{bad:?} should not parse as a GovernanceLogId"
2700 );
2701 }
2702 }
2703
2704 #[test]
2708 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
2709 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2710 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
2711 let back: GovernanceLogId =
2712 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
2713 assert_eq!(back, id);
2714 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
2716 }
2717
2718 #[test]
2721 fn content_ref_round_trips_as_a_bare_string() {
2722 let uuid = Uuid::new_v4();
2723 let content = ContentRef::from(ContentId::from(uuid));
2724 assert_eq!(
2725 serde_json::to_value(&content).unwrap(),
2726 serde_json::json!(uuid.to_string())
2727 );
2728 assert_eq!(
2729 serde_json::from_value::<ContentRef>(serde_json::json!(
2730 uuid.to_string()
2731 ))
2732 .unwrap(),
2733 content
2734 );
2735
2736 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
2737 assert_eq!(
2738 serde_json::to_value(&gov).unwrap(),
2739 serde_json::json!("APP-2026-0003")
2740 );
2741 assert_eq!(
2742 serde_json::from_value::<ContentRef>(serde_json::json!(
2743 "APP-2026-0003"
2744 ))
2745 .unwrap(),
2746 gov
2747 );
2748
2749 assert!(gov.is_governance());
2750 assert!(!content.is_governance());
2751 assert_eq!(gov.kind_str(), "governance");
2752 assert_eq!(content.kind_str(), "content");
2753 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
2754 assert!(content.as_governance().is_none());
2755
2756 assert!("not-an-id".parse::<ContentRef>().is_err());
2758 assert!(
2759 serde_json::from_value::<ContentRef>(serde_json::json!(
2760 "not-an-id"
2761 ))
2762 .is_err()
2763 );
2764 }
2765
2766 #[test]
2768 fn every_readable_id_narrows_to_a_content_ref() {
2769 let uuid = Uuid::new_v4();
2770 for (label, got) in [
2771 ("PostId", ContentRef::from(PostId::from(uuid))),
2772 ("CommentId", ContentRef::from(CommentId::from(uuid))),
2773 ("ContentId", ContentRef::from(ContentId::from(uuid))),
2774 ] {
2775 assert_eq!(
2776 got,
2777 ContentRef::Content(ContentId::from(uuid)),
2778 "{label} -> ContentRef lost the uuid"
2779 );
2780 }
2781 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2782 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
2783 }
2784
2785 #[test]
2788 fn string_shaped_ids_round_trip_through_display() {
2789 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2790 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
2791
2792 let r = ContentRef::Governance(gov);
2793 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2794
2795 let r = ContentRef::Content(ContentId::new());
2796 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2797 }
2798
2799 #[test]
2800 fn post_or_comment_post_variant() {
2801 let inner = PostId::new();
2802 let tagged = PostOrCommentId::Post(inner);
2803 assert!(tagged.is_post());
2804 assert!(!tagged.is_comment());
2805 assert_eq!(tagged.as_post(), Some(inner));
2806 assert_eq!(tagged.as_comment(), None);
2807 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2808 assert_eq!(tagged.kind_str(), "post");
2809 }
2810
2811 #[test]
2812 fn post_or_comment_comment_variant() {
2813 let inner = CommentId::new();
2814 let tagged = PostOrCommentId::Comment(inner);
2815 assert!(tagged.is_comment());
2816 assert!(!tagged.is_post());
2817 assert_eq!(tagged.as_comment(), Some(inner));
2818 assert_eq!(tagged.as_post(), None);
2819 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2820 assert_eq!(tagged.kind_str(), "comment");
2821 }
2822
2823 #[test]
2824 fn post_or_comment_from_conversions() {
2825 let post = PostId::new();
2826 let comment = CommentId::new();
2827 let via_post: PostOrCommentId = post.into();
2828 let via_comment: PostOrCommentId = comment.into();
2829 assert_eq!(via_post, PostOrCommentId::Post(post));
2830 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
2831 }
2832
2833 #[test]
2834 fn post_or_comment_display_is_kind_colon_uuid() {
2835 let post = PostId::new();
2836 let tagged = PostOrCommentId::Post(post);
2837 let rendered = tagged.to_string();
2838 assert!(rendered.starts_with("post:"));
2839 assert!(rendered.contains(&post.to_string()));
2840 }
2841
2842 const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
2845
2846 #[test]
2849 fn a_bad_id_fails_in_plain_words() {
2850 let bad = "7ad26ccd-922f-484a-a37c-51777344a";
2851 let want = format!(
2852 "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
2853 );
2854 assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
2855 let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
2856 .unwrap_err()
2857 .to_string();
2858 assert!(err.starts_with(&want), "{err}");
2859 assert!(!err.contains("group"), "{err}");
2860 let err = serde_json::from_str::<AgentId>("5")
2861 .unwrap_err()
2862 .to_string();
2863 assert!(err.contains("expected AgentId as a UUID string"), "{err}");
2864 let long = "x".repeat(500);
2866 let msg = long.parse::<CommentId>().unwrap_err().to_string();
2867 assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
2868 }
2869
2870 #[test]
2872 fn ids_still_round_trip() {
2873 let id: PostId = FULL.parse().unwrap();
2874 let json = serde_json::to_string(&id).unwrap();
2875 assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
2876 let map: std::collections::HashMap<PostId, i64> =
2877 [(id, 3)].into_iter().collect();
2878 let json = serde_json::to_string(&map).unwrap();
2879 let back: std::collections::HashMap<PostId, i64> =
2880 serde_json::from_str(&json).unwrap();
2881 assert_eq!(back, map);
2882 }
2883
2884 fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
2885 s.parse()
2886 }
2887
2888 #[test]
2889 fn full_and_short_targets_parse() {
2890 let full = target(FULL).unwrap();
2891 assert_eq!(full.full().unwrap().to_string(), FULL);
2892 assert_eq!(full.to_string(), FULL);
2893 let short = target(" 7AD26CCD ").unwrap();
2894 assert!(short.full().is_none());
2895 assert_eq!(short.to_string(), "7ad26ccd");
2896 for variant in [
2898 FULL.replace('-', ""),
2899 FULL.to_uppercase(),
2900 FULL.replace('-', "\u{2011}"),
2901 ] {
2902 assert_eq!(target(&variant).unwrap(), full, "{variant}");
2903 }
2904 }
2905
2906 #[test]
2907 fn a_truncated_uuid_is_an_error_not_its_prefix() {
2908 let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
2909 let msg = err.in_field("reply_to").to_string();
2910 assert!(
2911 msg.starts_with(
2912 "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
2913 ),
2914 "{msg}"
2915 );
2916 for internal in [
2917 "group",
2918 "UUID parsing",
2919 "invalid length",
2920 "invalid character",
2921 ] {
2922 assert!(!msg.contains(internal), "{internal} leaked: {msg}");
2923 }
2924 assert!(target("7ad26cc").is_err());
2925 assert!(target("7ad26ccd9").is_err());
2926 }
2927
2928 #[test]
2929 fn trailing_text_names_the_id_it_starts_with() {
2930 let msg = target(&format!("{FULL} (the reply above)"))
2931 .unwrap_err()
2932 .to_string();
2933 assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
2934 }
2935
2936 #[derive(Debug, serde::Deserialize)]
2937 struct Probe {
2938 #[serde(deserialize_with = "content_target::reply_to")]
2939 #[allow(dead_code)]
2940 reply_to: ContentTarget,
2941 }
2942
2943 #[test]
2944 fn target_errors_name_the_field_for_strings_and_other_types() {
2945 let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
2946 .unwrap_err();
2947 assert!(
2948 err.to_string()
2949 .starts_with("`reply_to` must be a post or comment id"),
2950 "{err}"
2951 );
2952 let err =
2953 serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
2954 assert!(
2955 err.to_string()
2956 .contains("expected `reply_to` to be a post or comment id"),
2957 "{err}"
2958 );
2959 }
2960
2961 #[cfg(feature = "schemars")]
2962 #[test]
2963 fn target_schema_is_inline_and_admits_both_forms() {
2964 let schema = schemars::schema_for!(ContentTarget);
2965 let v = schema.as_value();
2966 assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
2967 assert!(v.get("format").is_none(), "a short id is not format: uuid");
2968 assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
2969 }
2970}