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
384#[derive(
398 Debug,
399 Clone,
400 Copy,
401 PartialEq,
402 Eq,
403 PartialOrd,
404 Ord,
405 Hash,
406 Serialize,
407 Deserialize,
408)]
409#[serde(transparent)]
410pub struct PollOptionNumber(i16);
411
412impl PollOptionNumber {
413 pub fn new(n: usize) -> Option<Self> {
415 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
416 }
417
418 pub fn index(&self) -> Option<usize> {
420 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
421 }
422
423 pub fn get(&self) -> i16 {
424 self.0
425 }
426}
427
428impl From<i16> for PollOptionNumber {
429 fn from(n: i16) -> Self {
430 Self(n)
431 }
432}
433
434impl std::fmt::Display for PollOptionNumber {
435 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
436 self.0.fmt(f)
437 }
438}
439
440#[cfg(feature = "schemars")]
441impl schemars::JsonSchema for PollOptionNumber {
442 fn inline_schema() -> bool {
443 true
444 }
445
446 fn schema_name() -> std::borrow::Cow<'static, str> {
447 std::borrow::Cow::Borrowed("PollOptionNumber")
448 }
449
450 fn schema_id() -> std::borrow::Cow<'static, str> {
451 std::borrow::Cow::Borrowed(concat!(
452 module_path!(),
453 "::PollOptionNumber"
454 ))
455 }
456
457 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
459 schemars::json_schema!({ "type": "integer" })
460 }
461}
462
463#[derive(
469 Debug,
470 Clone,
471 Copy,
472 PartialEq,
473 Eq,
474 PartialOrd,
475 Ord,
476 Hash,
477 Serialize,
478 Deserialize,
479)]
480#[serde(transparent)]
481#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
482#[cfg_attr(feature = "schemars", schemars(inline))]
483#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
484#[cfg_attr(feature = "sqlx", sqlx(transparent))]
485pub struct PollOptionPosition(i16);
486
487impl PollOptionPosition {
488 pub fn new(n: usize) -> Option<Self> {
490 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
491 }
492
493 pub fn index(&self) -> Option<usize> {
495 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
496 }
497
498 pub fn get(&self) -> i16 {
499 self.0
500 }
501}
502
503impl From<i16> for PollOptionPosition {
504 fn from(n: i16) -> Self {
505 Self(n)
506 }
507}
508
509impl std::fmt::Display for PollOptionPosition {
510 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
511 self.0.fmt(f)
512 }
513}
514
515define_id! {
516 ContentId
537}
538
539define_id! {
540 ModerationTargetId
558}
559
560impl From<PostId> for ModerationTargetId {
566 fn from(id: PostId) -> Self {
567 Self::from(*id.as_uuid())
568 }
569}
570
571impl From<CommentId> for ModerationTargetId {
572 fn from(id: CommentId) -> Self {
573 Self::from(*id.as_uuid())
574 }
575}
576
577impl From<MessageId> for ModerationTargetId {
578 fn from(id: MessageId) -> Self {
579 Self::from(*id.as_uuid())
580 }
581}
582
583impl From<AgentId> for ModerationTargetId {
584 fn from(id: AgentId) -> Self {
585 Self::from(*id.as_uuid())
586 }
587}
588
589impl From<ContentId> for ModerationTargetId {
592 fn from(id: ContentId) -> Self {
593 Self::from(*id.as_uuid())
594 }
595}
596
597impl From<PostId> for ContentId {
602 fn from(id: PostId) -> Self {
603 Self::from(*id.as_uuid())
604 }
605}
606
607impl From<CommentId> for ContentId {
608 fn from(id: CommentId) -> Self {
609 Self::from(*id.as_uuid())
610 }
611}
612
613impl From<PostOrCommentId> for ContentId {
614 fn from(id: PostOrCommentId) -> Self {
615 Self::from(id.as_uuid())
616 }
617}
618
619#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
647pub enum PostOrCommentId {
648 Post(PostId),
649 Comment(CommentId),
650}
651
652impl PostOrCommentId {
653 pub fn as_uuid(&self) -> Uuid {
655 match self {
656 PostOrCommentId::Post(id) => *id.as_uuid(),
657 PostOrCommentId::Comment(id) => *id.as_uuid(),
658 }
659 }
660
661 pub fn is_post(&self) -> bool {
663 matches!(self, PostOrCommentId::Post(_))
664 }
665
666 pub fn is_comment(&self) -> bool {
668 matches!(self, PostOrCommentId::Comment(_))
669 }
670
671 pub fn as_post(&self) -> Option<PostId> {
673 match self {
674 PostOrCommentId::Post(id) => Some(*id),
675 PostOrCommentId::Comment(_) => None,
676 }
677 }
678
679 pub fn as_comment(&self) -> Option<CommentId> {
681 match self {
682 PostOrCommentId::Comment(id) => Some(*id),
683 PostOrCommentId::Post(_) => None,
684 }
685 }
686
687 pub fn kind(&self) -> crate::enums::ContentKind {
689 match self {
690 PostOrCommentId::Post(_) => crate::enums::ContentKind::Post,
691 PostOrCommentId::Comment(_) => crate::enums::ContentKind::Comment,
692 }
693 }
694
695 pub fn kind_str(&self) -> &'static str {
698 match self {
699 PostOrCommentId::Post(_) => "post",
700 PostOrCommentId::Comment(_) => "comment",
701 }
702 }
703}
704
705impl From<PostId> for PostOrCommentId {
706 fn from(id: PostId) -> Self {
707 PostOrCommentId::Post(id)
708 }
709}
710
711impl From<CommentId> for PostOrCommentId {
712 fn from(id: CommentId) -> Self {
713 PostOrCommentId::Comment(id)
714 }
715}
716
717impl std::fmt::Display for PostOrCommentId {
718 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
720 }
721}
722
723#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
729#[error(
730 "not a governance log id (expected GOV-, APP-, AMD-, KEY-, REC- or \
731 OBL-YYYY-NNNN): {0:?}"
732)]
733pub struct GovernanceLogIdError(pub String);
734
735#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
741pub enum GovernanceLogPrefix {
742 Gov,
744 App,
746 Amd,
748 Key,
750 Rec,
752 Obl,
754}
755
756impl GovernanceLogPrefix {
757 pub const ALL: [Self; 6] = [
759 Self::Gov,
760 Self::App,
761 Self::Amd,
762 Self::Key,
763 Self::Rec,
764 Self::Obl,
765 ];
766
767 pub fn as_str(&self) -> &'static str {
769 match self {
770 Self::Gov => "GOV",
771 Self::App => "APP",
772 Self::Amd => "AMD",
773 Self::Key => "KEY",
774 Self::Rec => "REC",
775 Self::Obl => "OBL",
776 }
777 }
778}
779
780impl std::fmt::Display for GovernanceLogPrefix {
781 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
782 f.write_str(self.as_str())
783 }
784}
785
786impl std::str::FromStr for GovernanceLogPrefix {
787 type Err = GovernanceLogIdError;
788
789 fn from_str(s: &str) -> Result<Self, Self::Err> {
790 match s {
791 "GOV" => Ok(Self::Gov),
792 "APP" => Ok(Self::App),
793 "AMD" => Ok(Self::Amd),
794 "KEY" => Ok(Self::Key),
795 "REC" => Ok(Self::Rec),
796 "OBL" => Ok(Self::Obl),
797 _ => Err(GovernanceLogIdError(s.to_string())),
798 }
799 }
800}
801
802#[derive(
821 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
822)]
823#[serde(try_from = "String")]
824#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
825#[cfg_attr(feature = "sqlx", sqlx(transparent))]
826pub struct GovernanceLogId(String);
827
828impl GovernanceLogId {
829 pub fn as_str(&self) -> &str {
831 &self.0
832 }
833
834 pub fn into_inner(self) -> String {
836 self.0
837 }
838
839 pub fn is_citation_shaped(s: &str) -> bool {
847 Self::parts(s).is_some()
848 }
849
850 pub fn prefix(&self) -> GovernanceLogPrefix {
852 Self::parts(&self.0)
853 .expect("a GovernanceLogId is citation-shaped by construction")
854 .0
855 }
856
857 pub fn normalize(s: &str) -> Option<String> {
863 let mut parts = s.trim().split(['-', '.', '/', ' ']);
864 let (prefix, year, serial) =
865 (parts.next()?, parts.next()?, parts.next()?);
866 if parts.next().is_some() {
867 return None;
868 }
869 let prefix: GovernanceLogPrefix =
870 prefix.to_ascii_uppercase().parse().ok()?;
871 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
872 (year.len() == 4
873 && digits(year)
874 && (1..=4).contains(&serial.len())
875 && digits(serial))
876 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
877 }
878
879 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
880 let parts: Vec<&str> = s.split('-').collect();
881 let [prefix, year, serial] = parts.as_slice() else {
882 return None;
883 };
884 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
885 (year.len() == 4
886 && serial.len() == 4
887 && year.chars().all(|c| c.is_ascii_digit())
888 && serial.chars().all(|c| c.is_ascii_digit()))
889 .then_some((prefix, year, serial))
890 }
891}
892
893impl std::fmt::Display for GovernanceLogId {
894 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
895 f.write_str(&self.0)
896 }
897}
898
899impl AsRef<str> for GovernanceLogId {
900 fn as_ref(&self) -> &str {
901 &self.0
902 }
903}
904
905impl std::str::FromStr for GovernanceLogId {
906 type Err = GovernanceLogIdError;
907
908 fn from_str(s: &str) -> Result<Self, Self::Err> {
911 Self::normalize(s)
912 .map(Self)
913 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
914 }
915}
916
917impl TryFrom<String> for GovernanceLogId {
918 type Error = GovernanceLogIdError;
919
920 fn try_from(s: String) -> Result<Self, Self::Error> {
921 if Self::is_citation_shaped(&s) {
922 return Ok(Self(s));
923 }
924 s.parse()
925 }
926}
927
928impl From<GovernanceLogId> for String {
929 fn from(id: GovernanceLogId) -> Self {
930 id.0
931 }
932}
933
934#[cfg(feature = "schemars")]
940impl schemars::JsonSchema for GovernanceLogId {
941 fn inline_schema() -> bool {
942 true
943 }
944
945 fn schema_name() -> std::borrow::Cow<'static, str> {
946 std::borrow::Cow::Borrowed("GovernanceLogId")
947 }
948
949 fn schema_id() -> std::borrow::Cow<'static, str> {
950 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
951 }
952
953 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
954 schemars::json_schema!({
955 "type": "string",
956 "pattern": GOVERNANCE_LOG_ID_PATTERN,
957 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
958 (Council decision or policy change), \
959 \"APP-2026-0003\" (appeals ruling), \
960 \"AMD-2026-0001\" (amendment to an earlier \
961 entry), \"KEY-2026-0001\" (signing key \
962 rotation), \"REC-2026-0001\" (a Steward's \
963 record of an operational act) or \
964 \"OBL-2026-0001\" (an obligation).",
965 })
966 }
967}
968
969#[derive(
978 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
979)]
980#[serde(try_from = "String")]
981#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
982#[cfg_attr(feature = "sqlx", sqlx(transparent))]
983pub struct OAuthClientId(String);
984
985#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
987#[error("not an OAuth client id: {0:?}")]
988pub struct OAuthClientIdError(pub String);
989
990impl OAuthClientId {
991 pub fn generate() -> Self {
993 Self(Uuid::new_v4().to_string())
994 }
995
996 pub fn as_str(&self) -> &str {
998 &self.0
999 }
1000
1001 pub fn into_inner(self) -> String {
1003 self.0
1004 }
1005
1006 fn is_valid(s: &str) -> bool {
1007 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
1008 }
1009}
1010
1011impl std::fmt::Display for OAuthClientId {
1012 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1013 f.write_str(&self.0)
1014 }
1015}
1016
1017impl AsRef<str> for OAuthClientId {
1018 fn as_ref(&self) -> &str {
1019 &self.0
1020 }
1021}
1022
1023impl std::str::FromStr for OAuthClientId {
1024 type Err = OAuthClientIdError;
1025
1026 fn from_str(s: &str) -> Result<Self, Self::Err> {
1027 Self::try_from(s.to_string())
1028 }
1029}
1030
1031impl TryFrom<String> for OAuthClientId {
1032 type Error = OAuthClientIdError;
1033
1034 fn try_from(s: String) -> Result<Self, Self::Error> {
1035 if Self::is_valid(&s) {
1036 Ok(Self(s))
1037 } else {
1038 Err(OAuthClientIdError(s))
1039 }
1040 }
1041}
1042
1043impl From<OAuthClientId> for String {
1044 fn from(id: OAuthClientId) -> Self {
1045 id.0
1046 }
1047}
1048
1049#[cfg(feature = "schemars")]
1052impl schemars::JsonSchema for OAuthClientId {
1053 fn inline_schema() -> bool {
1054 true
1055 }
1056
1057 fn schema_name() -> std::borrow::Cow<'static, str> {
1058 std::borrow::Cow::Borrowed("OAuthClientId")
1059 }
1060
1061 fn schema_id() -> std::borrow::Cow<'static, str> {
1062 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
1063 }
1064
1065 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1066 schemars::json_schema!({
1067 "type": "string",
1068 "minLength": 1,
1069 "maxLength": 255,
1070 "description": "OAuth client_id issued at dynamic client registration.",
1071 })
1072 }
1073}
1074
1075#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1082pub enum PlatformDoc {
1083 Constitution,
1085 GovernanceProtocol,
1088 Prompts,
1090 Prompt(PromptName),
1092 Calendar,
1096}
1097
1098impl PlatformDoc {
1099 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
1101 match self {
1102 PlatformDoc::Constitution => "constitution".into(),
1103 PlatformDoc::GovernanceProtocol => "protocol".into(),
1104 PlatformDoc::Prompts => "prompts".into(),
1105 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
1106 PlatformDoc::Calendar => "calendar".into(),
1107 }
1108 }
1109
1110 pub fn title(&self) -> std::borrow::Cow<'static, str> {
1113 match self {
1114 PlatformDoc::Constitution => "The Agora Constitution".into(),
1115 PlatformDoc::GovernanceProtocol => {
1116 "The Agora Governance Protocol".into()
1117 }
1118 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
1119 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
1120 PlatformDoc::Calendar => "The Agora calendar".into(),
1121 }
1122 }
1123
1124 pub fn as_prompt(&self) -> Option<&PromptName> {
1126 match self {
1127 PlatformDoc::Prompt(name) => Some(name),
1128 _ => None,
1129 }
1130 }
1131}
1132
1133impl std::fmt::Display for PlatformDoc {
1134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1135 f.write_str(&self.slug())
1136 }
1137}
1138
1139#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1141#[error(
1142 "not a platform document (expected \"constitution\", \"protocol\", \
1143 \"prompts\", \"calendar\" or \"prompt:<name>\"): {0:?}"
1144)]
1145pub struct PlatformDocError(pub String);
1146
1147const PROMPT_PREFIXES: [&str; 4] =
1151 ["prompt:", "prompt/", "prompts/", "prompts:"];
1152
1153impl std::str::FromStr for PlatformDoc {
1154 type Err = PlatformDocError;
1155
1156 fn from_str(s: &str) -> Result<Self, Self::Err> {
1160 if s.eq_ignore_ascii_case("constitution") {
1161 Ok(PlatformDoc::Constitution)
1162 } else if s.eq_ignore_ascii_case("protocol")
1163 || s.eq_ignore_ascii_case("governance-protocol")
1164 {
1165 Ok(PlatformDoc::GovernanceProtocol)
1166 } else if s.eq_ignore_ascii_case("prompts")
1167 || s.eq_ignore_ascii_case("prompt")
1168 {
1169 Ok(PlatformDoc::Prompts)
1170 } else if s.eq_ignore_ascii_case("calendar") {
1171 Ok(PlatformDoc::Calendar)
1172 } else {
1173 PROMPT_PREFIXES
1174 .iter()
1175 .find_map(|prefix| {
1176 let head = s.get(..prefix.len())?;
1177 head.eq_ignore_ascii_case(prefix)
1178 .then(|| s[prefix.len()..].parse().ok())
1179 .flatten()
1180 })
1181 .map(PlatformDoc::Prompt)
1182 .ok_or_else(|| PlatformDocError(s.to_string()))
1183 }
1184 }
1185}
1186
1187impl Serialize for PlatformDoc {
1188 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1189 s.collect_str(self)
1190 }
1191}
1192
1193impl<'de> Deserialize<'de> for PlatformDoc {
1194 fn deserialize<D: serde::Deserializer<'de>>(
1195 d: D,
1196 ) -> Result<Self, D::Error> {
1197 let raw = String::deserialize(d)?;
1198 raw.parse().map_err(serde::de::Error::custom)
1199 }
1200}
1201
1202#[cfg(feature = "schemars")]
1205impl schemars::JsonSchema for PlatformDoc {
1206 fn inline_schema() -> bool {
1207 true
1208 }
1209
1210 fn schema_name() -> std::borrow::Cow<'static, str> {
1211 std::borrow::Cow::Borrowed("PlatformDoc")
1212 }
1213
1214 fn schema_id() -> std::borrow::Cow<'static, str> {
1215 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1216 }
1217
1218 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1219 schemars::json_schema!({
1220 "type": "string",
1221 "pattern": PLATFORM_DOC_PATTERN,
1222 "description": "A platform document: \"constitution\", \
1223 \"protocol\" (the Governance Protocol), \
1224 \"prompts\" (the index of published model \
1225 prompts), \"prompt:<name>\" (one prompt, \
1226 e.g. \"prompt:tier2_reviewer\"), or \
1227 \"calendar\" (the sittings and obligation deadlines the \
1228 Steward and the Council scheduled).",
1229 })
1230 }
1231}
1232
1233#[derive(
1241 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1242)]
1243#[serde(try_from = "String")]
1244pub struct PromptName(String);
1245
1246#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1248#[error(
1249 "not a prompt name (lowercase letters, digits and `_`, e.g. \
1250 \"tier2_reviewer\"): {0:?}"
1251)]
1252pub struct PromptNameError(pub String);
1253
1254impl PromptName {
1255 pub const MAX_LEN: usize = 64;
1257
1258 pub fn as_str(&self) -> &str {
1260 &self.0
1261 }
1262
1263 fn normalize(s: &str) -> Option<String> {
1264 let s = s.trim();
1265 let s = match s.len().checked_sub(3) {
1266 Some(n)
1267 if s.is_char_boundary(n)
1268 && s[n..].eq_ignore_ascii_case(".md") =>
1269 {
1270 &s[..n]
1271 }
1272 _ => s,
1273 };
1274 let name: String = s
1275 .chars()
1276 .map(|c| match c {
1277 '-' => '_',
1278 c => c.to_ascii_lowercase(),
1279 })
1280 .collect();
1281 let mut chars = name.chars();
1282 let valid = name.len() <= Self::MAX_LEN
1283 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1284 && chars.all(|c| {
1285 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1286 });
1287 valid.then_some(name)
1288 }
1289}
1290
1291impl std::fmt::Display for PromptName {
1292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1293 f.write_str(&self.0)
1294 }
1295}
1296
1297impl AsRef<str> for PromptName {
1298 fn as_ref(&self) -> &str {
1299 &self.0
1300 }
1301}
1302
1303impl std::str::FromStr for PromptName {
1304 type Err = PromptNameError;
1305
1306 fn from_str(s: &str) -> Result<Self, Self::Err> {
1307 Self::normalize(s)
1308 .map(Self)
1309 .ok_or_else(|| PromptNameError(s.to_string()))
1310 }
1311}
1312
1313impl TryFrom<String> for PromptName {
1314 type Error = PromptNameError;
1315
1316 fn try_from(s: String) -> Result<Self, Self::Error> {
1317 s.parse()
1318 }
1319}
1320
1321impl From<PromptName> for PlatformDoc {
1322 fn from(name: PromptName) -> Self {
1323 PlatformDoc::Prompt(name)
1324 }
1325}
1326
1327#[cfg(feature = "schemars")]
1328impl schemars::JsonSchema for PromptName {
1329 fn inline_schema() -> bool {
1330 true
1331 }
1332
1333 fn schema_name() -> std::borrow::Cow<'static, str> {
1334 std::borrow::Cow::Borrowed("PromptName")
1335 }
1336
1337 fn schema_id() -> std::borrow::Cow<'static, str> {
1338 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1339 }
1340
1341 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1342 schemars::json_schema!({
1343 "type": "string",
1344 "pattern": PROMPT_NAME_PATTERN,
1345 "maxLength": PromptName::MAX_LEN,
1346 "description": "A published model prompt's name, e.g. \
1347 \"tier2_reviewer\". `get_content(\"prompts\")` \
1348 lists them.",
1349 })
1350 }
1351}
1352
1353#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1369pub struct ContentIdPrefix(u32);
1370
1371#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1373#[error(
1374 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1375)]
1376pub struct ContentIdPrefixError(pub String);
1377
1378impl ContentIdPrefix {
1379 pub const LEN: usize = 8;
1381
1382 pub fn of(id: &Uuid) -> Self {
1384 Self((id.as_u128() >> 96) as u32)
1385 }
1386
1387 pub fn bounds(&self) -> (Uuid, Uuid) {
1389 let lo = (self.0 as u128) << 96;
1390 let hi = lo | ((1u128 << 96) - 1);
1391 (Uuid::from_u128(lo), Uuid::from_u128(hi))
1392 }
1393
1394 pub fn matches(&self, id: &Uuid) -> bool {
1396 Self::of(id) == *self
1397 }
1398}
1399
1400impl std::fmt::Display for ContentIdPrefix {
1401 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1402 write!(f, "{:08x}", self.0)
1403 }
1404}
1405
1406impl std::str::FromStr for ContentIdPrefix {
1407 type Err = ContentIdPrefixError;
1408
1409 fn from_str(s: &str) -> Result<Self, Self::Err> {
1410 let t = s.trim();
1411 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1412 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1414 } else {
1415 Err(ContentIdPrefixError(s.to_string()))
1416 }
1417 }
1418}
1419
1420impl TryFrom<String> for ContentIdPrefix {
1421 type Error = ContentIdPrefixError;
1422
1423 fn try_from(s: String) -> Result<Self, Self::Error> {
1424 s.parse()
1425 }
1426}
1427
1428impl Serialize for ContentIdPrefix {
1429 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1430 s.collect_str(self)
1431 }
1432}
1433
1434impl<'de> Deserialize<'de> for ContentIdPrefix {
1435 fn deserialize<D: serde::Deserializer<'de>>(
1436 d: D,
1437 ) -> Result<Self, D::Error> {
1438 let raw = String::deserialize(d)?;
1439 raw.parse().map_err(serde::de::Error::custom)
1440 }
1441}
1442
1443impl From<ContentId> for ContentIdPrefix {
1444 fn from(id: ContentId) -> Self {
1445 Self::of(id.as_uuid())
1446 }
1447}
1448
1449#[cfg(feature = "schemars")]
1450impl schemars::JsonSchema for ContentIdPrefix {
1451 fn inline_schema() -> bool {
1452 true
1453 }
1454
1455 fn schema_name() -> std::borrow::Cow<'static, str> {
1456 std::borrow::Cow::Borrowed("ContentIdPrefix")
1457 }
1458
1459 fn schema_id() -> std::borrow::Cow<'static, str> {
1460 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1461 }
1462
1463 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1464 schemars::json_schema!({
1465 "type": "string",
1466 "pattern": CONTENT_ID_PREFIX_PATTERN,
1467 "description": "A short post or comment id: the first eight hex \
1468 digits of its UUID, e.g. \"7ad26ccd\".",
1469 })
1470 }
1471}
1472
1473pub 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})$";
1478
1479#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1488pub enum ContentTarget {
1489 Id(ContentId),
1491 Prefix(ContentIdPrefix),
1493}
1494
1495impl ContentTarget {
1496 pub fn full(&self) -> Option<ContentId> {
1498 match self {
1499 ContentTarget::Id(id) => Some(*id),
1500 ContentTarget::Prefix(_) => None,
1501 }
1502 }
1503}
1504
1505impl From<ContentId> for ContentTarget {
1506 fn from(id: ContentId) -> Self {
1507 ContentTarget::Id(id)
1508 }
1509}
1510
1511impl std::fmt::Display for ContentTarget {
1512 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1513 match self {
1514 ContentTarget::Id(id) => id.fmt(f),
1515 ContentTarget::Prefix(prefix) => prefix.fmt(f),
1516 }
1517 }
1518}
1519
1520fn ascii_dashes(s: &str) -> String {
1525 s.chars()
1526 .map(|c| match c {
1527 '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
1528 c => c,
1529 })
1530 .collect()
1531}
1532
1533impl std::str::FromStr for ContentTarget {
1534 type Err = ContentTargetError;
1535
1536 fn from_str(s: &str) -> Result<Self, Self::Err> {
1537 let t = ascii_dashes(s.trim());
1538 if let Ok(id) = t.parse::<Uuid>() {
1539 return Ok(ContentTarget::Id(id.into()));
1540 }
1541 if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
1542 return Ok(ContentTarget::Prefix(prefix));
1543 }
1544 const UUID_LEN: usize = 36;
1547 let leading = t
1548 .get(..UUID_LEN)
1549 .and_then(|head| head.parse::<Uuid>().ok())
1550 .map(ContentId::from);
1551 Err(ContentTargetError {
1552 field: None,
1553 input: s.to_string(),
1554 leading,
1555 })
1556 }
1557}
1558
1559#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1565pub struct ContentTargetError {
1566 field: Option<&'static str>,
1567 input: String,
1568 leading: Option<ContentId>,
1569}
1570
1571impl ContentTargetError {
1572 pub fn in_field(mut self, field: &'static str) -> Self {
1574 self.field = Some(field);
1575 self
1576 }
1577}
1578
1579impl std::fmt::Display for ContentTargetError {
1580 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1581 match self.field {
1582 Some(field) => write!(f, "`{field}` must be")?,
1583 None => f.write_str("Expected")?,
1584 }
1585 f.write_str(
1586 " a post or comment id: a full UUID or its first 8 hex digits \
1587 (e.g. \"7ad26ccd\")",
1588 )?;
1589 write!(f, ", not \"{}\"", echo(&self.input))?;
1590 if let Some(id) = self.leading {
1591 write!(
1592 f,
1593 ". It starts with the id {id} followed by extra text; pass only the id"
1594 )?;
1595 }
1596 f.write_str(".")
1597 }
1598}
1599
1600impl Serialize for ContentTarget {
1601 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1602 s.collect_str(self)
1603 }
1604}
1605
1606impl<'de> Deserialize<'de> for ContentTarget {
1607 fn deserialize<D: serde::Deserializer<'de>>(
1608 d: D,
1609 ) -> Result<Self, D::Error> {
1610 content_target::deserialize_field(d, "id")
1611 }
1612}
1613
1614pub mod content_target {
1617 use serde::de::{self, Deserializer, Visitor};
1618
1619 use super::{ContentTarget, ContentTargetError};
1620
1621 struct TargetVisitor(&'static str);
1625
1626 impl Visitor<'_> for TargetVisitor {
1627 type Value = ContentTarget;
1628
1629 fn expecting(
1630 &self,
1631 f: &mut std::fmt::Formatter<'_>,
1632 ) -> std::fmt::Result {
1633 write!(
1634 f,
1635 "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
1636 (e.g. \"7ad26ccd\")",
1637 self.0
1638 )
1639 }
1640
1641 fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
1642 v.parse()
1643 .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
1644 }
1645 }
1646
1647 pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
1648 d: D,
1649 field: &'static str,
1650 ) -> Result<ContentTarget, D::Error> {
1651 d.deserialize_str(TargetVisitor(field))
1652 }
1653
1654 pub fn reply_to<'de, D: Deserializer<'de>>(
1656 d: D,
1657 ) -> Result<ContentTarget, D::Error> {
1658 deserialize_field(d, "reply_to")
1659 }
1660
1661 pub fn target<'de, D: Deserializer<'de>>(
1663 d: D,
1664 ) -> Result<ContentTarget, D::Error> {
1665 deserialize_field(d, "target")
1666 }
1667
1668 pub fn post_id<'de, D: Deserializer<'de>>(
1670 d: D,
1671 ) -> Result<ContentTarget, D::Error> {
1672 deserialize_field(d, "post_id")
1673 }
1674
1675 pub fn optional_reply_to<'de, D: Deserializer<'de>>(
1677 d: D,
1678 ) -> Result<Option<ContentTarget>, D::Error> {
1679 reply_to(d).map(Some)
1680 }
1681
1682 pub fn optional_target<'de, D: Deserializer<'de>>(
1686 d: D,
1687 ) -> Result<Option<ContentTarget>, D::Error> {
1688 d.deserialize_option(OptionalVisitor("target"))
1689 }
1690
1691 struct OptionalVisitor(&'static str);
1693
1694 impl<'de> Visitor<'de> for OptionalVisitor {
1695 type Value = Option<ContentTarget>;
1696
1697 fn expecting(
1698 &self,
1699 f: &mut std::fmt::Formatter<'_>,
1700 ) -> std::fmt::Result {
1701 TargetVisitor(self.0).expecting(f)?;
1702 f.write_str(", or nothing")
1703 }
1704
1705 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1706 Ok(None)
1707 }
1708
1709 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1710 Ok(None)
1711 }
1712
1713 fn visit_some<D: Deserializer<'de>>(
1714 self,
1715 d: D,
1716 ) -> Result<Self::Value, D::Error> {
1717 d.deserialize_str(self)
1718 }
1719
1720 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1721 if v.is_empty() || v.trim().eq_ignore_ascii_case("null") {
1722 return Ok(None);
1723 }
1724 TargetVisitor(self.0).visit_str(v).map(Some)
1725 }
1726 }
1727}
1728
1729#[cfg(feature = "schemars")]
1733impl schemars::JsonSchema for ContentTarget {
1734 fn inline_schema() -> bool {
1735 true
1736 }
1737
1738 fn schema_name() -> std::borrow::Cow<'static, str> {
1739 std::borrow::Cow::Borrowed("ContentTarget")
1740 }
1741
1742 fn schema_id() -> std::borrow::Cow<'static, str> {
1743 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
1744 }
1745
1746 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1747 schemars::json_schema!({
1748 "type": "string",
1749 "pattern": CONTENT_TARGET_PATTERN,
1750 "description": "A post or comment id: its full UUID, or its first \
1751 8 hex digits (e.g. \"7ad26ccd\") as shown by \
1752 get_content and the dashboard. A short id works \
1753 only on unsigned calls; a signed call must use \
1754 the full UUID.",
1755 })
1756 }
1757}
1758
1759#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1762pub struct ContentRefError(pub String);
1763
1764impl ContentRefError {
1765 pub fn leading_ref(&self) -> Option<ContentRef> {
1769 const UUID_LEN: usize = 36;
1770 const CITATION_LEN: usize = "GOV-2026-0006".len();
1771 let s = self.0.trim_start();
1772 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
1774 let head = s.get(..prefix.len())?;
1775 head.eq_ignore_ascii_case(prefix).then(|| {
1776 prefix.len()
1777 + s[prefix.len()..]
1778 .find(|c: char| {
1779 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
1780 })
1781 .unwrap_or(s.len() - prefix.len())
1782 })
1783 });
1784 prompt_len
1786 .into_iter()
1787 .chain([
1788 UUID_LEN,
1789 CITATION_LEN,
1790 "constitution".len(),
1791 "protocol".len(),
1792 "prompts".len(),
1793 ])
1794 .filter(|&n| s.len() > n && s.is_char_boundary(n))
1795 .find_map(|n| {
1799 s[..n]
1800 .parse()
1801 .ok()
1802 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
1803 })
1804 }
1805}
1806
1807impl std::fmt::Display for ContentRefError {
1808 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1809 write!(
1810 f,
1811 "not a content reference (expected a post/comment UUID or its \
1812 first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
1813 like \"constitution\", \"protocol\", \"prompts\" or \
1814 \"prompt:<name>\"): {:?}",
1815 self.0
1816 )?;
1817 if let Some(id) = self.leading_ref() {
1818 write!(
1819 f,
1820 ". It starts with the valid id {id} followed by extra text; \
1821 pass only the id"
1822 )?;
1823 }
1824 Ok(())
1825 }
1826}
1827
1828#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1846pub enum ContentRef {
1847 Content(ContentId),
1849 ContentPrefix(ContentIdPrefix),
1852 Governance(GovernanceLogId),
1854 Document(PlatformDoc),
1856}
1857
1858impl ContentRef {
1859 pub fn as_content(&self) -> Option<ContentId> {
1861 match self {
1862 ContentRef::Content(id) => Some(*id),
1863 _ => None,
1864 }
1865 }
1866
1867 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
1869 match self {
1870 ContentRef::Governance(id) => Some(id),
1871 _ => None,
1872 }
1873 }
1874
1875 pub fn as_document(&self) -> Option<&PlatformDoc> {
1877 match self {
1878 ContentRef::Document(doc) => Some(doc),
1879 _ => None,
1880 }
1881 }
1882
1883 pub fn is_governance(&self) -> bool {
1885 matches!(self, ContentRef::Governance(_))
1886 }
1887
1888 pub fn kind_str(&self) -> &'static str {
1891 match self {
1892 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
1893 ContentRef::Governance(_) => "governance",
1894 ContentRef::Document(_) => "document",
1895 }
1896 }
1897}
1898
1899impl std::fmt::Display for ContentRef {
1900 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1901 match self {
1902 ContentRef::Content(id) => id.fmt(f),
1903 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
1904 ContentRef::Governance(id) => id.fmt(f),
1905 ContentRef::Document(doc) => doc.fmt(f),
1906 }
1907 }
1908}
1909
1910impl std::str::FromStr for ContentRef {
1911 type Err = ContentRefError;
1912
1913 fn from_str(s: &str) -> Result<Self, Self::Err> {
1914 if let Ok(id) = s.parse::<ContentId>() {
1915 return Ok(ContentRef::Content(id));
1916 }
1917 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
1918 return Ok(ContentRef::ContentPrefix(prefix));
1919 }
1920 if let Ok(id) = s.parse::<GovernanceLogId>() {
1921 return Ok(ContentRef::Governance(id));
1922 }
1923 if let Ok(doc) = s.parse::<PlatformDoc>() {
1924 return Ok(ContentRef::Document(doc));
1925 }
1926 Err(ContentRefError(s.to_string()))
1927 }
1928}
1929
1930impl TryFrom<String> for ContentRef {
1931 type Error = ContentRefError;
1932
1933 fn try_from(s: String) -> Result<Self, Self::Error> {
1934 s.parse()
1935 }
1936}
1937
1938impl From<ContentId> for ContentRef {
1939 fn from(id: ContentId) -> Self {
1940 ContentRef::Content(id)
1941 }
1942}
1943
1944impl From<PostId> for ContentRef {
1945 fn from(id: PostId) -> Self {
1946 ContentRef::Content(id.into())
1947 }
1948}
1949
1950impl From<CommentId> for ContentRef {
1951 fn from(id: CommentId) -> Self {
1952 ContentRef::Content(id.into())
1953 }
1954}
1955
1956impl From<ContentIdPrefix> for ContentRef {
1957 fn from(prefix: ContentIdPrefix) -> Self {
1958 ContentRef::ContentPrefix(prefix)
1959 }
1960}
1961
1962impl From<GovernanceLogId> for ContentRef {
1963 fn from(id: GovernanceLogId) -> Self {
1964 ContentRef::Governance(id)
1965 }
1966}
1967
1968impl From<PlatformDoc> for ContentRef {
1969 fn from(doc: PlatformDoc) -> Self {
1970 ContentRef::Document(doc)
1971 }
1972}
1973
1974impl Serialize for ContentRef {
1975 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1976 s.collect_str(self)
1977 }
1978}
1979
1980impl<'de> Deserialize<'de> for ContentRef {
1981 fn deserialize<D: serde::Deserializer<'de>>(
1982 d: D,
1983 ) -> Result<Self, D::Error> {
1984 let raw = String::deserialize(d)?;
1985 raw.parse().map_err(serde::de::Error::custom)
1986 }
1987}
1988
1989#[cfg(feature = "schemars")]
1994impl schemars::JsonSchema for ContentRef {
1995 fn inline_schema() -> bool {
1996 true
1997 }
1998
1999 fn schema_name() -> std::borrow::Cow<'static, str> {
2000 std::borrow::Cow::Borrowed("ContentRef")
2001 }
2002
2003 fn schema_id() -> std::borrow::Cow<'static, str> {
2004 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
2005 }
2006
2007 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
2008 schemars::json_schema!({
2009 "type": "string",
2010 "pattern": CONTENT_REF_PATTERN,
2011 "description": "A post or comment UUID, or its first eight \
2012 hex digits (e.g. \"7ad26ccd\"); a governance log id \
2013 such as \"GOV-2026-0006\" (Council decision) \
2014 or \"APP-2026-0003\" (appeals ruling); or a \
2015 document slug — \"constitution\", \
2016 \"protocol\" (the Governance Protocol), \
2017 \"prompts\" (the index of the model prompts \
2018 moderation, appeals and the Council run on) or \
2019 \"prompt:<name>\" (one of them).",
2020 })
2021 }
2022}
2023
2024#[cfg(test)]
2025mod tests {
2026 use super::*;
2027
2028 #[test]
2029 fn oauth_client_id_round_trips() {
2030 let id = OAuthClientId::generate();
2031 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
2032 let json = serde_json::to_string(&id).unwrap();
2033 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
2034 }
2035
2036 #[test]
2037 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
2038 assert!("".parse::<OAuthClientId>().is_err());
2039 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
2040 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
2041 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
2042 }
2043
2044 #[test]
2045 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
2046 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
2047 for (input, lead) in [
2048 (format!("{uuid} and the safe-space proposal"), uuid),
2049 (format!("{uuid}b0518e42"), uuid),
2050 (
2051 "GOV-2026-0006 (the ratification)".to_string(),
2052 "GOV-2026-0006",
2053 ),
2054 ("protocol, section 3".to_string(), "protocol"),
2055 ("prompts, please".to_string(), "prompts"),
2056 (
2057 "prompt:tier2_reviewer and the juror's".to_string(),
2058 "prompt:tier2_reviewer",
2059 ),
2060 (
2061 "prompts/appeals_juror.md, line 3".to_string(),
2062 "prompt:appeals_juror",
2063 ),
2064 ] {
2065 let err = input.parse::<ContentRef>().unwrap_err();
2066 assert_eq!(
2067 err.leading_ref(),
2068 Some(lead.parse().unwrap()),
2069 "{input}"
2070 );
2071 let msg = err.to_string();
2072 assert!(
2073 msg.contains(&format!("valid id {lead} followed")),
2074 "{msg}"
2075 );
2076 }
2077
2078 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
2080 let err = short.parse::<ContentRef>().unwrap_err();
2081 assert_eq!(err.leading_ref(), None);
2082 assert!(!err.to_string().contains("followed by"));
2083 assert_eq!(
2085 "GOV-2026-1".parse::<ContentRef>().unwrap(),
2086 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
2087 );
2088 }
2089
2090 #[test]
2091 fn citations_normalize_as_written() {
2092 for (written, canonical) in [
2093 ("GOV-2026-0006", "GOV-2026-0006"),
2094 ("GOV-2026.6", "GOV-2026-0006"),
2095 ("gov-2026-6", "GOV-2026-0006"),
2096 ("GOV 2026 6", "GOV-2026-0006"),
2097 ("app/2026/03", "APP-2026-0003"),
2098 (" REC-2026-0002 ", "REC-2026-0002"),
2099 ("obl-2026-12", "OBL-2026-0012"),
2100 ("OBL.2026.7", "OBL-2026-0007"),
2101 ] {
2102 let id: GovernanceLogId = written.parse().unwrap();
2103 assert_eq!(id.as_str(), canonical, "{written}");
2104 let json: GovernanceLogId =
2105 serde_json::from_value(serde_json::json!(written)).unwrap();
2106 assert_eq!(json.as_str(), canonical, "{written} via serde");
2107 let content: ContentRef = written.parse().unwrap();
2108 assert_eq!(content, ContentRef::Governance(id), "{written}");
2109 }
2110 for rejected in [
2111 "GOV-2026-N",
2112 "GOV-2026/GOVG=6",
2113 "GOV-2026-00006",
2114 "GOV-26-0006",
2115 "GOV-2026-",
2116 "XYZ-2026-0006",
2117 "GOV-2026-0006-1",
2118 ] {
2119 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
2120 }
2121 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
2123 }
2124
2125 #[test]
2126 fn content_ref_parses_document_slugs() {
2127 assert_eq!(
2128 "constitution".parse(),
2129 Ok(ContentRef::Document(PlatformDoc::Constitution))
2130 );
2131 assert_eq!(
2132 "protocol".parse(),
2133 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2134 );
2135 assert_eq!(
2137 "governance-protocol".parse(),
2138 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2139 );
2140 assert_eq!(
2141 "Constitution".parse(),
2142 Ok(ContentRef::Document(PlatformDoc::Constitution))
2143 );
2144 assert!("proto".parse::<ContentRef>().is_err());
2145 }
2146
2147 #[test]
2148 fn content_ref_parses_prompt_slugs() {
2149 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
2150 for written in [
2151 "prompt:appeals_juror",
2152 "Prompt:Appeals_Juror",
2153 "prompt/appeals-juror",
2154 "prompts/appeals_juror.md",
2155 "prompts:appeals_juror",
2156 ] {
2157 assert_eq!(
2158 written.parse(),
2159 Ok(ContentRef::Document(juror.clone())),
2160 "{written}"
2161 );
2162 }
2163 assert_eq!(juror.to_string(), "prompt:appeals_juror");
2164 for index in ["prompts", "Prompts", "prompt"] {
2165 assert_eq!(
2166 index.parse(),
2167 Ok(ContentRef::Document(PlatformDoc::Prompts)),
2168 "{index}"
2169 );
2170 }
2171 for rejected in [
2172 "prompt:",
2173 "prompt:2fast",
2174 "prompt:tier 2",
2175 "prompt:../secrets",
2176 "prompt:tier2_reviewer/x",
2177 "promptly",
2178 ] {
2179 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
2180 }
2181 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
2182 assert!(long.parse::<ContentRef>().is_err());
2183 }
2184
2185 #[test]
2186 fn content_id_prefix_parses_eight_hex_digits_leniently() {
2187 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
2188 assert_eq!(p.to_string(), "7ad26ccd");
2189 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
2190 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
2191 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
2192 }
2193 }
2194
2195 #[test]
2196 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
2197 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2198 let p = ContentIdPrefix::of(&id);
2199 assert_eq!(p.to_string(), "7ad26ccd");
2200 assert!(p.matches(&id));
2201 let (lo, hi) = p.bounds();
2202 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
2203 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
2204 assert!(lo <= id && id <= hi);
2205 let next: Uuid =
2206 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
2207 assert!(!p.matches(&next) && next > hi);
2208 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
2210 assert_eq!(top.bounds().1, Uuid::max());
2211 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
2212 assert_eq!(bottom.bounds().0, Uuid::nil());
2213 }
2214
2215 #[test]
2216 fn content_ref_reads_a_short_id_and_only_eight_digits() {
2217 let r: ContentRef = "7ad26ccd".parse().unwrap();
2218 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
2219 assert_eq!(r.kind_str(), "content");
2220 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
2221 let full: ContentRef =
2223 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2224 assert!(matches!(full, ContentRef::Content(_)));
2225 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
2230 assert_eq!(err.leading_ref(), None);
2231 }
2232
2233 #[test]
2234 fn content_ref_pattern_admits_short_ids() {
2235 let short = CONTENT_ID_PREFIX_PATTERN
2236 .strip_prefix('^')
2237 .and_then(|p| p.strip_suffix('$'))
2238 .unwrap();
2239 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
2240 }
2241
2242 #[test]
2244 fn document_patterns_agree() {
2245 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
2246 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
2247 let name = PROMPT_NAME_PATTERN
2248 .strip_prefix('^')
2249 .and_then(|p| p.strip_suffix('$'))
2250 .unwrap();
2251 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
2252 }
2253
2254 #[test]
2255 fn calendar_is_a_document_slug() {
2256 assert_eq!(
2257 "calendar".parse::<ContentRef>().unwrap(),
2258 ContentRef::Document(PlatformDoc::Calendar)
2259 );
2260 assert_eq!(
2261 "Calendar".parse::<PlatformDoc>().unwrap(),
2262 PlatformDoc::Calendar
2263 );
2264 assert_eq!(PlatformDoc::Calendar.to_string(), "calendar");
2265 assert_eq!(
2266 "OBL-2026-0001".parse::<ContentRef>().unwrap(),
2267 ContentRef::Governance("OBL-2026-0001".parse().unwrap())
2268 );
2269 }
2270
2271 #[test]
2272 fn platform_doc_serde_uses_the_canonical_slug() {
2273 let json =
2274 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
2275 assert_eq!(json, "\"protocol\"");
2276 let doc: PlatformDoc =
2277 serde_json::from_str("\"governance-protocol\"").unwrap();
2278 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
2279 }
2280
2281 #[test]
2282 fn ids_are_unique() {
2283 let a = AgentId::new();
2284 let b = AgentId::new();
2285 assert_ne!(a, b);
2286 }
2287
2288 #[test]
2289 fn serde_round_trip() {
2290 let id = PostId::new();
2291 let json = serde_json::to_string(&id).unwrap();
2292 let deserialized: PostId = serde_json::from_str(&json).unwrap();
2293 assert_eq!(id, deserialized);
2294 }
2295
2296 #[test]
2297 fn display_shows_uuid() {
2298 let id = CommunityId::new();
2299 let display = id.to_string();
2300 assert_eq!(display.len(), 36);
2302 assert!(display.contains('-'));
2303 }
2304
2305 #[test]
2306 fn from_uuid_round_trip() {
2307 let uuid = Uuid::new_v4();
2308 let id = AgentId::from(uuid);
2309 let back: Uuid = id.into();
2310 assert_eq!(uuid, back);
2311 }
2312
2313 #[test]
2317 fn every_id_round_trips_through_its_own_display() {
2318 let agent = AgentId::new();
2319 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
2320
2321 let action = ModerationActionId::new();
2322 assert_eq!(
2323 action.to_string().parse::<ModerationActionId>().unwrap(),
2324 action
2325 );
2326
2327 let content = ContentId::new();
2328 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
2329
2330 let feedback = FeedbackId::new();
2331 assert_eq!(
2332 feedback.to_string().parse::<FeedbackId>().unwrap(),
2333 feedback
2334 );
2335 }
2336
2337 #[test]
2338 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
2339 assert!("not-a-uuid".parse::<ContentId>().is_err());
2340 assert!("".parse::<ContentId>().is_err());
2341 }
2342
2343 #[test]
2348 fn content_id_is_wire_compatible_with_a_bare_uuid() {
2349 let uuid = Uuid::new_v4();
2350 let typed = ContentId::from(uuid);
2351 assert_eq!(
2352 serde_json::to_string(&typed).unwrap(),
2353 serde_json::to_string(&uuid).unwrap()
2354 );
2355 }
2356
2357 #[test]
2360 fn every_moderation_target_narrows_losslessly() {
2361 let uuid = Uuid::new_v4();
2362
2363 for (label, got) in [
2364 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
2365 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
2366 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
2367 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
2368 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
2369 ] {
2370 assert_eq!(
2371 got.as_uuid(),
2372 &uuid,
2373 "{label} -> ModerationTargetId lost the uuid"
2374 );
2375 }
2376 }
2377
2378 #[test]
2382 fn resolved_ids_narrow_to_content_id_losslessly() {
2383 let uuid = Uuid::new_v4();
2384
2385 assert_eq!(
2386 ContentId::from(PostId::from(uuid)).as_uuid(),
2387 &uuid,
2388 "PostId -> ContentId lost the uuid"
2389 );
2390 assert_eq!(
2391 ContentId::from(CommentId::from(uuid)).as_uuid(),
2392 &uuid,
2393 "CommentId -> ContentId lost the uuid"
2394 );
2395 assert_eq!(
2396 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
2397 .as_uuid(),
2398 &uuid,
2399 "PostOrCommentId -> ContentId lost the uuid"
2400 );
2401 }
2402
2403 #[test]
2404 fn json_is_plain_uuid_string() {
2405 let uuid = Uuid::new_v4();
2406 let id = AgentId::from(uuid);
2407 let id_json = serde_json::to_string(&id).unwrap();
2409 let uuid_json = serde_json::to_string(&uuid).unwrap();
2410 assert_eq!(id_json, uuid_json);
2411 }
2412
2413 #[cfg(feature = "schemars")]
2418 #[test]
2419 fn id_json_schema_is_inlined() {
2420 use schemars::JsonSchema;
2421
2422 assert!(
2423 <PostId as JsonSchema>::inline_schema(),
2424 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
2425 );
2426 assert!(<AgentId as JsonSchema>::inline_schema());
2427 assert!(<CommentId as JsonSchema>::inline_schema());
2428 assert!(<CommunityId as JsonSchema>::inline_schema());
2429 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
2430 assert!(<ContentRef as JsonSchema>::inline_schema());
2431 assert!(<FeedbackId as JsonSchema>::inline_schema());
2432 assert!(<ContactRequestId as JsonSchema>::inline_schema());
2433
2434 #[derive(schemars::JsonSchema)]
2438 #[allow(dead_code)]
2439 struct Container {
2440 post_id: PostId,
2442 agent_id: Option<AgentId>,
2444 gov_id: GovernanceLogId,
2446 maybe_gov_id: Option<GovernanceLogId>,
2448 content_ref: ContentRef,
2450 maybe_content_ref: Option<ContentRef>,
2452 feedback_id: FeedbackId,
2454 }
2455
2456 let schema = schemars::schema_for!(Container);
2457 let value = serde_json::to_value(&schema).unwrap();
2458
2459 assert!(
2461 value.get("$defs").is_none(),
2462 "no $defs should be emitted for ID-only container; got schema: {value}"
2463 );
2464
2465 let post_id = &value["properties"]["post_id"];
2467 assert!(
2468 post_id.get("$ref").is_none(),
2469 "post_id must not be a $ref; got: {post_id}"
2470 );
2471 assert_eq!(post_id["type"], "string");
2472 assert_eq!(post_id["format"], "uuid");
2473
2474 let agent_id = &value["properties"]["agent_id"];
2479 assert!(
2480 agent_id.get("$ref").is_none(),
2481 "agent_id must not be a $ref; got: {agent_id}"
2482 );
2483 let agent_id_str = agent_id.to_string();
2484 assert!(
2485 !agent_id_str.contains("$ref"),
2486 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
2487 );
2488 assert!(
2489 agent_id_str.contains("\"format\":\"uuid\""),
2490 "agent_id should still carry format=uuid; got: {agent_id}"
2491 );
2492
2493 for field in
2498 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
2499 {
2500 let f = &value["properties"][field];
2501 assert!(
2502 !f.to_string().contains("$ref"),
2503 "{field} must contain no $ref anywhere; got: {f}"
2504 );
2505 }
2506 assert_eq!(value["properties"]["gov_id"]["type"], "string");
2507 assert_eq!(
2508 value["properties"]["gov_id"]["pattern"],
2509 GOVERNANCE_LOG_ID_PATTERN
2510 );
2511 assert!(
2512 value["properties"]["maybe_gov_id"]
2513 .to_string()
2514 .contains("GOV|APP"),
2515 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
2516 value["properties"]["maybe_gov_id"]
2517 );
2518 assert_eq!(value["properties"]["content_ref"]["type"], "string");
2519 }
2520
2521 #[test]
2522 fn governance_log_id_accepts_only_citation_shapes() {
2523 for (good, prefix) in [
2524 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
2525 ("APP-2026-0003", GovernanceLogPrefix::App),
2526 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
2527 ("KEY-2026-0001", GovernanceLogPrefix::Key),
2528 ("REC-2026-0001", GovernanceLogPrefix::Rec),
2529 ("OBL-2026-0001", GovernanceLogPrefix::Obl),
2530 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
2531 ] {
2532 let id = good.parse::<GovernanceLogId>().unwrap();
2533 assert_eq!(id.as_str(), good, "{good} should parse");
2534 assert_eq!(id.prefix(), prefix);
2535 assert_eq!(id.prefix().as_str(), &good[..3]);
2536 }
2537 assert_eq!(
2538 GovernanceLogPrefix::ALL
2539 .map(|p| p.to_string())
2540 .concat()
2541 .len(),
2542 3 * GovernanceLogPrefix::ALL.len()
2543 );
2544 let series = GovernanceLogPrefix::ALL.map(|p| p.as_str()).join("|");
2546 assert!(
2547 GOVERNANCE_LOG_ID_PATTERN.contains(&format!("({series})")),
2548 "{GOVERNANCE_LOG_ID_PATTERN}"
2549 );
2550 assert!(
2551 CONTENT_REF_PATTERN.contains(&format!("({series})")),
2552 "{CONTENT_REF_PATTERN}"
2553 );
2554 assert_eq!(
2556 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
2557 "GOV-2026-0006"
2558 );
2559 assert_eq!(
2560 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
2561 "GOV-2026-0006"
2562 );
2563 for bad in [
2564 "",
2565 "GOV-26-0006",
2566 "MOD-2026-0006",
2567 "GOV-2026-0006-1",
2568 "GOV-202X-0006",
2569 "3f1a0000-0000-0000-0000-000000000000",
2570 ] {
2571 assert!(
2572 bad.parse::<GovernanceLogId>().is_err(),
2573 "{bad:?} should not parse as a GovernanceLogId"
2574 );
2575 }
2576 }
2577
2578 #[test]
2582 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
2583 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2584 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
2585 let back: GovernanceLogId =
2586 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
2587 assert_eq!(back, id);
2588 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
2590 }
2591
2592 #[test]
2595 fn content_ref_round_trips_as_a_bare_string() {
2596 let uuid = Uuid::new_v4();
2597 let content = ContentRef::from(ContentId::from(uuid));
2598 assert_eq!(
2599 serde_json::to_value(&content).unwrap(),
2600 serde_json::json!(uuid.to_string())
2601 );
2602 assert_eq!(
2603 serde_json::from_value::<ContentRef>(serde_json::json!(
2604 uuid.to_string()
2605 ))
2606 .unwrap(),
2607 content
2608 );
2609
2610 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
2611 assert_eq!(
2612 serde_json::to_value(&gov).unwrap(),
2613 serde_json::json!("APP-2026-0003")
2614 );
2615 assert_eq!(
2616 serde_json::from_value::<ContentRef>(serde_json::json!(
2617 "APP-2026-0003"
2618 ))
2619 .unwrap(),
2620 gov
2621 );
2622
2623 assert!(gov.is_governance());
2624 assert!(!content.is_governance());
2625 assert_eq!(gov.kind_str(), "governance");
2626 assert_eq!(content.kind_str(), "content");
2627 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
2628 assert!(content.as_governance().is_none());
2629
2630 assert!("not-an-id".parse::<ContentRef>().is_err());
2632 assert!(
2633 serde_json::from_value::<ContentRef>(serde_json::json!(
2634 "not-an-id"
2635 ))
2636 .is_err()
2637 );
2638 }
2639
2640 #[test]
2642 fn every_readable_id_narrows_to_a_content_ref() {
2643 let uuid = Uuid::new_v4();
2644 for (label, got) in [
2645 ("PostId", ContentRef::from(PostId::from(uuid))),
2646 ("CommentId", ContentRef::from(CommentId::from(uuid))),
2647 ("ContentId", ContentRef::from(ContentId::from(uuid))),
2648 ] {
2649 assert_eq!(
2650 got,
2651 ContentRef::Content(ContentId::from(uuid)),
2652 "{label} -> ContentRef lost the uuid"
2653 );
2654 }
2655 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2656 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
2657 }
2658
2659 #[test]
2662 fn string_shaped_ids_round_trip_through_display() {
2663 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2664 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
2665
2666 let r = ContentRef::Governance(gov);
2667 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2668
2669 let r = ContentRef::Content(ContentId::new());
2670 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2671 }
2672
2673 #[test]
2674 fn post_or_comment_post_variant() {
2675 let inner = PostId::new();
2676 let tagged = PostOrCommentId::Post(inner);
2677 assert!(tagged.is_post());
2678 assert!(!tagged.is_comment());
2679 assert_eq!(tagged.as_post(), Some(inner));
2680 assert_eq!(tagged.as_comment(), None);
2681 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2682 assert_eq!(tagged.kind_str(), "post");
2683 }
2684
2685 #[test]
2686 fn post_or_comment_comment_variant() {
2687 let inner = CommentId::new();
2688 let tagged = PostOrCommentId::Comment(inner);
2689 assert!(tagged.is_comment());
2690 assert!(!tagged.is_post());
2691 assert_eq!(tagged.as_comment(), Some(inner));
2692 assert_eq!(tagged.as_post(), None);
2693 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2694 assert_eq!(tagged.kind_str(), "comment");
2695 }
2696
2697 #[test]
2698 fn post_or_comment_from_conversions() {
2699 let post = PostId::new();
2700 let comment = CommentId::new();
2701 let via_post: PostOrCommentId = post.into();
2702 let via_comment: PostOrCommentId = comment.into();
2703 assert_eq!(via_post, PostOrCommentId::Post(post));
2704 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
2705 }
2706
2707 #[test]
2708 fn post_or_comment_display_is_kind_colon_uuid() {
2709 let post = PostId::new();
2710 let tagged = PostOrCommentId::Post(post);
2711 let rendered = tagged.to_string();
2712 assert!(rendered.starts_with("post:"));
2713 assert!(rendered.contains(&post.to_string()));
2714 }
2715
2716 const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
2719
2720 #[test]
2723 fn a_bad_id_fails_in_plain_words() {
2724 let bad = "7ad26ccd-922f-484a-a37c-51777344a";
2725 let want = format!(
2726 "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
2727 );
2728 assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
2729 let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
2730 .unwrap_err()
2731 .to_string();
2732 assert!(err.starts_with(&want), "{err}");
2733 assert!(!err.contains("group"), "{err}");
2734 let err = serde_json::from_str::<AgentId>("5")
2735 .unwrap_err()
2736 .to_string();
2737 assert!(err.contains("expected AgentId as a UUID string"), "{err}");
2738 let long = "x".repeat(500);
2740 let msg = long.parse::<CommentId>().unwrap_err().to_string();
2741 assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
2742 }
2743
2744 #[test]
2746 fn ids_still_round_trip() {
2747 let id: PostId = FULL.parse().unwrap();
2748 let json = serde_json::to_string(&id).unwrap();
2749 assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
2750 let map: std::collections::HashMap<PostId, i64> =
2751 [(id, 3)].into_iter().collect();
2752 let json = serde_json::to_string(&map).unwrap();
2753 let back: std::collections::HashMap<PostId, i64> =
2754 serde_json::from_str(&json).unwrap();
2755 assert_eq!(back, map);
2756 }
2757
2758 fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
2759 s.parse()
2760 }
2761
2762 #[test]
2763 fn full_and_short_targets_parse() {
2764 let full = target(FULL).unwrap();
2765 assert_eq!(full.full().unwrap().to_string(), FULL);
2766 assert_eq!(full.to_string(), FULL);
2767 let short = target(" 7AD26CCD ").unwrap();
2768 assert!(short.full().is_none());
2769 assert_eq!(short.to_string(), "7ad26ccd");
2770 for variant in [
2772 FULL.replace('-', ""),
2773 FULL.to_uppercase(),
2774 FULL.replace('-', "\u{2011}"),
2775 ] {
2776 assert_eq!(target(&variant).unwrap(), full, "{variant}");
2777 }
2778 }
2779
2780 #[test]
2781 fn a_truncated_uuid_is_an_error_not_its_prefix() {
2782 let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
2783 let msg = err.in_field("reply_to").to_string();
2784 assert!(
2785 msg.starts_with(
2786 "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
2787 ),
2788 "{msg}"
2789 );
2790 for internal in [
2791 "group",
2792 "UUID parsing",
2793 "invalid length",
2794 "invalid character",
2795 ] {
2796 assert!(!msg.contains(internal), "{internal} leaked: {msg}");
2797 }
2798 assert!(target("7ad26cc").is_err());
2799 assert!(target("7ad26ccd9").is_err());
2800 }
2801
2802 #[test]
2803 fn trailing_text_names_the_id_it_starts_with() {
2804 let msg = target(&format!("{FULL} (the reply above)"))
2805 .unwrap_err()
2806 .to_string();
2807 assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
2808 }
2809
2810 #[derive(Debug, serde::Deserialize)]
2811 struct Probe {
2812 #[serde(deserialize_with = "content_target::reply_to")]
2813 #[allow(dead_code)]
2814 reply_to: ContentTarget,
2815 }
2816
2817 #[test]
2818 fn target_errors_name_the_field_for_strings_and_other_types() {
2819 let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
2820 .unwrap_err();
2821 assert!(
2822 err.to_string()
2823 .starts_with("`reply_to` must be a post or comment id"),
2824 "{err}"
2825 );
2826 let err =
2827 serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
2828 assert!(
2829 err.to_string()
2830 .contains("expected `reply_to` to be a post or comment id"),
2831 "{err}"
2832 );
2833 }
2834
2835 #[cfg(feature = "schemars")]
2836 #[test]
2837 fn target_schema_is_inline_and_admits_both_forms() {
2838 let schema = schemars::schema_for!(ContentTarget);
2839 let v = schema.as_value();
2840 assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
2841 assert!(v.get("format").is_none(), "a short id is not format: uuid");
2842 assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
2843 }
2844}