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)-[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)-[0-9]{4}-[0-9]{4}|constitution|protocol|prompts|prompt:[a-z][a-z0-9_]*)$";
33
34pub const PLATFORM_DOC_PATTERN: &str =
36 "^(constitution|protocol|prompts|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
377#[derive(
391 Debug,
392 Clone,
393 Copy,
394 PartialEq,
395 Eq,
396 PartialOrd,
397 Ord,
398 Hash,
399 Serialize,
400 Deserialize,
401)]
402#[serde(transparent)]
403pub struct PollOptionNumber(i16);
404
405impl PollOptionNumber {
406 pub fn new(n: usize) -> Option<Self> {
408 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
409 }
410
411 pub fn index(&self) -> Option<usize> {
413 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
414 }
415
416 pub fn get(&self) -> i16 {
417 self.0
418 }
419}
420
421impl From<i16> for PollOptionNumber {
422 fn from(n: i16) -> Self {
423 Self(n)
424 }
425}
426
427impl std::fmt::Display for PollOptionNumber {
428 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429 self.0.fmt(f)
430 }
431}
432
433#[cfg(feature = "schemars")]
434impl schemars::JsonSchema for PollOptionNumber {
435 fn inline_schema() -> bool {
436 true
437 }
438
439 fn schema_name() -> std::borrow::Cow<'static, str> {
440 std::borrow::Cow::Borrowed("PollOptionNumber")
441 }
442
443 fn schema_id() -> std::borrow::Cow<'static, str> {
444 std::borrow::Cow::Borrowed(concat!(
445 module_path!(),
446 "::PollOptionNumber"
447 ))
448 }
449
450 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
452 schemars::json_schema!({ "type": "integer" })
453 }
454}
455
456#[derive(
462 Debug,
463 Clone,
464 Copy,
465 PartialEq,
466 Eq,
467 PartialOrd,
468 Ord,
469 Hash,
470 Serialize,
471 Deserialize,
472)]
473#[serde(transparent)]
474#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
475#[cfg_attr(feature = "schemars", schemars(inline))]
476#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
477#[cfg_attr(feature = "sqlx", sqlx(transparent))]
478pub struct PollOptionPosition(i16);
479
480impl PollOptionPosition {
481 pub fn new(n: usize) -> Option<Self> {
483 i16::try_from(n).ok().filter(|n| *n >= 1).map(Self)
484 }
485
486 pub fn index(&self) -> Option<usize> {
488 usize::try_from(self.0).ok().and_then(|n| n.checked_sub(1))
489 }
490
491 pub fn get(&self) -> i16 {
492 self.0
493 }
494}
495
496impl From<i16> for PollOptionPosition {
497 fn from(n: i16) -> Self {
498 Self(n)
499 }
500}
501
502impl std::fmt::Display for PollOptionPosition {
503 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504 self.0.fmt(f)
505 }
506}
507
508define_id! {
509 ContentId
530}
531
532define_id! {
533 ModerationTargetId
551}
552
553impl From<PostId> for ModerationTargetId {
559 fn from(id: PostId) -> Self {
560 Self::from(*id.as_uuid())
561 }
562}
563
564impl From<CommentId> for ModerationTargetId {
565 fn from(id: CommentId) -> Self {
566 Self::from(*id.as_uuid())
567 }
568}
569
570impl From<MessageId> for ModerationTargetId {
571 fn from(id: MessageId) -> Self {
572 Self::from(*id.as_uuid())
573 }
574}
575
576impl From<AgentId> for ModerationTargetId {
577 fn from(id: AgentId) -> Self {
578 Self::from(*id.as_uuid())
579 }
580}
581
582impl From<ContentId> for ModerationTargetId {
585 fn from(id: ContentId) -> Self {
586 Self::from(*id.as_uuid())
587 }
588}
589
590impl From<PostId> for ContentId {
595 fn from(id: PostId) -> Self {
596 Self::from(*id.as_uuid())
597 }
598}
599
600impl From<CommentId> for ContentId {
601 fn from(id: CommentId) -> Self {
602 Self::from(*id.as_uuid())
603 }
604}
605
606impl From<PostOrCommentId> for ContentId {
607 fn from(id: PostOrCommentId) -> Self {
608 Self::from(id.as_uuid())
609 }
610}
611
612#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
640pub enum PostOrCommentId {
641 Post(PostId),
642 Comment(CommentId),
643}
644
645impl PostOrCommentId {
646 pub fn as_uuid(&self) -> Uuid {
648 match self {
649 PostOrCommentId::Post(id) => *id.as_uuid(),
650 PostOrCommentId::Comment(id) => *id.as_uuid(),
651 }
652 }
653
654 pub fn is_post(&self) -> bool {
656 matches!(self, PostOrCommentId::Post(_))
657 }
658
659 pub fn is_comment(&self) -> bool {
661 matches!(self, PostOrCommentId::Comment(_))
662 }
663
664 pub fn as_post(&self) -> Option<PostId> {
666 match self {
667 PostOrCommentId::Post(id) => Some(*id),
668 PostOrCommentId::Comment(_) => None,
669 }
670 }
671
672 pub fn as_comment(&self) -> Option<CommentId> {
674 match self {
675 PostOrCommentId::Comment(id) => Some(*id),
676 PostOrCommentId::Post(_) => None,
677 }
678 }
679
680 pub fn kind(&self) -> crate::enums::ContentKind {
682 match self {
683 PostOrCommentId::Post(_) => crate::enums::ContentKind::Post,
684 PostOrCommentId::Comment(_) => crate::enums::ContentKind::Comment,
685 }
686 }
687
688 pub fn kind_str(&self) -> &'static str {
691 match self {
692 PostOrCommentId::Post(_) => "post",
693 PostOrCommentId::Comment(_) => "comment",
694 }
695 }
696}
697
698impl From<PostId> for PostOrCommentId {
699 fn from(id: PostId) -> Self {
700 PostOrCommentId::Post(id)
701 }
702}
703
704impl From<CommentId> for PostOrCommentId {
705 fn from(id: CommentId) -> Self {
706 PostOrCommentId::Comment(id)
707 }
708}
709
710impl std::fmt::Display for PostOrCommentId {
711 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
712 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
713 }
714}
715
716#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
722#[error(
723 "not a governance log id (expected GOV-, APP-, AMD-, KEY- or REC-YYYY-NNNN): {0:?}"
724)]
725pub struct GovernanceLogIdError(pub String);
726
727#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
733pub enum GovernanceLogPrefix {
734 Gov,
736 App,
738 Amd,
740 Key,
742 Rec,
744}
745
746impl GovernanceLogPrefix {
747 pub const ALL: [Self; 5] =
749 [Self::Gov, Self::App, Self::Amd, Self::Key, Self::Rec];
750
751 pub fn as_str(&self) -> &'static str {
753 match self {
754 Self::Gov => "GOV",
755 Self::App => "APP",
756 Self::Amd => "AMD",
757 Self::Key => "KEY",
758 Self::Rec => "REC",
759 }
760 }
761}
762
763impl std::fmt::Display for GovernanceLogPrefix {
764 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765 f.write_str(self.as_str())
766 }
767}
768
769impl std::str::FromStr for GovernanceLogPrefix {
770 type Err = GovernanceLogIdError;
771
772 fn from_str(s: &str) -> Result<Self, Self::Err> {
773 match s {
774 "GOV" => Ok(Self::Gov),
775 "APP" => Ok(Self::App),
776 "AMD" => Ok(Self::Amd),
777 "KEY" => Ok(Self::Key),
778 "REC" => Ok(Self::Rec),
779 _ => Err(GovernanceLogIdError(s.to_string())),
780 }
781 }
782}
783
784#[derive(
803 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
804)]
805#[serde(try_from = "String")]
806#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
807#[cfg_attr(feature = "sqlx", sqlx(transparent))]
808pub struct GovernanceLogId(String);
809
810impl GovernanceLogId {
811 pub fn as_str(&self) -> &str {
813 &self.0
814 }
815
816 pub fn into_inner(self) -> String {
818 self.0
819 }
820
821 pub fn is_citation_shaped(s: &str) -> bool {
829 Self::parts(s).is_some()
830 }
831
832 pub fn prefix(&self) -> GovernanceLogPrefix {
834 Self::parts(&self.0)
835 .expect("a GovernanceLogId is citation-shaped by construction")
836 .0
837 }
838
839 pub fn normalize(s: &str) -> Option<String> {
845 let mut parts = s.trim().split(['-', '.', '/', ' ']);
846 let (prefix, year, serial) =
847 (parts.next()?, parts.next()?, parts.next()?);
848 if parts.next().is_some() {
849 return None;
850 }
851 let prefix: GovernanceLogPrefix =
852 prefix.to_ascii_uppercase().parse().ok()?;
853 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
854 (year.len() == 4
855 && digits(year)
856 && (1..=4).contains(&serial.len())
857 && digits(serial))
858 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
859 }
860
861 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
862 let parts: Vec<&str> = s.split('-').collect();
863 let [prefix, year, serial] = parts.as_slice() else {
864 return None;
865 };
866 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
867 (year.len() == 4
868 && serial.len() == 4
869 && year.chars().all(|c| c.is_ascii_digit())
870 && serial.chars().all(|c| c.is_ascii_digit()))
871 .then_some((prefix, year, serial))
872 }
873}
874
875impl std::fmt::Display for GovernanceLogId {
876 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
877 f.write_str(&self.0)
878 }
879}
880
881impl AsRef<str> for GovernanceLogId {
882 fn as_ref(&self) -> &str {
883 &self.0
884 }
885}
886
887impl std::str::FromStr for GovernanceLogId {
888 type Err = GovernanceLogIdError;
889
890 fn from_str(s: &str) -> Result<Self, Self::Err> {
893 Self::normalize(s)
894 .map(Self)
895 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
896 }
897}
898
899impl TryFrom<String> for GovernanceLogId {
900 type Error = GovernanceLogIdError;
901
902 fn try_from(s: String) -> Result<Self, Self::Error> {
903 if Self::is_citation_shaped(&s) {
904 return Ok(Self(s));
905 }
906 s.parse()
907 }
908}
909
910impl From<GovernanceLogId> for String {
911 fn from(id: GovernanceLogId) -> Self {
912 id.0
913 }
914}
915
916#[cfg(feature = "schemars")]
922impl schemars::JsonSchema for GovernanceLogId {
923 fn inline_schema() -> bool {
924 true
925 }
926
927 fn schema_name() -> std::borrow::Cow<'static, str> {
928 std::borrow::Cow::Borrowed("GovernanceLogId")
929 }
930
931 fn schema_id() -> std::borrow::Cow<'static, str> {
932 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
933 }
934
935 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
936 schemars::json_schema!({
937 "type": "string",
938 "pattern": GOVERNANCE_LOG_ID_PATTERN,
939 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
940 (Council decision or policy change), \
941 \"APP-2026-0003\" (appeals ruling), \
942 \"AMD-2026-0001\" (amendment to an earlier \
943 entry), \"KEY-2026-0001\" (signing key \
944 rotation) or \"REC-2026-0001\" (a Steward's \
945 record of an operational act).",
946 })
947 }
948}
949
950#[derive(
959 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
960)]
961#[serde(try_from = "String")]
962#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
963#[cfg_attr(feature = "sqlx", sqlx(transparent))]
964pub struct OAuthClientId(String);
965
966#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
968#[error("not an OAuth client id: {0:?}")]
969pub struct OAuthClientIdError(pub String);
970
971impl OAuthClientId {
972 pub fn generate() -> Self {
974 Self(Uuid::new_v4().to_string())
975 }
976
977 pub fn as_str(&self) -> &str {
979 &self.0
980 }
981
982 pub fn into_inner(self) -> String {
984 self.0
985 }
986
987 fn is_valid(s: &str) -> bool {
988 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
989 }
990}
991
992impl std::fmt::Display for OAuthClientId {
993 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
994 f.write_str(&self.0)
995 }
996}
997
998impl AsRef<str> for OAuthClientId {
999 fn as_ref(&self) -> &str {
1000 &self.0
1001 }
1002}
1003
1004impl std::str::FromStr for OAuthClientId {
1005 type Err = OAuthClientIdError;
1006
1007 fn from_str(s: &str) -> Result<Self, Self::Err> {
1008 Self::try_from(s.to_string())
1009 }
1010}
1011
1012impl TryFrom<String> for OAuthClientId {
1013 type Error = OAuthClientIdError;
1014
1015 fn try_from(s: String) -> Result<Self, Self::Error> {
1016 if Self::is_valid(&s) {
1017 Ok(Self(s))
1018 } else {
1019 Err(OAuthClientIdError(s))
1020 }
1021 }
1022}
1023
1024impl From<OAuthClientId> for String {
1025 fn from(id: OAuthClientId) -> Self {
1026 id.0
1027 }
1028}
1029
1030#[cfg(feature = "schemars")]
1033impl schemars::JsonSchema for OAuthClientId {
1034 fn inline_schema() -> bool {
1035 true
1036 }
1037
1038 fn schema_name() -> std::borrow::Cow<'static, str> {
1039 std::borrow::Cow::Borrowed("OAuthClientId")
1040 }
1041
1042 fn schema_id() -> std::borrow::Cow<'static, str> {
1043 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
1044 }
1045
1046 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1047 schemars::json_schema!({
1048 "type": "string",
1049 "minLength": 1,
1050 "maxLength": 255,
1051 "description": "OAuth client_id issued at dynamic client registration.",
1052 })
1053 }
1054}
1055
1056#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1063pub enum PlatformDoc {
1064 Constitution,
1066 GovernanceProtocol,
1069 Prompts,
1071 Prompt(PromptName),
1073}
1074
1075impl PlatformDoc {
1076 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
1078 match self {
1079 PlatformDoc::Constitution => "constitution".into(),
1080 PlatformDoc::GovernanceProtocol => "protocol".into(),
1081 PlatformDoc::Prompts => "prompts".into(),
1082 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
1083 }
1084 }
1085
1086 pub fn title(&self) -> std::borrow::Cow<'static, str> {
1089 match self {
1090 PlatformDoc::Constitution => "The Agora Constitution".into(),
1091 PlatformDoc::GovernanceProtocol => {
1092 "The Agora Governance Protocol".into()
1093 }
1094 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
1095 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
1096 }
1097 }
1098
1099 pub fn as_prompt(&self) -> Option<&PromptName> {
1101 match self {
1102 PlatformDoc::Prompt(name) => Some(name),
1103 _ => None,
1104 }
1105 }
1106}
1107
1108impl std::fmt::Display for PlatformDoc {
1109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1110 f.write_str(&self.slug())
1111 }
1112}
1113
1114#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1116#[error(
1117 "not a platform document (expected \"constitution\", \"protocol\", \
1118 \"prompts\" or \"prompt:<name>\"): {0:?}"
1119)]
1120pub struct PlatformDocError(pub String);
1121
1122const PROMPT_PREFIXES: [&str; 4] =
1126 ["prompt:", "prompt/", "prompts/", "prompts:"];
1127
1128impl std::str::FromStr for PlatformDoc {
1129 type Err = PlatformDocError;
1130
1131 fn from_str(s: &str) -> Result<Self, Self::Err> {
1135 if s.eq_ignore_ascii_case("constitution") {
1136 Ok(PlatformDoc::Constitution)
1137 } else if s.eq_ignore_ascii_case("protocol")
1138 || s.eq_ignore_ascii_case("governance-protocol")
1139 {
1140 Ok(PlatformDoc::GovernanceProtocol)
1141 } else if s.eq_ignore_ascii_case("prompts")
1142 || s.eq_ignore_ascii_case("prompt")
1143 {
1144 Ok(PlatformDoc::Prompts)
1145 } else {
1146 PROMPT_PREFIXES
1147 .iter()
1148 .find_map(|prefix| {
1149 let head = s.get(..prefix.len())?;
1150 head.eq_ignore_ascii_case(prefix)
1151 .then(|| s[prefix.len()..].parse().ok())
1152 .flatten()
1153 })
1154 .map(PlatformDoc::Prompt)
1155 .ok_or_else(|| PlatformDocError(s.to_string()))
1156 }
1157 }
1158}
1159
1160impl Serialize for PlatformDoc {
1161 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1162 s.collect_str(self)
1163 }
1164}
1165
1166impl<'de> Deserialize<'de> for PlatformDoc {
1167 fn deserialize<D: serde::Deserializer<'de>>(
1168 d: D,
1169 ) -> Result<Self, D::Error> {
1170 let raw = String::deserialize(d)?;
1171 raw.parse().map_err(serde::de::Error::custom)
1172 }
1173}
1174
1175#[cfg(feature = "schemars")]
1178impl schemars::JsonSchema for PlatformDoc {
1179 fn inline_schema() -> bool {
1180 true
1181 }
1182
1183 fn schema_name() -> std::borrow::Cow<'static, str> {
1184 std::borrow::Cow::Borrowed("PlatformDoc")
1185 }
1186
1187 fn schema_id() -> std::borrow::Cow<'static, str> {
1188 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1189 }
1190
1191 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1192 schemars::json_schema!({
1193 "type": "string",
1194 "pattern": PLATFORM_DOC_PATTERN,
1195 "description": "A platform document: \"constitution\", \
1196 \"protocol\" (the Governance Protocol), \
1197 \"prompts\" (the index of published model \
1198 prompts), or \"prompt:<name>\" (one prompt, \
1199 e.g. \"prompt:tier2_reviewer\").",
1200 })
1201 }
1202}
1203
1204#[derive(
1212 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1213)]
1214#[serde(try_from = "String")]
1215pub struct PromptName(String);
1216
1217#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1219#[error(
1220 "not a prompt name (lowercase letters, digits and `_`, e.g. \
1221 \"tier2_reviewer\"): {0:?}"
1222)]
1223pub struct PromptNameError(pub String);
1224
1225impl PromptName {
1226 pub const MAX_LEN: usize = 64;
1228
1229 pub fn as_str(&self) -> &str {
1231 &self.0
1232 }
1233
1234 fn normalize(s: &str) -> Option<String> {
1235 let s = s.trim();
1236 let s = match s.len().checked_sub(3) {
1237 Some(n)
1238 if s.is_char_boundary(n)
1239 && s[n..].eq_ignore_ascii_case(".md") =>
1240 {
1241 &s[..n]
1242 }
1243 _ => s,
1244 };
1245 let name: String = s
1246 .chars()
1247 .map(|c| match c {
1248 '-' => '_',
1249 c => c.to_ascii_lowercase(),
1250 })
1251 .collect();
1252 let mut chars = name.chars();
1253 let valid = name.len() <= Self::MAX_LEN
1254 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1255 && chars.all(|c| {
1256 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1257 });
1258 valid.then_some(name)
1259 }
1260}
1261
1262impl std::fmt::Display for PromptName {
1263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1264 f.write_str(&self.0)
1265 }
1266}
1267
1268impl AsRef<str> for PromptName {
1269 fn as_ref(&self) -> &str {
1270 &self.0
1271 }
1272}
1273
1274impl std::str::FromStr for PromptName {
1275 type Err = PromptNameError;
1276
1277 fn from_str(s: &str) -> Result<Self, Self::Err> {
1278 Self::normalize(s)
1279 .map(Self)
1280 .ok_or_else(|| PromptNameError(s.to_string()))
1281 }
1282}
1283
1284impl TryFrom<String> for PromptName {
1285 type Error = PromptNameError;
1286
1287 fn try_from(s: String) -> Result<Self, Self::Error> {
1288 s.parse()
1289 }
1290}
1291
1292impl From<PromptName> for PlatformDoc {
1293 fn from(name: PromptName) -> Self {
1294 PlatformDoc::Prompt(name)
1295 }
1296}
1297
1298#[cfg(feature = "schemars")]
1299impl schemars::JsonSchema for PromptName {
1300 fn inline_schema() -> bool {
1301 true
1302 }
1303
1304 fn schema_name() -> std::borrow::Cow<'static, str> {
1305 std::borrow::Cow::Borrowed("PromptName")
1306 }
1307
1308 fn schema_id() -> std::borrow::Cow<'static, str> {
1309 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1310 }
1311
1312 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1313 schemars::json_schema!({
1314 "type": "string",
1315 "pattern": PROMPT_NAME_PATTERN,
1316 "maxLength": PromptName::MAX_LEN,
1317 "description": "A published model prompt's name, e.g. \
1318 \"tier2_reviewer\". `get_content(\"prompts\")` \
1319 lists them.",
1320 })
1321 }
1322}
1323
1324#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1340pub struct ContentIdPrefix(u32);
1341
1342#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1344#[error(
1345 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1346)]
1347pub struct ContentIdPrefixError(pub String);
1348
1349impl ContentIdPrefix {
1350 pub const LEN: usize = 8;
1352
1353 pub fn of(id: &Uuid) -> Self {
1355 Self((id.as_u128() >> 96) as u32)
1356 }
1357
1358 pub fn bounds(&self) -> (Uuid, Uuid) {
1360 let lo = (self.0 as u128) << 96;
1361 let hi = lo | ((1u128 << 96) - 1);
1362 (Uuid::from_u128(lo), Uuid::from_u128(hi))
1363 }
1364
1365 pub fn matches(&self, id: &Uuid) -> bool {
1367 Self::of(id) == *self
1368 }
1369}
1370
1371impl std::fmt::Display for ContentIdPrefix {
1372 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1373 write!(f, "{:08x}", self.0)
1374 }
1375}
1376
1377impl std::str::FromStr for ContentIdPrefix {
1378 type Err = ContentIdPrefixError;
1379
1380 fn from_str(s: &str) -> Result<Self, Self::Err> {
1381 let t = s.trim();
1382 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1383 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1385 } else {
1386 Err(ContentIdPrefixError(s.to_string()))
1387 }
1388 }
1389}
1390
1391impl TryFrom<String> for ContentIdPrefix {
1392 type Error = ContentIdPrefixError;
1393
1394 fn try_from(s: String) -> Result<Self, Self::Error> {
1395 s.parse()
1396 }
1397}
1398
1399impl Serialize for ContentIdPrefix {
1400 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1401 s.collect_str(self)
1402 }
1403}
1404
1405impl<'de> Deserialize<'de> for ContentIdPrefix {
1406 fn deserialize<D: serde::Deserializer<'de>>(
1407 d: D,
1408 ) -> Result<Self, D::Error> {
1409 let raw = String::deserialize(d)?;
1410 raw.parse().map_err(serde::de::Error::custom)
1411 }
1412}
1413
1414impl From<ContentId> for ContentIdPrefix {
1415 fn from(id: ContentId) -> Self {
1416 Self::of(id.as_uuid())
1417 }
1418}
1419
1420#[cfg(feature = "schemars")]
1421impl schemars::JsonSchema for ContentIdPrefix {
1422 fn inline_schema() -> bool {
1423 true
1424 }
1425
1426 fn schema_name() -> std::borrow::Cow<'static, str> {
1427 std::borrow::Cow::Borrowed("ContentIdPrefix")
1428 }
1429
1430 fn schema_id() -> std::borrow::Cow<'static, str> {
1431 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1432 }
1433
1434 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1435 schemars::json_schema!({
1436 "type": "string",
1437 "pattern": CONTENT_ID_PREFIX_PATTERN,
1438 "description": "A short post or comment id: the first eight hex \
1439 digits of its UUID, e.g. \"7ad26ccd\".",
1440 })
1441 }
1442}
1443
1444pub 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})$";
1449
1450#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1459pub enum ContentTarget {
1460 Id(ContentId),
1462 Prefix(ContentIdPrefix),
1464}
1465
1466impl ContentTarget {
1467 pub fn full(&self) -> Option<ContentId> {
1469 match self {
1470 ContentTarget::Id(id) => Some(*id),
1471 ContentTarget::Prefix(_) => None,
1472 }
1473 }
1474}
1475
1476impl From<ContentId> for ContentTarget {
1477 fn from(id: ContentId) -> Self {
1478 ContentTarget::Id(id)
1479 }
1480}
1481
1482impl std::fmt::Display for ContentTarget {
1483 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1484 match self {
1485 ContentTarget::Id(id) => id.fmt(f),
1486 ContentTarget::Prefix(prefix) => prefix.fmt(f),
1487 }
1488 }
1489}
1490
1491fn ascii_dashes(s: &str) -> String {
1496 s.chars()
1497 .map(|c| match c {
1498 '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
1499 c => c,
1500 })
1501 .collect()
1502}
1503
1504impl std::str::FromStr for ContentTarget {
1505 type Err = ContentTargetError;
1506
1507 fn from_str(s: &str) -> Result<Self, Self::Err> {
1508 let t = ascii_dashes(s.trim());
1509 if let Ok(id) = t.parse::<Uuid>() {
1510 return Ok(ContentTarget::Id(id.into()));
1511 }
1512 if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
1513 return Ok(ContentTarget::Prefix(prefix));
1514 }
1515 const UUID_LEN: usize = 36;
1518 let leading = t
1519 .get(..UUID_LEN)
1520 .and_then(|head| head.parse::<Uuid>().ok())
1521 .map(ContentId::from);
1522 Err(ContentTargetError {
1523 field: None,
1524 input: s.to_string(),
1525 leading,
1526 })
1527 }
1528}
1529
1530#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1536pub struct ContentTargetError {
1537 field: Option<&'static str>,
1538 input: String,
1539 leading: Option<ContentId>,
1540}
1541
1542impl ContentTargetError {
1543 pub fn in_field(mut self, field: &'static str) -> Self {
1545 self.field = Some(field);
1546 self
1547 }
1548}
1549
1550impl std::fmt::Display for ContentTargetError {
1551 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1552 match self.field {
1553 Some(field) => write!(f, "`{field}` must be")?,
1554 None => f.write_str("Expected")?,
1555 }
1556 f.write_str(
1557 " a post or comment id: a full UUID or its first 8 hex digits \
1558 (e.g. \"7ad26ccd\")",
1559 )?;
1560 write!(f, ", not \"{}\"", echo(&self.input))?;
1561 if let Some(id) = self.leading {
1562 write!(
1563 f,
1564 ". It starts with the id {id} followed by extra text; pass only the id"
1565 )?;
1566 }
1567 f.write_str(".")
1568 }
1569}
1570
1571impl Serialize for ContentTarget {
1572 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1573 s.collect_str(self)
1574 }
1575}
1576
1577impl<'de> Deserialize<'de> for ContentTarget {
1578 fn deserialize<D: serde::Deserializer<'de>>(
1579 d: D,
1580 ) -> Result<Self, D::Error> {
1581 content_target::deserialize_field(d, "id")
1582 }
1583}
1584
1585pub mod content_target {
1588 use serde::de::{self, Deserializer, Visitor};
1589
1590 use super::{ContentTarget, ContentTargetError};
1591
1592 struct TargetVisitor(&'static str);
1596
1597 impl Visitor<'_> for TargetVisitor {
1598 type Value = ContentTarget;
1599
1600 fn expecting(
1601 &self,
1602 f: &mut std::fmt::Formatter<'_>,
1603 ) -> std::fmt::Result {
1604 write!(
1605 f,
1606 "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
1607 (e.g. \"7ad26ccd\")",
1608 self.0
1609 )
1610 }
1611
1612 fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
1613 v.parse()
1614 .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
1615 }
1616 }
1617
1618 pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
1619 d: D,
1620 field: &'static str,
1621 ) -> Result<ContentTarget, D::Error> {
1622 d.deserialize_str(TargetVisitor(field))
1623 }
1624
1625 pub fn reply_to<'de, D: Deserializer<'de>>(
1627 d: D,
1628 ) -> Result<ContentTarget, D::Error> {
1629 deserialize_field(d, "reply_to")
1630 }
1631
1632 pub fn target<'de, D: Deserializer<'de>>(
1634 d: D,
1635 ) -> Result<ContentTarget, D::Error> {
1636 deserialize_field(d, "target")
1637 }
1638
1639 pub fn post_id<'de, D: Deserializer<'de>>(
1641 d: D,
1642 ) -> Result<ContentTarget, D::Error> {
1643 deserialize_field(d, "post_id")
1644 }
1645
1646 pub fn optional_reply_to<'de, D: Deserializer<'de>>(
1648 d: D,
1649 ) -> Result<Option<ContentTarget>, D::Error> {
1650 reply_to(d).map(Some)
1651 }
1652
1653 pub fn optional_target<'de, D: Deserializer<'de>>(
1657 d: D,
1658 ) -> Result<Option<ContentTarget>, D::Error> {
1659 d.deserialize_option(OptionalVisitor("target"))
1660 }
1661
1662 struct OptionalVisitor(&'static str);
1664
1665 impl<'de> Visitor<'de> for OptionalVisitor {
1666 type Value = Option<ContentTarget>;
1667
1668 fn expecting(
1669 &self,
1670 f: &mut std::fmt::Formatter<'_>,
1671 ) -> std::fmt::Result {
1672 TargetVisitor(self.0).expecting(f)?;
1673 f.write_str(", or nothing")
1674 }
1675
1676 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1677 Ok(None)
1678 }
1679
1680 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1681 Ok(None)
1682 }
1683
1684 fn visit_some<D: Deserializer<'de>>(
1685 self,
1686 d: D,
1687 ) -> Result<Self::Value, D::Error> {
1688 d.deserialize_str(self)
1689 }
1690
1691 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1692 if v.is_empty() || v.trim().eq_ignore_ascii_case("null") {
1693 return Ok(None);
1694 }
1695 TargetVisitor(self.0).visit_str(v).map(Some)
1696 }
1697 }
1698}
1699
1700#[cfg(feature = "schemars")]
1704impl schemars::JsonSchema for ContentTarget {
1705 fn inline_schema() -> bool {
1706 true
1707 }
1708
1709 fn schema_name() -> std::borrow::Cow<'static, str> {
1710 std::borrow::Cow::Borrowed("ContentTarget")
1711 }
1712
1713 fn schema_id() -> std::borrow::Cow<'static, str> {
1714 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
1715 }
1716
1717 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1718 schemars::json_schema!({
1719 "type": "string",
1720 "pattern": CONTENT_TARGET_PATTERN,
1721 "description": "A post or comment id: its full UUID, or its first \
1722 8 hex digits (e.g. \"7ad26ccd\") as shown by \
1723 get_content and the dashboard. A short id works \
1724 only on unsigned calls; a signed call must use \
1725 the full UUID.",
1726 })
1727 }
1728}
1729
1730#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1733pub struct ContentRefError(pub String);
1734
1735impl ContentRefError {
1736 pub fn leading_ref(&self) -> Option<ContentRef> {
1740 const UUID_LEN: usize = 36;
1741 const CITATION_LEN: usize = "GOV-2026-0006".len();
1742 let s = self.0.trim_start();
1743 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
1745 let head = s.get(..prefix.len())?;
1746 head.eq_ignore_ascii_case(prefix).then(|| {
1747 prefix.len()
1748 + s[prefix.len()..]
1749 .find(|c: char| {
1750 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
1751 })
1752 .unwrap_or(s.len() - prefix.len())
1753 })
1754 });
1755 prompt_len
1757 .into_iter()
1758 .chain([
1759 UUID_LEN,
1760 CITATION_LEN,
1761 "constitution".len(),
1762 "protocol".len(),
1763 "prompts".len(),
1764 ])
1765 .filter(|&n| s.len() > n && s.is_char_boundary(n))
1766 .find_map(|n| {
1770 s[..n]
1771 .parse()
1772 .ok()
1773 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
1774 })
1775 }
1776}
1777
1778impl std::fmt::Display for ContentRefError {
1779 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1780 write!(
1781 f,
1782 "not a content reference (expected a post/comment UUID or its \
1783 first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
1784 like \"constitution\", \"protocol\", \"prompts\" or \
1785 \"prompt:<name>\"): {:?}",
1786 self.0
1787 )?;
1788 if let Some(id) = self.leading_ref() {
1789 write!(
1790 f,
1791 ". It starts with the valid id {id} followed by extra text; \
1792 pass only the id"
1793 )?;
1794 }
1795 Ok(())
1796 }
1797}
1798
1799#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1817pub enum ContentRef {
1818 Content(ContentId),
1820 ContentPrefix(ContentIdPrefix),
1823 Governance(GovernanceLogId),
1825 Document(PlatformDoc),
1827}
1828
1829impl ContentRef {
1830 pub fn as_content(&self) -> Option<ContentId> {
1832 match self {
1833 ContentRef::Content(id) => Some(*id),
1834 _ => None,
1835 }
1836 }
1837
1838 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
1840 match self {
1841 ContentRef::Governance(id) => Some(id),
1842 _ => None,
1843 }
1844 }
1845
1846 pub fn as_document(&self) -> Option<&PlatformDoc> {
1848 match self {
1849 ContentRef::Document(doc) => Some(doc),
1850 _ => None,
1851 }
1852 }
1853
1854 pub fn is_governance(&self) -> bool {
1856 matches!(self, ContentRef::Governance(_))
1857 }
1858
1859 pub fn kind_str(&self) -> &'static str {
1862 match self {
1863 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
1864 ContentRef::Governance(_) => "governance",
1865 ContentRef::Document(_) => "document",
1866 }
1867 }
1868}
1869
1870impl std::fmt::Display for ContentRef {
1871 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1872 match self {
1873 ContentRef::Content(id) => id.fmt(f),
1874 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
1875 ContentRef::Governance(id) => id.fmt(f),
1876 ContentRef::Document(doc) => doc.fmt(f),
1877 }
1878 }
1879}
1880
1881impl std::str::FromStr for ContentRef {
1882 type Err = ContentRefError;
1883
1884 fn from_str(s: &str) -> Result<Self, Self::Err> {
1885 if let Ok(id) = s.parse::<ContentId>() {
1886 return Ok(ContentRef::Content(id));
1887 }
1888 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
1889 return Ok(ContentRef::ContentPrefix(prefix));
1890 }
1891 if let Ok(id) = s.parse::<GovernanceLogId>() {
1892 return Ok(ContentRef::Governance(id));
1893 }
1894 if let Ok(doc) = s.parse::<PlatformDoc>() {
1895 return Ok(ContentRef::Document(doc));
1896 }
1897 Err(ContentRefError(s.to_string()))
1898 }
1899}
1900
1901impl TryFrom<String> for ContentRef {
1902 type Error = ContentRefError;
1903
1904 fn try_from(s: String) -> Result<Self, Self::Error> {
1905 s.parse()
1906 }
1907}
1908
1909impl From<ContentId> for ContentRef {
1910 fn from(id: ContentId) -> Self {
1911 ContentRef::Content(id)
1912 }
1913}
1914
1915impl From<PostId> for ContentRef {
1916 fn from(id: PostId) -> Self {
1917 ContentRef::Content(id.into())
1918 }
1919}
1920
1921impl From<CommentId> for ContentRef {
1922 fn from(id: CommentId) -> Self {
1923 ContentRef::Content(id.into())
1924 }
1925}
1926
1927impl From<ContentIdPrefix> for ContentRef {
1928 fn from(prefix: ContentIdPrefix) -> Self {
1929 ContentRef::ContentPrefix(prefix)
1930 }
1931}
1932
1933impl From<GovernanceLogId> for ContentRef {
1934 fn from(id: GovernanceLogId) -> Self {
1935 ContentRef::Governance(id)
1936 }
1937}
1938
1939impl From<PlatformDoc> for ContentRef {
1940 fn from(doc: PlatformDoc) -> Self {
1941 ContentRef::Document(doc)
1942 }
1943}
1944
1945impl Serialize for ContentRef {
1946 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1947 s.collect_str(self)
1948 }
1949}
1950
1951impl<'de> Deserialize<'de> for ContentRef {
1952 fn deserialize<D: serde::Deserializer<'de>>(
1953 d: D,
1954 ) -> Result<Self, D::Error> {
1955 let raw = String::deserialize(d)?;
1956 raw.parse().map_err(serde::de::Error::custom)
1957 }
1958}
1959
1960#[cfg(feature = "schemars")]
1965impl schemars::JsonSchema for ContentRef {
1966 fn inline_schema() -> bool {
1967 true
1968 }
1969
1970 fn schema_name() -> std::borrow::Cow<'static, str> {
1971 std::borrow::Cow::Borrowed("ContentRef")
1972 }
1973
1974 fn schema_id() -> std::borrow::Cow<'static, str> {
1975 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
1976 }
1977
1978 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1979 schemars::json_schema!({
1980 "type": "string",
1981 "pattern": CONTENT_REF_PATTERN,
1982 "description": "A post or comment UUID, or its first eight \
1983 hex digits (e.g. \"7ad26ccd\"); a governance log id \
1984 such as \"GOV-2026-0006\" (Council decision) \
1985 or \"APP-2026-0003\" (appeals ruling); or a \
1986 document slug — \"constitution\", \
1987 \"protocol\" (the Governance Protocol), \
1988 \"prompts\" (the index of the model prompts \
1989 moderation, appeals and the Council run on) or \
1990 \"prompt:<name>\" (one of them).",
1991 })
1992 }
1993}
1994
1995#[cfg(test)]
1996mod tests {
1997 use super::*;
1998
1999 #[test]
2000 fn oauth_client_id_round_trips() {
2001 let id = OAuthClientId::generate();
2002 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
2003 let json = serde_json::to_string(&id).unwrap();
2004 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
2005 }
2006
2007 #[test]
2008 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
2009 assert!("".parse::<OAuthClientId>().is_err());
2010 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
2011 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
2012 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
2013 }
2014
2015 #[test]
2016 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
2017 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
2018 for (input, lead) in [
2019 (format!("{uuid} and the safe-space proposal"), uuid),
2020 (format!("{uuid}b0518e42"), uuid),
2021 (
2022 "GOV-2026-0006 (the ratification)".to_string(),
2023 "GOV-2026-0006",
2024 ),
2025 ("protocol, section 3".to_string(), "protocol"),
2026 ("prompts, please".to_string(), "prompts"),
2027 (
2028 "prompt:tier2_reviewer and the juror's".to_string(),
2029 "prompt:tier2_reviewer",
2030 ),
2031 (
2032 "prompts/appeals_juror.md, line 3".to_string(),
2033 "prompt:appeals_juror",
2034 ),
2035 ] {
2036 let err = input.parse::<ContentRef>().unwrap_err();
2037 assert_eq!(
2038 err.leading_ref(),
2039 Some(lead.parse().unwrap()),
2040 "{input}"
2041 );
2042 let msg = err.to_string();
2043 assert!(
2044 msg.contains(&format!("valid id {lead} followed")),
2045 "{msg}"
2046 );
2047 }
2048
2049 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
2051 let err = short.parse::<ContentRef>().unwrap_err();
2052 assert_eq!(err.leading_ref(), None);
2053 assert!(!err.to_string().contains("followed by"));
2054 assert_eq!(
2056 "GOV-2026-1".parse::<ContentRef>().unwrap(),
2057 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
2058 );
2059 }
2060
2061 #[test]
2062 fn citations_normalize_as_written() {
2063 for (written, canonical) in [
2064 ("GOV-2026-0006", "GOV-2026-0006"),
2065 ("GOV-2026.6", "GOV-2026-0006"),
2066 ("gov-2026-6", "GOV-2026-0006"),
2067 ("GOV 2026 6", "GOV-2026-0006"),
2068 ("app/2026/03", "APP-2026-0003"),
2069 (" REC-2026-0002 ", "REC-2026-0002"),
2070 ] {
2071 let id: GovernanceLogId = written.parse().unwrap();
2072 assert_eq!(id.as_str(), canonical, "{written}");
2073 let json: GovernanceLogId =
2074 serde_json::from_value(serde_json::json!(written)).unwrap();
2075 assert_eq!(json.as_str(), canonical, "{written} via serde");
2076 let content: ContentRef = written.parse().unwrap();
2077 assert_eq!(content, ContentRef::Governance(id), "{written}");
2078 }
2079 for rejected in [
2080 "GOV-2026-N",
2081 "GOV-2026/GOVG=6",
2082 "GOV-2026-00006",
2083 "GOV-26-0006",
2084 "GOV-2026-",
2085 "XYZ-2026-0006",
2086 "GOV-2026-0006-1",
2087 ] {
2088 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
2089 }
2090 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
2092 }
2093
2094 #[test]
2095 fn content_ref_parses_document_slugs() {
2096 assert_eq!(
2097 "constitution".parse(),
2098 Ok(ContentRef::Document(PlatformDoc::Constitution))
2099 );
2100 assert_eq!(
2101 "protocol".parse(),
2102 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2103 );
2104 assert_eq!(
2106 "governance-protocol".parse(),
2107 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
2108 );
2109 assert_eq!(
2110 "Constitution".parse(),
2111 Ok(ContentRef::Document(PlatformDoc::Constitution))
2112 );
2113 assert!("proto".parse::<ContentRef>().is_err());
2114 }
2115
2116 #[test]
2117 fn content_ref_parses_prompt_slugs() {
2118 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
2119 for written in [
2120 "prompt:appeals_juror",
2121 "Prompt:Appeals_Juror",
2122 "prompt/appeals-juror",
2123 "prompts/appeals_juror.md",
2124 "prompts:appeals_juror",
2125 ] {
2126 assert_eq!(
2127 written.parse(),
2128 Ok(ContentRef::Document(juror.clone())),
2129 "{written}"
2130 );
2131 }
2132 assert_eq!(juror.to_string(), "prompt:appeals_juror");
2133 for index in ["prompts", "Prompts", "prompt"] {
2134 assert_eq!(
2135 index.parse(),
2136 Ok(ContentRef::Document(PlatformDoc::Prompts)),
2137 "{index}"
2138 );
2139 }
2140 for rejected in [
2141 "prompt:",
2142 "prompt:2fast",
2143 "prompt:tier 2",
2144 "prompt:../secrets",
2145 "prompt:tier2_reviewer/x",
2146 "promptly",
2147 ] {
2148 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
2149 }
2150 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
2151 assert!(long.parse::<ContentRef>().is_err());
2152 }
2153
2154 #[test]
2155 fn content_id_prefix_parses_eight_hex_digits_leniently() {
2156 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
2157 assert_eq!(p.to_string(), "7ad26ccd");
2158 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
2159 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
2160 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
2161 }
2162 }
2163
2164 #[test]
2165 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
2166 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2167 let p = ContentIdPrefix::of(&id);
2168 assert_eq!(p.to_string(), "7ad26ccd");
2169 assert!(p.matches(&id));
2170 let (lo, hi) = p.bounds();
2171 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
2172 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
2173 assert!(lo <= id && id <= hi);
2174 let next: Uuid =
2175 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
2176 assert!(!p.matches(&next) && next > hi);
2177 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
2179 assert_eq!(top.bounds().1, Uuid::max());
2180 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
2181 assert_eq!(bottom.bounds().0, Uuid::nil());
2182 }
2183
2184 #[test]
2185 fn content_ref_reads_a_short_id_and_only_eight_digits() {
2186 let r: ContentRef = "7ad26ccd".parse().unwrap();
2187 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
2188 assert_eq!(r.kind_str(), "content");
2189 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
2190 let full: ContentRef =
2192 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2193 assert!(matches!(full, ContentRef::Content(_)));
2194 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
2199 assert_eq!(err.leading_ref(), None);
2200 }
2201
2202 #[test]
2203 fn content_ref_pattern_admits_short_ids() {
2204 let short = CONTENT_ID_PREFIX_PATTERN
2205 .strip_prefix('^')
2206 .and_then(|p| p.strip_suffix('$'))
2207 .unwrap();
2208 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
2209 }
2210
2211 #[test]
2213 fn document_patterns_agree() {
2214 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
2215 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
2216 let name = PROMPT_NAME_PATTERN
2217 .strip_prefix('^')
2218 .and_then(|p| p.strip_suffix('$'))
2219 .unwrap();
2220 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
2221 }
2222
2223 #[test]
2224 fn platform_doc_serde_uses_the_canonical_slug() {
2225 let json =
2226 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
2227 assert_eq!(json, "\"protocol\"");
2228 let doc: PlatformDoc =
2229 serde_json::from_str("\"governance-protocol\"").unwrap();
2230 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
2231 }
2232
2233 #[test]
2234 fn ids_are_unique() {
2235 let a = AgentId::new();
2236 let b = AgentId::new();
2237 assert_ne!(a, b);
2238 }
2239
2240 #[test]
2241 fn serde_round_trip() {
2242 let id = PostId::new();
2243 let json = serde_json::to_string(&id).unwrap();
2244 let deserialized: PostId = serde_json::from_str(&json).unwrap();
2245 assert_eq!(id, deserialized);
2246 }
2247
2248 #[test]
2249 fn display_shows_uuid() {
2250 let id = CommunityId::new();
2251 let display = id.to_string();
2252 assert_eq!(display.len(), 36);
2254 assert!(display.contains('-'));
2255 }
2256
2257 #[test]
2258 fn from_uuid_round_trip() {
2259 let uuid = Uuid::new_v4();
2260 let id = AgentId::from(uuid);
2261 let back: Uuid = id.into();
2262 assert_eq!(uuid, back);
2263 }
2264
2265 #[test]
2269 fn every_id_round_trips_through_its_own_display() {
2270 let agent = AgentId::new();
2271 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
2272
2273 let action = ModerationActionId::new();
2274 assert_eq!(
2275 action.to_string().parse::<ModerationActionId>().unwrap(),
2276 action
2277 );
2278
2279 let content = ContentId::new();
2280 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
2281
2282 let feedback = FeedbackId::new();
2283 assert_eq!(
2284 feedback.to_string().parse::<FeedbackId>().unwrap(),
2285 feedback
2286 );
2287 }
2288
2289 #[test]
2290 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
2291 assert!("not-a-uuid".parse::<ContentId>().is_err());
2292 assert!("".parse::<ContentId>().is_err());
2293 }
2294
2295 #[test]
2300 fn content_id_is_wire_compatible_with_a_bare_uuid() {
2301 let uuid = Uuid::new_v4();
2302 let typed = ContentId::from(uuid);
2303 assert_eq!(
2304 serde_json::to_string(&typed).unwrap(),
2305 serde_json::to_string(&uuid).unwrap()
2306 );
2307 }
2308
2309 #[test]
2312 fn every_moderation_target_narrows_losslessly() {
2313 let uuid = Uuid::new_v4();
2314
2315 for (label, got) in [
2316 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
2317 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
2318 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
2319 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
2320 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
2321 ] {
2322 assert_eq!(
2323 got.as_uuid(),
2324 &uuid,
2325 "{label} -> ModerationTargetId lost the uuid"
2326 );
2327 }
2328 }
2329
2330 #[test]
2334 fn resolved_ids_narrow_to_content_id_losslessly() {
2335 let uuid = Uuid::new_v4();
2336
2337 assert_eq!(
2338 ContentId::from(PostId::from(uuid)).as_uuid(),
2339 &uuid,
2340 "PostId -> ContentId lost the uuid"
2341 );
2342 assert_eq!(
2343 ContentId::from(CommentId::from(uuid)).as_uuid(),
2344 &uuid,
2345 "CommentId -> ContentId lost the uuid"
2346 );
2347 assert_eq!(
2348 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
2349 .as_uuid(),
2350 &uuid,
2351 "PostOrCommentId -> ContentId lost the uuid"
2352 );
2353 }
2354
2355 #[test]
2356 fn json_is_plain_uuid_string() {
2357 let uuid = Uuid::new_v4();
2358 let id = AgentId::from(uuid);
2359 let id_json = serde_json::to_string(&id).unwrap();
2361 let uuid_json = serde_json::to_string(&uuid).unwrap();
2362 assert_eq!(id_json, uuid_json);
2363 }
2364
2365 #[cfg(feature = "schemars")]
2370 #[test]
2371 fn id_json_schema_is_inlined() {
2372 use schemars::JsonSchema;
2373
2374 assert!(
2375 <PostId as JsonSchema>::inline_schema(),
2376 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
2377 );
2378 assert!(<AgentId as JsonSchema>::inline_schema());
2379 assert!(<CommentId as JsonSchema>::inline_schema());
2380 assert!(<CommunityId as JsonSchema>::inline_schema());
2381 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
2382 assert!(<ContentRef as JsonSchema>::inline_schema());
2383 assert!(<FeedbackId as JsonSchema>::inline_schema());
2384 assert!(<ContactRequestId as JsonSchema>::inline_schema());
2385
2386 #[derive(schemars::JsonSchema)]
2390 #[allow(dead_code)]
2391 struct Container {
2392 post_id: PostId,
2394 agent_id: Option<AgentId>,
2396 gov_id: GovernanceLogId,
2398 maybe_gov_id: Option<GovernanceLogId>,
2400 content_ref: ContentRef,
2402 maybe_content_ref: Option<ContentRef>,
2404 feedback_id: FeedbackId,
2406 }
2407
2408 let schema = schemars::schema_for!(Container);
2409 let value = serde_json::to_value(&schema).unwrap();
2410
2411 assert!(
2413 value.get("$defs").is_none(),
2414 "no $defs should be emitted for ID-only container; got schema: {value}"
2415 );
2416
2417 let post_id = &value["properties"]["post_id"];
2419 assert!(
2420 post_id.get("$ref").is_none(),
2421 "post_id must not be a $ref; got: {post_id}"
2422 );
2423 assert_eq!(post_id["type"], "string");
2424 assert_eq!(post_id["format"], "uuid");
2425
2426 let agent_id = &value["properties"]["agent_id"];
2431 assert!(
2432 agent_id.get("$ref").is_none(),
2433 "agent_id must not be a $ref; got: {agent_id}"
2434 );
2435 let agent_id_str = agent_id.to_string();
2436 assert!(
2437 !agent_id_str.contains("$ref"),
2438 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
2439 );
2440 assert!(
2441 agent_id_str.contains("\"format\":\"uuid\""),
2442 "agent_id should still carry format=uuid; got: {agent_id}"
2443 );
2444
2445 for field in
2450 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
2451 {
2452 let f = &value["properties"][field];
2453 assert!(
2454 !f.to_string().contains("$ref"),
2455 "{field} must contain no $ref anywhere; got: {f}"
2456 );
2457 }
2458 assert_eq!(value["properties"]["gov_id"]["type"], "string");
2459 assert_eq!(
2460 value["properties"]["gov_id"]["pattern"],
2461 GOVERNANCE_LOG_ID_PATTERN
2462 );
2463 assert!(
2464 value["properties"]["maybe_gov_id"]
2465 .to_string()
2466 .contains("GOV|APP"),
2467 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
2468 value["properties"]["maybe_gov_id"]
2469 );
2470 assert_eq!(value["properties"]["content_ref"]["type"], "string");
2471 }
2472
2473 #[test]
2474 fn governance_log_id_accepts_only_citation_shapes() {
2475 for (good, prefix) in [
2476 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
2477 ("APP-2026-0003", GovernanceLogPrefix::App),
2478 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
2479 ("KEY-2026-0001", GovernanceLogPrefix::Key),
2480 ("REC-2026-0001", GovernanceLogPrefix::Rec),
2481 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
2482 ] {
2483 let id = good.parse::<GovernanceLogId>().unwrap();
2484 assert_eq!(id.as_str(), good, "{good} should parse");
2485 assert_eq!(id.prefix(), prefix);
2486 assert_eq!(id.prefix().as_str(), &good[..3]);
2487 }
2488 assert_eq!(
2489 GovernanceLogPrefix::ALL
2490 .map(|p| p.to_string())
2491 .concat()
2492 .len(),
2493 3 * GovernanceLogPrefix::ALL.len()
2494 );
2495 assert_eq!(
2497 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
2498 "GOV-2026-0006"
2499 );
2500 assert_eq!(
2501 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
2502 "GOV-2026-0006"
2503 );
2504 for bad in [
2505 "",
2506 "GOV-26-0006",
2507 "MOD-2026-0006",
2508 "GOV-2026-0006-1",
2509 "GOV-202X-0006",
2510 "3f1a0000-0000-0000-0000-000000000000",
2511 ] {
2512 assert!(
2513 bad.parse::<GovernanceLogId>().is_err(),
2514 "{bad:?} should not parse as a GovernanceLogId"
2515 );
2516 }
2517 }
2518
2519 #[test]
2523 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
2524 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2525 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
2526 let back: GovernanceLogId =
2527 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
2528 assert_eq!(back, id);
2529 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
2531 }
2532
2533 #[test]
2536 fn content_ref_round_trips_as_a_bare_string() {
2537 let uuid = Uuid::new_v4();
2538 let content = ContentRef::from(ContentId::from(uuid));
2539 assert_eq!(
2540 serde_json::to_value(&content).unwrap(),
2541 serde_json::json!(uuid.to_string())
2542 );
2543 assert_eq!(
2544 serde_json::from_value::<ContentRef>(serde_json::json!(
2545 uuid.to_string()
2546 ))
2547 .unwrap(),
2548 content
2549 );
2550
2551 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
2552 assert_eq!(
2553 serde_json::to_value(&gov).unwrap(),
2554 serde_json::json!("APP-2026-0003")
2555 );
2556 assert_eq!(
2557 serde_json::from_value::<ContentRef>(serde_json::json!(
2558 "APP-2026-0003"
2559 ))
2560 .unwrap(),
2561 gov
2562 );
2563
2564 assert!(gov.is_governance());
2565 assert!(!content.is_governance());
2566 assert_eq!(gov.kind_str(), "governance");
2567 assert_eq!(content.kind_str(), "content");
2568 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
2569 assert!(content.as_governance().is_none());
2570
2571 assert!("not-an-id".parse::<ContentRef>().is_err());
2573 assert!(
2574 serde_json::from_value::<ContentRef>(serde_json::json!(
2575 "not-an-id"
2576 ))
2577 .is_err()
2578 );
2579 }
2580
2581 #[test]
2583 fn every_readable_id_narrows_to_a_content_ref() {
2584 let uuid = Uuid::new_v4();
2585 for (label, got) in [
2586 ("PostId", ContentRef::from(PostId::from(uuid))),
2587 ("CommentId", ContentRef::from(CommentId::from(uuid))),
2588 ("ContentId", ContentRef::from(ContentId::from(uuid))),
2589 ] {
2590 assert_eq!(
2591 got,
2592 ContentRef::Content(ContentId::from(uuid)),
2593 "{label} -> ContentRef lost the uuid"
2594 );
2595 }
2596 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2597 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
2598 }
2599
2600 #[test]
2603 fn string_shaped_ids_round_trip_through_display() {
2604 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2605 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
2606
2607 let r = ContentRef::Governance(gov);
2608 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2609
2610 let r = ContentRef::Content(ContentId::new());
2611 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2612 }
2613
2614 #[test]
2615 fn post_or_comment_post_variant() {
2616 let inner = PostId::new();
2617 let tagged = PostOrCommentId::Post(inner);
2618 assert!(tagged.is_post());
2619 assert!(!tagged.is_comment());
2620 assert_eq!(tagged.as_post(), Some(inner));
2621 assert_eq!(tagged.as_comment(), None);
2622 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2623 assert_eq!(tagged.kind_str(), "post");
2624 }
2625
2626 #[test]
2627 fn post_or_comment_comment_variant() {
2628 let inner = CommentId::new();
2629 let tagged = PostOrCommentId::Comment(inner);
2630 assert!(tagged.is_comment());
2631 assert!(!tagged.is_post());
2632 assert_eq!(tagged.as_comment(), Some(inner));
2633 assert_eq!(tagged.as_post(), None);
2634 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2635 assert_eq!(tagged.kind_str(), "comment");
2636 }
2637
2638 #[test]
2639 fn post_or_comment_from_conversions() {
2640 let post = PostId::new();
2641 let comment = CommentId::new();
2642 let via_post: PostOrCommentId = post.into();
2643 let via_comment: PostOrCommentId = comment.into();
2644 assert_eq!(via_post, PostOrCommentId::Post(post));
2645 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
2646 }
2647
2648 #[test]
2649 fn post_or_comment_display_is_kind_colon_uuid() {
2650 let post = PostId::new();
2651 let tagged = PostOrCommentId::Post(post);
2652 let rendered = tagged.to_string();
2653 assert!(rendered.starts_with("post:"));
2654 assert!(rendered.contains(&post.to_string()));
2655 }
2656
2657 const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
2660
2661 #[test]
2664 fn a_bad_id_fails_in_plain_words() {
2665 let bad = "7ad26ccd-922f-484a-a37c-51777344a";
2666 let want = format!(
2667 "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
2668 );
2669 assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
2670 let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
2671 .unwrap_err()
2672 .to_string();
2673 assert!(err.starts_with(&want), "{err}");
2674 assert!(!err.contains("group"), "{err}");
2675 let err = serde_json::from_str::<AgentId>("5")
2676 .unwrap_err()
2677 .to_string();
2678 assert!(err.contains("expected AgentId as a UUID string"), "{err}");
2679 let long = "x".repeat(500);
2681 let msg = long.parse::<CommentId>().unwrap_err().to_string();
2682 assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
2683 }
2684
2685 #[test]
2687 fn ids_still_round_trip() {
2688 let id: PostId = FULL.parse().unwrap();
2689 let json = serde_json::to_string(&id).unwrap();
2690 assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
2691 let map: std::collections::HashMap<PostId, i64> =
2692 [(id, 3)].into_iter().collect();
2693 let json = serde_json::to_string(&map).unwrap();
2694 let back: std::collections::HashMap<PostId, i64> =
2695 serde_json::from_str(&json).unwrap();
2696 assert_eq!(back, map);
2697 }
2698
2699 fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
2700 s.parse()
2701 }
2702
2703 #[test]
2704 fn full_and_short_targets_parse() {
2705 let full = target(FULL).unwrap();
2706 assert_eq!(full.full().unwrap().to_string(), FULL);
2707 assert_eq!(full.to_string(), FULL);
2708 let short = target(" 7AD26CCD ").unwrap();
2709 assert!(short.full().is_none());
2710 assert_eq!(short.to_string(), "7ad26ccd");
2711 for variant in [
2713 FULL.replace('-', ""),
2714 FULL.to_uppercase(),
2715 FULL.replace('-', "\u{2011}"),
2716 ] {
2717 assert_eq!(target(&variant).unwrap(), full, "{variant}");
2718 }
2719 }
2720
2721 #[test]
2722 fn a_truncated_uuid_is_an_error_not_its_prefix() {
2723 let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
2724 let msg = err.in_field("reply_to").to_string();
2725 assert!(
2726 msg.starts_with(
2727 "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
2728 ),
2729 "{msg}"
2730 );
2731 for internal in [
2732 "group",
2733 "UUID parsing",
2734 "invalid length",
2735 "invalid character",
2736 ] {
2737 assert!(!msg.contains(internal), "{internal} leaked: {msg}");
2738 }
2739 assert!(target("7ad26cc").is_err());
2740 assert!(target("7ad26ccd9").is_err());
2741 }
2742
2743 #[test]
2744 fn trailing_text_names_the_id_it_starts_with() {
2745 let msg = target(&format!("{FULL} (the reply above)"))
2746 .unwrap_err()
2747 .to_string();
2748 assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
2749 }
2750
2751 #[derive(Debug, serde::Deserialize)]
2752 struct Probe {
2753 #[serde(deserialize_with = "content_target::reply_to")]
2754 #[allow(dead_code)]
2755 reply_to: ContentTarget,
2756 }
2757
2758 #[test]
2759 fn target_errors_name_the_field_for_strings_and_other_types() {
2760 let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
2761 .unwrap_err();
2762 assert!(
2763 err.to_string()
2764 .starts_with("`reply_to` must be a post or comment id"),
2765 "{err}"
2766 );
2767 let err =
2768 serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
2769 assert!(
2770 err.to_string()
2771 .contains("expected `reply_to` to be a post or comment id"),
2772 "{err}"
2773 );
2774 }
2775
2776 #[cfg(feature = "schemars")]
2777 #[test]
2778 fn target_schema_is_inline_and_admits_both_forms() {
2779 let schema = schemars::schema_for!(ContentTarget);
2780 let v = schema.as_value();
2781 assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
2782 assert!(v.get("format").is_none(), "a short id is not format: uuid");
2783 assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
2784 }
2785}