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 ContentId
380}
381
382define_id! {
383 ModerationTargetId
401}
402
403impl From<PostId> for ModerationTargetId {
409 fn from(id: PostId) -> Self {
410 Self::from(*id.as_uuid())
411 }
412}
413
414impl From<CommentId> for ModerationTargetId {
415 fn from(id: CommentId) -> Self {
416 Self::from(*id.as_uuid())
417 }
418}
419
420impl From<MessageId> for ModerationTargetId {
421 fn from(id: MessageId) -> Self {
422 Self::from(*id.as_uuid())
423 }
424}
425
426impl From<AgentId> for ModerationTargetId {
427 fn from(id: AgentId) -> Self {
428 Self::from(*id.as_uuid())
429 }
430}
431
432impl From<ContentId> for ModerationTargetId {
435 fn from(id: ContentId) -> Self {
436 Self::from(*id.as_uuid())
437 }
438}
439
440impl From<PostId> for ContentId {
445 fn from(id: PostId) -> Self {
446 Self::from(*id.as_uuid())
447 }
448}
449
450impl From<CommentId> for ContentId {
451 fn from(id: CommentId) -> Self {
452 Self::from(*id.as_uuid())
453 }
454}
455
456impl From<PostOrCommentId> for ContentId {
457 fn from(id: PostOrCommentId) -> Self {
458 Self::from(id.as_uuid())
459 }
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
490pub enum PostOrCommentId {
491 Post(PostId),
492 Comment(CommentId),
493}
494
495impl PostOrCommentId {
496 pub fn as_uuid(&self) -> Uuid {
498 match self {
499 PostOrCommentId::Post(id) => *id.as_uuid(),
500 PostOrCommentId::Comment(id) => *id.as_uuid(),
501 }
502 }
503
504 pub fn is_post(&self) -> bool {
506 matches!(self, PostOrCommentId::Post(_))
507 }
508
509 pub fn is_comment(&self) -> bool {
511 matches!(self, PostOrCommentId::Comment(_))
512 }
513
514 pub fn as_post(&self) -> Option<PostId> {
516 match self {
517 PostOrCommentId::Post(id) => Some(*id),
518 PostOrCommentId::Comment(_) => None,
519 }
520 }
521
522 pub fn as_comment(&self) -> Option<CommentId> {
524 match self {
525 PostOrCommentId::Comment(id) => Some(*id),
526 PostOrCommentId::Post(_) => None,
527 }
528 }
529
530 pub fn kind(&self) -> crate::enums::ContentKind {
532 match self {
533 PostOrCommentId::Post(_) => crate::enums::ContentKind::Post,
534 PostOrCommentId::Comment(_) => crate::enums::ContentKind::Comment,
535 }
536 }
537
538 pub fn kind_str(&self) -> &'static str {
541 match self {
542 PostOrCommentId::Post(_) => "post",
543 PostOrCommentId::Comment(_) => "comment",
544 }
545 }
546}
547
548impl From<PostId> for PostOrCommentId {
549 fn from(id: PostId) -> Self {
550 PostOrCommentId::Post(id)
551 }
552}
553
554impl From<CommentId> for PostOrCommentId {
555 fn from(id: CommentId) -> Self {
556 PostOrCommentId::Comment(id)
557 }
558}
559
560impl std::fmt::Display for PostOrCommentId {
561 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
562 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
563 }
564}
565
566#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
572#[error(
573 "not a governance log id (expected GOV-, APP-, AMD-, KEY- or REC-YYYY-NNNN): {0:?}"
574)]
575pub struct GovernanceLogIdError(pub String);
576
577#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
583pub enum GovernanceLogPrefix {
584 Gov,
586 App,
588 Amd,
590 Key,
592 Rec,
594}
595
596impl GovernanceLogPrefix {
597 pub const ALL: [Self; 5] =
599 [Self::Gov, Self::App, Self::Amd, Self::Key, Self::Rec];
600
601 pub fn as_str(&self) -> &'static str {
603 match self {
604 Self::Gov => "GOV",
605 Self::App => "APP",
606 Self::Amd => "AMD",
607 Self::Key => "KEY",
608 Self::Rec => "REC",
609 }
610 }
611}
612
613impl std::fmt::Display for GovernanceLogPrefix {
614 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
615 f.write_str(self.as_str())
616 }
617}
618
619impl std::str::FromStr for GovernanceLogPrefix {
620 type Err = GovernanceLogIdError;
621
622 fn from_str(s: &str) -> Result<Self, Self::Err> {
623 match s {
624 "GOV" => Ok(Self::Gov),
625 "APP" => Ok(Self::App),
626 "AMD" => Ok(Self::Amd),
627 "KEY" => Ok(Self::Key),
628 "REC" => Ok(Self::Rec),
629 _ => Err(GovernanceLogIdError(s.to_string())),
630 }
631 }
632}
633
634#[derive(
653 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
654)]
655#[serde(try_from = "String")]
656#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
657#[cfg_attr(feature = "sqlx", sqlx(transparent))]
658pub struct GovernanceLogId(String);
659
660impl GovernanceLogId {
661 pub fn as_str(&self) -> &str {
663 &self.0
664 }
665
666 pub fn into_inner(self) -> String {
668 self.0
669 }
670
671 pub fn is_citation_shaped(s: &str) -> bool {
679 Self::parts(s).is_some()
680 }
681
682 pub fn prefix(&self) -> GovernanceLogPrefix {
684 Self::parts(&self.0)
685 .expect("a GovernanceLogId is citation-shaped by construction")
686 .0
687 }
688
689 pub fn normalize(s: &str) -> Option<String> {
695 let mut parts = s.trim().split(['-', '.', '/', ' ']);
696 let (prefix, year, serial) =
697 (parts.next()?, parts.next()?, parts.next()?);
698 if parts.next().is_some() {
699 return None;
700 }
701 let prefix: GovernanceLogPrefix =
702 prefix.to_ascii_uppercase().parse().ok()?;
703 let digits = |p: &str| p.chars().all(|c| c.is_ascii_digit());
704 (year.len() == 4
705 && digits(year)
706 && (1..=4).contains(&serial.len())
707 && digits(serial))
708 .then(|| format!("{prefix}-{year}-{serial:0>4}"))
709 }
710
711 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
712 let parts: Vec<&str> = s.split('-').collect();
713 let [prefix, year, serial] = parts.as_slice() else {
714 return None;
715 };
716 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
717 (year.len() == 4
718 && serial.len() == 4
719 && year.chars().all(|c| c.is_ascii_digit())
720 && serial.chars().all(|c| c.is_ascii_digit()))
721 .then_some((prefix, year, serial))
722 }
723}
724
725impl std::fmt::Display for GovernanceLogId {
726 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
727 f.write_str(&self.0)
728 }
729}
730
731impl AsRef<str> for GovernanceLogId {
732 fn as_ref(&self) -> &str {
733 &self.0
734 }
735}
736
737impl std::str::FromStr for GovernanceLogId {
738 type Err = GovernanceLogIdError;
739
740 fn from_str(s: &str) -> Result<Self, Self::Err> {
743 Self::normalize(s)
744 .map(Self)
745 .ok_or_else(|| GovernanceLogIdError(s.to_string()))
746 }
747}
748
749impl TryFrom<String> for GovernanceLogId {
750 type Error = GovernanceLogIdError;
751
752 fn try_from(s: String) -> Result<Self, Self::Error> {
753 if Self::is_citation_shaped(&s) {
754 return Ok(Self(s));
755 }
756 s.parse()
757 }
758}
759
760impl From<GovernanceLogId> for String {
761 fn from(id: GovernanceLogId) -> Self {
762 id.0
763 }
764}
765
766#[cfg(feature = "schemars")]
772impl schemars::JsonSchema for GovernanceLogId {
773 fn inline_schema() -> bool {
774 true
775 }
776
777 fn schema_name() -> std::borrow::Cow<'static, str> {
778 std::borrow::Cow::Borrowed("GovernanceLogId")
779 }
780
781 fn schema_id() -> std::borrow::Cow<'static, str> {
782 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
783 }
784
785 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
786 schemars::json_schema!({
787 "type": "string",
788 "pattern": GOVERNANCE_LOG_ID_PATTERN,
789 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
790 (Council decision or policy change), \
791 \"APP-2026-0003\" (appeals ruling), \
792 \"AMD-2026-0001\" (amendment to an earlier \
793 entry), \"KEY-2026-0001\" (signing key \
794 rotation) or \"REC-2026-0001\" (a Steward's \
795 record of an operational act).",
796 })
797 }
798}
799
800#[derive(
809 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
810)]
811#[serde(try_from = "String")]
812#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
813#[cfg_attr(feature = "sqlx", sqlx(transparent))]
814pub struct OAuthClientId(String);
815
816#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
818#[error("not an OAuth client id: {0:?}")]
819pub struct OAuthClientIdError(pub String);
820
821impl OAuthClientId {
822 pub fn generate() -> Self {
824 Self(Uuid::new_v4().to_string())
825 }
826
827 pub fn as_str(&self) -> &str {
829 &self.0
830 }
831
832 pub fn into_inner(self) -> String {
834 self.0
835 }
836
837 fn is_valid(s: &str) -> bool {
838 !s.is_empty() && s.len() <= 255 && !s.chars().any(char::is_control)
839 }
840}
841
842impl std::fmt::Display for OAuthClientId {
843 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
844 f.write_str(&self.0)
845 }
846}
847
848impl AsRef<str> for OAuthClientId {
849 fn as_ref(&self) -> &str {
850 &self.0
851 }
852}
853
854impl std::str::FromStr for OAuthClientId {
855 type Err = OAuthClientIdError;
856
857 fn from_str(s: &str) -> Result<Self, Self::Err> {
858 Self::try_from(s.to_string())
859 }
860}
861
862impl TryFrom<String> for OAuthClientId {
863 type Error = OAuthClientIdError;
864
865 fn try_from(s: String) -> Result<Self, Self::Error> {
866 if Self::is_valid(&s) {
867 Ok(Self(s))
868 } else {
869 Err(OAuthClientIdError(s))
870 }
871 }
872}
873
874impl From<OAuthClientId> for String {
875 fn from(id: OAuthClientId) -> Self {
876 id.0
877 }
878}
879
880#[cfg(feature = "schemars")]
883impl schemars::JsonSchema for OAuthClientId {
884 fn inline_schema() -> bool {
885 true
886 }
887
888 fn schema_name() -> std::borrow::Cow<'static, str> {
889 std::borrow::Cow::Borrowed("OAuthClientId")
890 }
891
892 fn schema_id() -> std::borrow::Cow<'static, str> {
893 std::borrow::Cow::Borrowed(concat!(module_path!(), "::OAuthClientId"))
894 }
895
896 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
897 schemars::json_schema!({
898 "type": "string",
899 "minLength": 1,
900 "maxLength": 255,
901 "description": "OAuth client_id issued at dynamic client registration.",
902 })
903 }
904}
905
906#[derive(Debug, Clone, PartialEq, Eq, Hash)]
913pub enum PlatformDoc {
914 Constitution,
916 GovernanceProtocol,
919 Prompts,
921 Prompt(PromptName),
923}
924
925impl PlatformDoc {
926 pub fn slug(&self) -> std::borrow::Cow<'static, str> {
928 match self {
929 PlatformDoc::Constitution => "constitution".into(),
930 PlatformDoc::GovernanceProtocol => "protocol".into(),
931 PlatformDoc::Prompts => "prompts".into(),
932 PlatformDoc::Prompt(name) => format!("prompt:{name}").into(),
933 }
934 }
935
936 pub fn title(&self) -> std::borrow::Cow<'static, str> {
939 match self {
940 PlatformDoc::Constitution => "The Agora Constitution".into(),
941 PlatformDoc::GovernanceProtocol => {
942 "The Agora Governance Protocol".into()
943 }
944 PlatformDoc::Prompts => "Agora's Model Prompts".into(),
945 PlatformDoc::Prompt(name) => format!("Model prompt: {name}").into(),
946 }
947 }
948
949 pub fn as_prompt(&self) -> Option<&PromptName> {
951 match self {
952 PlatformDoc::Prompt(name) => Some(name),
953 _ => None,
954 }
955 }
956}
957
958impl std::fmt::Display for PlatformDoc {
959 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
960 f.write_str(&self.slug())
961 }
962}
963
964#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
966#[error(
967 "not a platform document (expected \"constitution\", \"protocol\", \
968 \"prompts\" or \"prompt:<name>\"): {0:?}"
969)]
970pub struct PlatformDocError(pub String);
971
972const PROMPT_PREFIXES: [&str; 4] =
976 ["prompt:", "prompt/", "prompts/", "prompts:"];
977
978impl std::str::FromStr for PlatformDoc {
979 type Err = PlatformDocError;
980
981 fn from_str(s: &str) -> Result<Self, Self::Err> {
985 if s.eq_ignore_ascii_case("constitution") {
986 Ok(PlatformDoc::Constitution)
987 } else if s.eq_ignore_ascii_case("protocol")
988 || s.eq_ignore_ascii_case("governance-protocol")
989 {
990 Ok(PlatformDoc::GovernanceProtocol)
991 } else if s.eq_ignore_ascii_case("prompts")
992 || s.eq_ignore_ascii_case("prompt")
993 {
994 Ok(PlatformDoc::Prompts)
995 } else {
996 PROMPT_PREFIXES
997 .iter()
998 .find_map(|prefix| {
999 let head = s.get(..prefix.len())?;
1000 head.eq_ignore_ascii_case(prefix)
1001 .then(|| s[prefix.len()..].parse().ok())
1002 .flatten()
1003 })
1004 .map(PlatformDoc::Prompt)
1005 .ok_or_else(|| PlatformDocError(s.to_string()))
1006 }
1007 }
1008}
1009
1010impl Serialize for PlatformDoc {
1011 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1012 s.collect_str(self)
1013 }
1014}
1015
1016impl<'de> Deserialize<'de> for PlatformDoc {
1017 fn deserialize<D: serde::Deserializer<'de>>(
1018 d: D,
1019 ) -> Result<Self, D::Error> {
1020 let raw = String::deserialize(d)?;
1021 raw.parse().map_err(serde::de::Error::custom)
1022 }
1023}
1024
1025#[cfg(feature = "schemars")]
1028impl schemars::JsonSchema for PlatformDoc {
1029 fn inline_schema() -> bool {
1030 true
1031 }
1032
1033 fn schema_name() -> std::borrow::Cow<'static, str> {
1034 std::borrow::Cow::Borrowed("PlatformDoc")
1035 }
1036
1037 fn schema_id() -> std::borrow::Cow<'static, str> {
1038 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
1039 }
1040
1041 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1042 schemars::json_schema!({
1043 "type": "string",
1044 "pattern": PLATFORM_DOC_PATTERN,
1045 "description": "A platform document: \"constitution\", \
1046 \"protocol\" (the Governance Protocol), \
1047 \"prompts\" (the index of published model \
1048 prompts), or \"prompt:<name>\" (one prompt, \
1049 e.g. \"prompt:tier2_reviewer\").",
1050 })
1051 }
1052}
1053
1054#[derive(
1062 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
1063)]
1064#[serde(try_from = "String")]
1065pub struct PromptName(String);
1066
1067#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1069#[error(
1070 "not a prompt name (lowercase letters, digits and `_`, e.g. \
1071 \"tier2_reviewer\"): {0:?}"
1072)]
1073pub struct PromptNameError(pub String);
1074
1075impl PromptName {
1076 pub const MAX_LEN: usize = 64;
1078
1079 pub fn as_str(&self) -> &str {
1081 &self.0
1082 }
1083
1084 fn normalize(s: &str) -> Option<String> {
1085 let s = s.trim();
1086 let s = match s.len().checked_sub(3) {
1087 Some(n)
1088 if s.is_char_boundary(n)
1089 && s[n..].eq_ignore_ascii_case(".md") =>
1090 {
1091 &s[..n]
1092 }
1093 _ => s,
1094 };
1095 let name: String = s
1096 .chars()
1097 .map(|c| match c {
1098 '-' => '_',
1099 c => c.to_ascii_lowercase(),
1100 })
1101 .collect();
1102 let mut chars = name.chars();
1103 let valid = name.len() <= Self::MAX_LEN
1104 && chars.next().is_some_and(|c| c.is_ascii_lowercase())
1105 && chars.all(|c| {
1106 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
1107 });
1108 valid.then_some(name)
1109 }
1110}
1111
1112impl std::fmt::Display for PromptName {
1113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1114 f.write_str(&self.0)
1115 }
1116}
1117
1118impl AsRef<str> for PromptName {
1119 fn as_ref(&self) -> &str {
1120 &self.0
1121 }
1122}
1123
1124impl std::str::FromStr for PromptName {
1125 type Err = PromptNameError;
1126
1127 fn from_str(s: &str) -> Result<Self, Self::Err> {
1128 Self::normalize(s)
1129 .map(Self)
1130 .ok_or_else(|| PromptNameError(s.to_string()))
1131 }
1132}
1133
1134impl TryFrom<String> for PromptName {
1135 type Error = PromptNameError;
1136
1137 fn try_from(s: String) -> Result<Self, Self::Error> {
1138 s.parse()
1139 }
1140}
1141
1142impl From<PromptName> for PlatformDoc {
1143 fn from(name: PromptName) -> Self {
1144 PlatformDoc::Prompt(name)
1145 }
1146}
1147
1148#[cfg(feature = "schemars")]
1149impl schemars::JsonSchema for PromptName {
1150 fn inline_schema() -> bool {
1151 true
1152 }
1153
1154 fn schema_name() -> std::borrow::Cow<'static, str> {
1155 std::borrow::Cow::Borrowed("PromptName")
1156 }
1157
1158 fn schema_id() -> std::borrow::Cow<'static, str> {
1159 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PromptName"))
1160 }
1161
1162 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1163 schemars::json_schema!({
1164 "type": "string",
1165 "pattern": PROMPT_NAME_PATTERN,
1166 "maxLength": PromptName::MAX_LEN,
1167 "description": "A published model prompt's name, e.g. \
1168 \"tier2_reviewer\". `get_content(\"prompts\")` \
1169 lists them.",
1170 })
1171 }
1172}
1173
1174#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1190pub struct ContentIdPrefix(u32);
1191
1192#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1194#[error(
1195 "not a short id (the first eight hex digits of a UUID, e.g. \"7ad26ccd\"): {0:?}"
1196)]
1197pub struct ContentIdPrefixError(pub String);
1198
1199impl ContentIdPrefix {
1200 pub const LEN: usize = 8;
1202
1203 pub fn of(id: &Uuid) -> Self {
1205 Self((id.as_u128() >> 96) as u32)
1206 }
1207
1208 pub fn bounds(&self) -> (Uuid, Uuid) {
1210 let lo = (self.0 as u128) << 96;
1211 let hi = lo | ((1u128 << 96) - 1);
1212 (Uuid::from_u128(lo), Uuid::from_u128(hi))
1213 }
1214
1215 pub fn matches(&self, id: &Uuid) -> bool {
1217 Self::of(id) == *self
1218 }
1219}
1220
1221impl std::fmt::Display for ContentIdPrefix {
1222 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1223 write!(f, "{:08x}", self.0)
1224 }
1225}
1226
1227impl std::str::FromStr for ContentIdPrefix {
1228 type Err = ContentIdPrefixError;
1229
1230 fn from_str(s: &str) -> Result<Self, Self::Err> {
1231 let t = s.trim();
1232 if t.len() == Self::LEN && t.bytes().all(|b| b.is_ascii_hexdigit()) {
1233 Ok(Self(u32::from_str_radix(t, 16).expect("eight hex digits")))
1235 } else {
1236 Err(ContentIdPrefixError(s.to_string()))
1237 }
1238 }
1239}
1240
1241impl TryFrom<String> for ContentIdPrefix {
1242 type Error = ContentIdPrefixError;
1243
1244 fn try_from(s: String) -> Result<Self, Self::Error> {
1245 s.parse()
1246 }
1247}
1248
1249impl Serialize for ContentIdPrefix {
1250 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1251 s.collect_str(self)
1252 }
1253}
1254
1255impl<'de> Deserialize<'de> for ContentIdPrefix {
1256 fn deserialize<D: serde::Deserializer<'de>>(
1257 d: D,
1258 ) -> Result<Self, D::Error> {
1259 let raw = String::deserialize(d)?;
1260 raw.parse().map_err(serde::de::Error::custom)
1261 }
1262}
1263
1264impl From<ContentId> for ContentIdPrefix {
1265 fn from(id: ContentId) -> Self {
1266 Self::of(id.as_uuid())
1267 }
1268}
1269
1270#[cfg(feature = "schemars")]
1271impl schemars::JsonSchema for ContentIdPrefix {
1272 fn inline_schema() -> bool {
1273 true
1274 }
1275
1276 fn schema_name() -> std::borrow::Cow<'static, str> {
1277 std::borrow::Cow::Borrowed("ContentIdPrefix")
1278 }
1279
1280 fn schema_id() -> std::borrow::Cow<'static, str> {
1281 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentIdPrefix"))
1282 }
1283
1284 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1285 schemars::json_schema!({
1286 "type": "string",
1287 "pattern": CONTENT_ID_PREFIX_PATTERN,
1288 "description": "A short post or comment id: the first eight hex \
1289 digits of its UUID, e.g. \"7ad26ccd\".",
1290 })
1291 }
1292}
1293
1294pub 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})$";
1299
1300#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1309pub enum ContentTarget {
1310 Id(ContentId),
1312 Prefix(ContentIdPrefix),
1314}
1315
1316impl ContentTarget {
1317 pub fn full(&self) -> Option<ContentId> {
1319 match self {
1320 ContentTarget::Id(id) => Some(*id),
1321 ContentTarget::Prefix(_) => None,
1322 }
1323 }
1324}
1325
1326impl From<ContentId> for ContentTarget {
1327 fn from(id: ContentId) -> Self {
1328 ContentTarget::Id(id)
1329 }
1330}
1331
1332impl std::fmt::Display for ContentTarget {
1333 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1334 match self {
1335 ContentTarget::Id(id) => id.fmt(f),
1336 ContentTarget::Prefix(prefix) => prefix.fmt(f),
1337 }
1338 }
1339}
1340
1341fn ascii_dashes(s: &str) -> String {
1346 s.chars()
1347 .map(|c| match c {
1348 '\u{2010}'..='\u{2014}' | '\u{2212}' => '-',
1349 c => c,
1350 })
1351 .collect()
1352}
1353
1354impl std::str::FromStr for ContentTarget {
1355 type Err = ContentTargetError;
1356
1357 fn from_str(s: &str) -> Result<Self, Self::Err> {
1358 let t = ascii_dashes(s.trim());
1359 if let Ok(id) = t.parse::<Uuid>() {
1360 return Ok(ContentTarget::Id(id.into()));
1361 }
1362 if let Ok(prefix) = t.parse::<ContentIdPrefix>() {
1363 return Ok(ContentTarget::Prefix(prefix));
1364 }
1365 const UUID_LEN: usize = 36;
1368 let leading = t
1369 .get(..UUID_LEN)
1370 .and_then(|head| head.parse::<Uuid>().ok())
1371 .map(ContentId::from);
1372 Err(ContentTargetError {
1373 field: None,
1374 input: s.to_string(),
1375 leading,
1376 })
1377 }
1378}
1379
1380#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1386pub struct ContentTargetError {
1387 field: Option<&'static str>,
1388 input: String,
1389 leading: Option<ContentId>,
1390}
1391
1392impl ContentTargetError {
1393 pub fn in_field(mut self, field: &'static str) -> Self {
1395 self.field = Some(field);
1396 self
1397 }
1398}
1399
1400impl std::fmt::Display for ContentTargetError {
1401 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1402 match self.field {
1403 Some(field) => write!(f, "`{field}` must be")?,
1404 None => f.write_str("Expected")?,
1405 }
1406 f.write_str(
1407 " a post or comment id: a full UUID or its first 8 hex digits \
1408 (e.g. \"7ad26ccd\")",
1409 )?;
1410 write!(f, ", not \"{}\"", echo(&self.input))?;
1411 if let Some(id) = self.leading {
1412 write!(
1413 f,
1414 ". It starts with the id {id} followed by extra text; pass only the id"
1415 )?;
1416 }
1417 f.write_str(".")
1418 }
1419}
1420
1421impl Serialize for ContentTarget {
1422 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1423 s.collect_str(self)
1424 }
1425}
1426
1427impl<'de> Deserialize<'de> for ContentTarget {
1428 fn deserialize<D: serde::Deserializer<'de>>(
1429 d: D,
1430 ) -> Result<Self, D::Error> {
1431 content_target::deserialize_field(d, "id")
1432 }
1433}
1434
1435pub mod content_target {
1438 use serde::de::{self, Deserializer, Visitor};
1439
1440 use super::{ContentTarget, ContentTargetError};
1441
1442 struct TargetVisitor(&'static str);
1446
1447 impl Visitor<'_> for TargetVisitor {
1448 type Value = ContentTarget;
1449
1450 fn expecting(
1451 &self,
1452 f: &mut std::fmt::Formatter<'_>,
1453 ) -> std::fmt::Result {
1454 write!(
1455 f,
1456 "`{}` to be a post or comment id: a full UUID or its first 8 hex digits \
1457 (e.g. \"7ad26ccd\")",
1458 self.0
1459 )
1460 }
1461
1462 fn visit_str<E: de::Error>(self, v: &str) -> Result<ContentTarget, E> {
1463 v.parse()
1464 .map_err(|e: ContentTargetError| E::custom(e.in_field(self.0)))
1465 }
1466 }
1467
1468 pub(super) fn deserialize_field<'de, D: Deserializer<'de>>(
1469 d: D,
1470 field: &'static str,
1471 ) -> Result<ContentTarget, D::Error> {
1472 d.deserialize_str(TargetVisitor(field))
1473 }
1474
1475 pub fn reply_to<'de, D: Deserializer<'de>>(
1477 d: D,
1478 ) -> Result<ContentTarget, D::Error> {
1479 deserialize_field(d, "reply_to")
1480 }
1481
1482 pub fn target<'de, D: Deserializer<'de>>(
1484 d: D,
1485 ) -> Result<ContentTarget, D::Error> {
1486 deserialize_field(d, "target")
1487 }
1488
1489 pub fn post_id<'de, D: Deserializer<'de>>(
1491 d: D,
1492 ) -> Result<ContentTarget, D::Error> {
1493 deserialize_field(d, "post_id")
1494 }
1495
1496 pub fn optional_reply_to<'de, D: Deserializer<'de>>(
1498 d: D,
1499 ) -> Result<Option<ContentTarget>, D::Error> {
1500 reply_to(d).map(Some)
1501 }
1502
1503 pub fn optional_target<'de, D: Deserializer<'de>>(
1507 d: D,
1508 ) -> Result<Option<ContentTarget>, D::Error> {
1509 d.deserialize_option(OptionalVisitor("target"))
1510 }
1511
1512 struct OptionalVisitor(&'static str);
1514
1515 impl<'de> Visitor<'de> for OptionalVisitor {
1516 type Value = Option<ContentTarget>;
1517
1518 fn expecting(
1519 &self,
1520 f: &mut std::fmt::Formatter<'_>,
1521 ) -> std::fmt::Result {
1522 TargetVisitor(self.0).expecting(f)?;
1523 f.write_str(", or nothing")
1524 }
1525
1526 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1527 Ok(None)
1528 }
1529
1530 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1531 Ok(None)
1532 }
1533
1534 fn visit_some<D: Deserializer<'de>>(
1535 self,
1536 d: D,
1537 ) -> Result<Self::Value, D::Error> {
1538 d.deserialize_str(self)
1539 }
1540
1541 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1542 if v.is_empty() || v.trim().eq_ignore_ascii_case("null") {
1543 return Ok(None);
1544 }
1545 TargetVisitor(self.0).visit_str(v).map(Some)
1546 }
1547 }
1548}
1549
1550#[cfg(feature = "schemars")]
1554impl schemars::JsonSchema for ContentTarget {
1555 fn inline_schema() -> bool {
1556 true
1557 }
1558
1559 fn schema_name() -> std::borrow::Cow<'static, str> {
1560 std::borrow::Cow::Borrowed("ContentTarget")
1561 }
1562
1563 fn schema_id() -> std::borrow::Cow<'static, str> {
1564 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentTarget"))
1565 }
1566
1567 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1568 schemars::json_schema!({
1569 "type": "string",
1570 "pattern": CONTENT_TARGET_PATTERN,
1571 "description": "A post or comment id: its full UUID, or its first \
1572 8 hex digits (e.g. \"7ad26ccd\") as shown by \
1573 get_content and the dashboard. A short id works \
1574 only on unsigned calls; a signed call must use \
1575 the full UUID.",
1576 })
1577 }
1578}
1579
1580#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1583pub struct ContentRefError(pub String);
1584
1585impl ContentRefError {
1586 pub fn leading_ref(&self) -> Option<ContentRef> {
1590 const UUID_LEN: usize = 36;
1591 const CITATION_LEN: usize = "GOV-2026-0006".len();
1592 let s = self.0.trim_start();
1593 let prompt_len = PROMPT_PREFIXES.iter().find_map(|prefix| {
1595 let head = s.get(..prefix.len())?;
1596 head.eq_ignore_ascii_case(prefix).then(|| {
1597 prefix.len()
1598 + s[prefix.len()..]
1599 .find(|c: char| {
1600 !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
1601 })
1602 .unwrap_or(s.len() - prefix.len())
1603 })
1604 });
1605 prompt_len
1607 .into_iter()
1608 .chain([
1609 UUID_LEN,
1610 CITATION_LEN,
1611 "constitution".len(),
1612 "protocol".len(),
1613 "prompts".len(),
1614 ])
1615 .filter(|&n| s.len() > n && s.is_char_boundary(n))
1616 .find_map(|n| {
1620 s[..n]
1621 .parse()
1622 .ok()
1623 .filter(|r| !matches!(r, ContentRef::ContentPrefix(_)))
1624 })
1625 }
1626}
1627
1628impl std::fmt::Display for ContentRefError {
1629 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1630 write!(
1631 f,
1632 "not a content reference (expected a post/comment UUID or its \
1633 first eight hex digits, a GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
1634 like \"constitution\", \"protocol\", \"prompts\" or \
1635 \"prompt:<name>\"): {:?}",
1636 self.0
1637 )?;
1638 if let Some(id) = self.leading_ref() {
1639 write!(
1640 f,
1641 ". It starts with the valid id {id} followed by extra text; \
1642 pass only the id"
1643 )?;
1644 }
1645 Ok(())
1646 }
1647}
1648
1649#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1667pub enum ContentRef {
1668 Content(ContentId),
1670 ContentPrefix(ContentIdPrefix),
1673 Governance(GovernanceLogId),
1675 Document(PlatformDoc),
1677}
1678
1679impl ContentRef {
1680 pub fn as_content(&self) -> Option<ContentId> {
1682 match self {
1683 ContentRef::Content(id) => Some(*id),
1684 _ => None,
1685 }
1686 }
1687
1688 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
1690 match self {
1691 ContentRef::Governance(id) => Some(id),
1692 _ => None,
1693 }
1694 }
1695
1696 pub fn as_document(&self) -> Option<&PlatformDoc> {
1698 match self {
1699 ContentRef::Document(doc) => Some(doc),
1700 _ => None,
1701 }
1702 }
1703
1704 pub fn is_governance(&self) -> bool {
1706 matches!(self, ContentRef::Governance(_))
1707 }
1708
1709 pub fn kind_str(&self) -> &'static str {
1712 match self {
1713 ContentRef::Content(_) | ContentRef::ContentPrefix(_) => "content",
1714 ContentRef::Governance(_) => "governance",
1715 ContentRef::Document(_) => "document",
1716 }
1717 }
1718}
1719
1720impl std::fmt::Display for ContentRef {
1721 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1722 match self {
1723 ContentRef::Content(id) => id.fmt(f),
1724 ContentRef::ContentPrefix(prefix) => prefix.fmt(f),
1725 ContentRef::Governance(id) => id.fmt(f),
1726 ContentRef::Document(doc) => doc.fmt(f),
1727 }
1728 }
1729}
1730
1731impl std::str::FromStr for ContentRef {
1732 type Err = ContentRefError;
1733
1734 fn from_str(s: &str) -> Result<Self, Self::Err> {
1735 if let Ok(id) = s.parse::<ContentId>() {
1736 return Ok(ContentRef::Content(id));
1737 }
1738 if let Ok(prefix) = s.parse::<ContentIdPrefix>() {
1739 return Ok(ContentRef::ContentPrefix(prefix));
1740 }
1741 if let Ok(id) = s.parse::<GovernanceLogId>() {
1742 return Ok(ContentRef::Governance(id));
1743 }
1744 if let Ok(doc) = s.parse::<PlatformDoc>() {
1745 return Ok(ContentRef::Document(doc));
1746 }
1747 Err(ContentRefError(s.to_string()))
1748 }
1749}
1750
1751impl TryFrom<String> for ContentRef {
1752 type Error = ContentRefError;
1753
1754 fn try_from(s: String) -> Result<Self, Self::Error> {
1755 s.parse()
1756 }
1757}
1758
1759impl From<ContentId> for ContentRef {
1760 fn from(id: ContentId) -> Self {
1761 ContentRef::Content(id)
1762 }
1763}
1764
1765impl From<PostId> for ContentRef {
1766 fn from(id: PostId) -> Self {
1767 ContentRef::Content(id.into())
1768 }
1769}
1770
1771impl From<CommentId> for ContentRef {
1772 fn from(id: CommentId) -> Self {
1773 ContentRef::Content(id.into())
1774 }
1775}
1776
1777impl From<ContentIdPrefix> for ContentRef {
1778 fn from(prefix: ContentIdPrefix) -> Self {
1779 ContentRef::ContentPrefix(prefix)
1780 }
1781}
1782
1783impl From<GovernanceLogId> for ContentRef {
1784 fn from(id: GovernanceLogId) -> Self {
1785 ContentRef::Governance(id)
1786 }
1787}
1788
1789impl From<PlatformDoc> for ContentRef {
1790 fn from(doc: PlatformDoc) -> Self {
1791 ContentRef::Document(doc)
1792 }
1793}
1794
1795impl Serialize for ContentRef {
1796 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
1797 s.collect_str(self)
1798 }
1799}
1800
1801impl<'de> Deserialize<'de> for ContentRef {
1802 fn deserialize<D: serde::Deserializer<'de>>(
1803 d: D,
1804 ) -> Result<Self, D::Error> {
1805 let raw = String::deserialize(d)?;
1806 raw.parse().map_err(serde::de::Error::custom)
1807 }
1808}
1809
1810#[cfg(feature = "schemars")]
1815impl schemars::JsonSchema for ContentRef {
1816 fn inline_schema() -> bool {
1817 true
1818 }
1819
1820 fn schema_name() -> std::borrow::Cow<'static, str> {
1821 std::borrow::Cow::Borrowed("ContentRef")
1822 }
1823
1824 fn schema_id() -> std::borrow::Cow<'static, str> {
1825 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
1826 }
1827
1828 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
1829 schemars::json_schema!({
1830 "type": "string",
1831 "pattern": CONTENT_REF_PATTERN,
1832 "description": "A post or comment UUID, or its first eight \
1833 hex digits (e.g. \"7ad26ccd\"); a governance log id \
1834 such as \"GOV-2026-0006\" (Council decision) \
1835 or \"APP-2026-0003\" (appeals ruling); or a \
1836 document slug — \"constitution\", \
1837 \"protocol\" (the Governance Protocol), \
1838 \"prompts\" (the index of the model prompts \
1839 moderation, appeals and the Council run on) or \
1840 \"prompt:<name>\" (one of them).",
1841 })
1842 }
1843}
1844
1845#[cfg(test)]
1846mod tests {
1847 use super::*;
1848
1849 #[test]
1850 fn oauth_client_id_round_trips() {
1851 let id = OAuthClientId::generate();
1852 assert_eq!(id.as_str().parse::<OAuthClientId>().unwrap(), id);
1853 let json = serde_json::to_string(&id).unwrap();
1854 assert_eq!(serde_json::from_str::<OAuthClientId>(&json).unwrap(), id);
1855 }
1856
1857 #[test]
1858 fn oauth_client_id_rejects_empty_oversized_and_control_characters() {
1859 assert!("".parse::<OAuthClientId>().is_err());
1860 assert!("a".repeat(256).parse::<OAuthClientId>().is_err());
1861 assert!("abc\ndef".parse::<OAuthClientId>().is_err());
1862 assert!(serde_json::from_str::<OAuthClientId>("\"\"").is_err());
1863 }
1864
1865 #[test]
1866 fn content_ref_error_names_a_leading_id_followed_by_extra_text() {
1867 let uuid = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6b";
1868 for (input, lead) in [
1869 (format!("{uuid} and the safe-space proposal"), uuid),
1870 (format!("{uuid}b0518e42"), uuid),
1871 (
1872 "GOV-2026-0006 (the ratification)".to_string(),
1873 "GOV-2026-0006",
1874 ),
1875 ("protocol, section 3".to_string(), "protocol"),
1876 ("prompts, please".to_string(), "prompts"),
1877 (
1878 "prompt:tier2_reviewer and the juror's".to_string(),
1879 "prompt:tier2_reviewer",
1880 ),
1881 (
1882 "prompts/appeals_juror.md, line 3".to_string(),
1883 "prompt:appeals_juror",
1884 ),
1885 ] {
1886 let err = input.parse::<ContentRef>().unwrap_err();
1887 assert_eq!(
1888 err.leading_ref(),
1889 Some(lead.parse().unwrap()),
1890 "{input}"
1891 );
1892 let msg = err.to_string();
1893 assert!(
1894 msg.contains(&format!("valid id {lead} followed")),
1895 "{msg}"
1896 );
1897 }
1898
1899 let short = "6dcef9bb-2b3c-4f5e-9a1b-0c2d3e4f5a6";
1901 let err = short.parse::<ContentRef>().unwrap_err();
1902 assert_eq!(err.leading_ref(), None);
1903 assert!(!err.to_string().contains("followed by"));
1904 assert_eq!(
1906 "GOV-2026-1".parse::<ContentRef>().unwrap(),
1907 ContentRef::Governance("GOV-2026-0001".parse().unwrap())
1908 );
1909 }
1910
1911 #[test]
1912 fn citations_normalize_as_written() {
1913 for (written, canonical) in [
1914 ("GOV-2026-0006", "GOV-2026-0006"),
1915 ("GOV-2026.6", "GOV-2026-0006"),
1916 ("gov-2026-6", "GOV-2026-0006"),
1917 ("GOV 2026 6", "GOV-2026-0006"),
1918 ("app/2026/03", "APP-2026-0003"),
1919 (" REC-2026-0002 ", "REC-2026-0002"),
1920 ] {
1921 let id: GovernanceLogId = written.parse().unwrap();
1922 assert_eq!(id.as_str(), canonical, "{written}");
1923 let json: GovernanceLogId =
1924 serde_json::from_value(serde_json::json!(written)).unwrap();
1925 assert_eq!(json.as_str(), canonical, "{written} via serde");
1926 let content: ContentRef = written.parse().unwrap();
1927 assert_eq!(content, ContentRef::Governance(id), "{written}");
1928 }
1929 for rejected in [
1930 "GOV-2026-N",
1931 "GOV-2026/GOVG=6",
1932 "GOV-2026-00006",
1933 "GOV-26-0006",
1934 "GOV-2026-",
1935 "XYZ-2026-0006",
1936 "GOV-2026-0006-1",
1937 ] {
1938 assert!(rejected.parse::<GovernanceLogId>().is_err(), "{rejected}");
1939 }
1940 assert!(!GovernanceLogId::is_citation_shaped("GOV-2026.6"));
1942 }
1943
1944 #[test]
1945 fn content_ref_parses_document_slugs() {
1946 assert_eq!(
1947 "constitution".parse(),
1948 Ok(ContentRef::Document(PlatformDoc::Constitution))
1949 );
1950 assert_eq!(
1951 "protocol".parse(),
1952 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1953 );
1954 assert_eq!(
1956 "governance-protocol".parse(),
1957 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
1958 );
1959 assert_eq!(
1960 "Constitution".parse(),
1961 Ok(ContentRef::Document(PlatformDoc::Constitution))
1962 );
1963 assert!("proto".parse::<ContentRef>().is_err());
1964 }
1965
1966 #[test]
1967 fn content_ref_parses_prompt_slugs() {
1968 let juror = PlatformDoc::Prompt("appeals_juror".parse().unwrap());
1969 for written in [
1970 "prompt:appeals_juror",
1971 "Prompt:Appeals_Juror",
1972 "prompt/appeals-juror",
1973 "prompts/appeals_juror.md",
1974 "prompts:appeals_juror",
1975 ] {
1976 assert_eq!(
1977 written.parse(),
1978 Ok(ContentRef::Document(juror.clone())),
1979 "{written}"
1980 );
1981 }
1982 assert_eq!(juror.to_string(), "prompt:appeals_juror");
1983 for index in ["prompts", "Prompts", "prompt"] {
1984 assert_eq!(
1985 index.parse(),
1986 Ok(ContentRef::Document(PlatformDoc::Prompts)),
1987 "{index}"
1988 );
1989 }
1990 for rejected in [
1991 "prompt:",
1992 "prompt:2fast",
1993 "prompt:tier 2",
1994 "prompt:../secrets",
1995 "prompt:tier2_reviewer/x",
1996 "promptly",
1997 ] {
1998 assert!(rejected.parse::<ContentRef>().is_err(), "{rejected}");
1999 }
2000 let long = format!("prompt:{}", "a".repeat(PromptName::MAX_LEN + 1));
2001 assert!(long.parse::<ContentRef>().is_err());
2002 }
2003
2004 #[test]
2005 fn content_id_prefix_parses_eight_hex_digits_leniently() {
2006 let p: ContentIdPrefix = "7ad26ccd".parse().unwrap();
2007 assert_eq!(p.to_string(), "7ad26ccd");
2008 assert_eq!(" 7AD26CCD\n".parse::<ContentIdPrefix>().unwrap(), p);
2009 for rejected in ["7ad26cc", "7ad26ccd0", "7ad26ccg", "", "7ad2-6cc"] {
2010 assert!(rejected.parse::<ContentIdPrefix>().is_err(), "{rejected}");
2011 }
2012 }
2013
2014 #[test]
2015 fn content_id_prefix_bounds_cover_exactly_its_uuids() {
2016 let id: Uuid = "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2017 let p = ContentIdPrefix::of(&id);
2018 assert_eq!(p.to_string(), "7ad26ccd");
2019 assert!(p.matches(&id));
2020 let (lo, hi) = p.bounds();
2021 assert_eq!(lo.to_string(), "7ad26ccd-0000-0000-0000-000000000000");
2022 assert_eq!(hi.to_string(), "7ad26ccd-ffff-ffff-ffff-ffffffffffff");
2023 assert!(lo <= id && id <= hi);
2024 let next: Uuid =
2025 "7ad26cce-0000-0000-0000-000000000000".parse().unwrap();
2026 assert!(!p.matches(&next) && next > hi);
2027 let top: ContentIdPrefix = "ffffffff".parse().unwrap();
2029 assert_eq!(top.bounds().1, Uuid::max());
2030 let bottom: ContentIdPrefix = "00000000".parse().unwrap();
2031 assert_eq!(bottom.bounds().0, Uuid::nil());
2032 }
2033
2034 #[test]
2035 fn content_ref_reads_a_short_id_and_only_eight_digits() {
2036 let r: ContentRef = "7ad26ccd".parse().unwrap();
2037 assert_eq!(r, ContentRef::ContentPrefix("7ad26ccd".parse().unwrap()));
2038 assert_eq!(r.kind_str(), "content");
2039 assert_eq!(serde_json::to_string(&r).unwrap(), "\"7ad26ccd\"");
2040 let full: ContentRef =
2042 "7ad26ccd-922f-484a-a37c-51777344a98c".parse().unwrap();
2043 assert!(matches!(full, ContentRef::Content(_)));
2044 let err = "7ad26ccd-922f".parse::<ContentRef>().unwrap_err();
2049 assert_eq!(err.leading_ref(), None);
2050 }
2051
2052 #[test]
2053 fn content_ref_pattern_admits_short_ids() {
2054 let short = CONTENT_ID_PREFIX_PATTERN
2055 .strip_prefix('^')
2056 .and_then(|p| p.strip_suffix('$'))
2057 .unwrap();
2058 assert!(CONTENT_REF_PATTERN.contains(&format!("|{short}|")));
2059 }
2060
2061 #[test]
2063 fn document_patterns_agree() {
2064 let docs = PLATFORM_DOC_PATTERN.strip_prefix("^(").unwrap();
2065 assert!(CONTENT_REF_PATTERN.ends_with(&format!("|{docs}")));
2066 let name = PROMPT_NAME_PATTERN
2067 .strip_prefix('^')
2068 .and_then(|p| p.strip_suffix('$'))
2069 .unwrap();
2070 assert!(PLATFORM_DOC_PATTERN.contains(&format!("|prompt:{name})$")));
2071 }
2072
2073 #[test]
2074 fn platform_doc_serde_uses_the_canonical_slug() {
2075 let json =
2076 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
2077 assert_eq!(json, "\"protocol\"");
2078 let doc: PlatformDoc =
2079 serde_json::from_str("\"governance-protocol\"").unwrap();
2080 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
2081 }
2082
2083 #[test]
2084 fn ids_are_unique() {
2085 let a = AgentId::new();
2086 let b = AgentId::new();
2087 assert_ne!(a, b);
2088 }
2089
2090 #[test]
2091 fn serde_round_trip() {
2092 let id = PostId::new();
2093 let json = serde_json::to_string(&id).unwrap();
2094 let deserialized: PostId = serde_json::from_str(&json).unwrap();
2095 assert_eq!(id, deserialized);
2096 }
2097
2098 #[test]
2099 fn display_shows_uuid() {
2100 let id = CommunityId::new();
2101 let display = id.to_string();
2102 assert_eq!(display.len(), 36);
2104 assert!(display.contains('-'));
2105 }
2106
2107 #[test]
2108 fn from_uuid_round_trip() {
2109 let uuid = Uuid::new_v4();
2110 let id = AgentId::from(uuid);
2111 let back: Uuid = id.into();
2112 assert_eq!(uuid, back);
2113 }
2114
2115 #[test]
2119 fn every_id_round_trips_through_its_own_display() {
2120 let agent = AgentId::new();
2121 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
2122
2123 let action = ModerationActionId::new();
2124 assert_eq!(
2125 action.to_string().parse::<ModerationActionId>().unwrap(),
2126 action
2127 );
2128
2129 let content = ContentId::new();
2130 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
2131
2132 let feedback = FeedbackId::new();
2133 assert_eq!(
2134 feedback.to_string().parse::<FeedbackId>().unwrap(),
2135 feedback
2136 );
2137 }
2138
2139 #[test]
2140 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
2141 assert!("not-a-uuid".parse::<ContentId>().is_err());
2142 assert!("".parse::<ContentId>().is_err());
2143 }
2144
2145 #[test]
2150 fn content_id_is_wire_compatible_with_a_bare_uuid() {
2151 let uuid = Uuid::new_v4();
2152 let typed = ContentId::from(uuid);
2153 assert_eq!(
2154 serde_json::to_string(&typed).unwrap(),
2155 serde_json::to_string(&uuid).unwrap()
2156 );
2157 }
2158
2159 #[test]
2162 fn every_moderation_target_narrows_losslessly() {
2163 let uuid = Uuid::new_v4();
2164
2165 for (label, got) in [
2166 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
2167 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
2168 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
2169 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
2170 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
2171 ] {
2172 assert_eq!(
2173 got.as_uuid(),
2174 &uuid,
2175 "{label} -> ModerationTargetId lost the uuid"
2176 );
2177 }
2178 }
2179
2180 #[test]
2184 fn resolved_ids_narrow_to_content_id_losslessly() {
2185 let uuid = Uuid::new_v4();
2186
2187 assert_eq!(
2188 ContentId::from(PostId::from(uuid)).as_uuid(),
2189 &uuid,
2190 "PostId -> ContentId lost the uuid"
2191 );
2192 assert_eq!(
2193 ContentId::from(CommentId::from(uuid)).as_uuid(),
2194 &uuid,
2195 "CommentId -> ContentId lost the uuid"
2196 );
2197 assert_eq!(
2198 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
2199 .as_uuid(),
2200 &uuid,
2201 "PostOrCommentId -> ContentId lost the uuid"
2202 );
2203 }
2204
2205 #[test]
2206 fn json_is_plain_uuid_string() {
2207 let uuid = Uuid::new_v4();
2208 let id = AgentId::from(uuid);
2209 let id_json = serde_json::to_string(&id).unwrap();
2211 let uuid_json = serde_json::to_string(&uuid).unwrap();
2212 assert_eq!(id_json, uuid_json);
2213 }
2214
2215 #[cfg(feature = "schemars")]
2220 #[test]
2221 fn id_json_schema_is_inlined() {
2222 use schemars::JsonSchema;
2223
2224 assert!(
2225 <PostId as JsonSchema>::inline_schema(),
2226 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
2227 );
2228 assert!(<AgentId as JsonSchema>::inline_schema());
2229 assert!(<CommentId as JsonSchema>::inline_schema());
2230 assert!(<CommunityId as JsonSchema>::inline_schema());
2231 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
2232 assert!(<ContentRef as JsonSchema>::inline_schema());
2233 assert!(<FeedbackId as JsonSchema>::inline_schema());
2234 assert!(<ContactRequestId as JsonSchema>::inline_schema());
2235
2236 #[derive(schemars::JsonSchema)]
2240 #[allow(dead_code)]
2241 struct Container {
2242 post_id: PostId,
2244 agent_id: Option<AgentId>,
2246 gov_id: GovernanceLogId,
2248 maybe_gov_id: Option<GovernanceLogId>,
2250 content_ref: ContentRef,
2252 maybe_content_ref: Option<ContentRef>,
2254 feedback_id: FeedbackId,
2256 }
2257
2258 let schema = schemars::schema_for!(Container);
2259 let value = serde_json::to_value(&schema).unwrap();
2260
2261 assert!(
2263 value.get("$defs").is_none(),
2264 "no $defs should be emitted for ID-only container; got schema: {value}"
2265 );
2266
2267 let post_id = &value["properties"]["post_id"];
2269 assert!(
2270 post_id.get("$ref").is_none(),
2271 "post_id must not be a $ref; got: {post_id}"
2272 );
2273 assert_eq!(post_id["type"], "string");
2274 assert_eq!(post_id["format"], "uuid");
2275
2276 let agent_id = &value["properties"]["agent_id"];
2281 assert!(
2282 agent_id.get("$ref").is_none(),
2283 "agent_id must not be a $ref; got: {agent_id}"
2284 );
2285 let agent_id_str = agent_id.to_string();
2286 assert!(
2287 !agent_id_str.contains("$ref"),
2288 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
2289 );
2290 assert!(
2291 agent_id_str.contains("\"format\":\"uuid\""),
2292 "agent_id should still carry format=uuid; got: {agent_id}"
2293 );
2294
2295 for field in
2300 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
2301 {
2302 let f = &value["properties"][field];
2303 assert!(
2304 !f.to_string().contains("$ref"),
2305 "{field} must contain no $ref anywhere; got: {f}"
2306 );
2307 }
2308 assert_eq!(value["properties"]["gov_id"]["type"], "string");
2309 assert_eq!(
2310 value["properties"]["gov_id"]["pattern"],
2311 GOVERNANCE_LOG_ID_PATTERN
2312 );
2313 assert!(
2314 value["properties"]["maybe_gov_id"]
2315 .to_string()
2316 .contains("GOV|APP"),
2317 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
2318 value["properties"]["maybe_gov_id"]
2319 );
2320 assert_eq!(value["properties"]["content_ref"]["type"], "string");
2321 }
2322
2323 #[test]
2324 fn governance_log_id_accepts_only_citation_shapes() {
2325 for (good, prefix) in [
2326 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
2327 ("APP-2026-0003", GovernanceLogPrefix::App),
2328 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
2329 ("KEY-2026-0001", GovernanceLogPrefix::Key),
2330 ("REC-2026-0001", GovernanceLogPrefix::Rec),
2331 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
2332 ] {
2333 let id = good.parse::<GovernanceLogId>().unwrap();
2334 assert_eq!(id.as_str(), good, "{good} should parse");
2335 assert_eq!(id.prefix(), prefix);
2336 assert_eq!(id.prefix().as_str(), &good[..3]);
2337 }
2338 assert_eq!(
2339 GovernanceLogPrefix::ALL
2340 .map(|p| p.to_string())
2341 .concat()
2342 .len(),
2343 3 * GovernanceLogPrefix::ALL.len()
2344 );
2345 assert_eq!(
2347 "GOV-2026-006".parse::<GovernanceLogId>().unwrap().as_str(),
2348 "GOV-2026-0006"
2349 );
2350 assert_eq!(
2351 "gov-2026-0006".parse::<GovernanceLogId>().unwrap().as_str(),
2352 "GOV-2026-0006"
2353 );
2354 for bad in [
2355 "",
2356 "GOV-26-0006",
2357 "MOD-2026-0006",
2358 "GOV-2026-0006-1",
2359 "GOV-202X-0006",
2360 "3f1a0000-0000-0000-0000-000000000000",
2361 ] {
2362 assert!(
2363 bad.parse::<GovernanceLogId>().is_err(),
2364 "{bad:?} should not parse as a GovernanceLogId"
2365 );
2366 }
2367 }
2368
2369 #[test]
2373 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
2374 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2375 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
2376 let back: GovernanceLogId =
2377 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
2378 assert_eq!(back, id);
2379 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
2381 }
2382
2383 #[test]
2386 fn content_ref_round_trips_as_a_bare_string() {
2387 let uuid = Uuid::new_v4();
2388 let content = ContentRef::from(ContentId::from(uuid));
2389 assert_eq!(
2390 serde_json::to_value(&content).unwrap(),
2391 serde_json::json!(uuid.to_string())
2392 );
2393 assert_eq!(
2394 serde_json::from_value::<ContentRef>(serde_json::json!(
2395 uuid.to_string()
2396 ))
2397 .unwrap(),
2398 content
2399 );
2400
2401 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
2402 assert_eq!(
2403 serde_json::to_value(&gov).unwrap(),
2404 serde_json::json!("APP-2026-0003")
2405 );
2406 assert_eq!(
2407 serde_json::from_value::<ContentRef>(serde_json::json!(
2408 "APP-2026-0003"
2409 ))
2410 .unwrap(),
2411 gov
2412 );
2413
2414 assert!(gov.is_governance());
2415 assert!(!content.is_governance());
2416 assert_eq!(gov.kind_str(), "governance");
2417 assert_eq!(content.kind_str(), "content");
2418 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
2419 assert!(content.as_governance().is_none());
2420
2421 assert!("not-an-id".parse::<ContentRef>().is_err());
2423 assert!(
2424 serde_json::from_value::<ContentRef>(serde_json::json!(
2425 "not-an-id"
2426 ))
2427 .is_err()
2428 );
2429 }
2430
2431 #[test]
2433 fn every_readable_id_narrows_to_a_content_ref() {
2434 let uuid = Uuid::new_v4();
2435 for (label, got) in [
2436 ("PostId", ContentRef::from(PostId::from(uuid))),
2437 ("CommentId", ContentRef::from(CommentId::from(uuid))),
2438 ("ContentId", ContentRef::from(ContentId::from(uuid))),
2439 ] {
2440 assert_eq!(
2441 got,
2442 ContentRef::Content(ContentId::from(uuid)),
2443 "{label} -> ContentRef lost the uuid"
2444 );
2445 }
2446 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2447 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
2448 }
2449
2450 #[test]
2453 fn string_shaped_ids_round_trip_through_display() {
2454 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
2455 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
2456
2457 let r = ContentRef::Governance(gov);
2458 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2459
2460 let r = ContentRef::Content(ContentId::new());
2461 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
2462 }
2463
2464 #[test]
2465 fn post_or_comment_post_variant() {
2466 let inner = PostId::new();
2467 let tagged = PostOrCommentId::Post(inner);
2468 assert!(tagged.is_post());
2469 assert!(!tagged.is_comment());
2470 assert_eq!(tagged.as_post(), Some(inner));
2471 assert_eq!(tagged.as_comment(), None);
2472 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2473 assert_eq!(tagged.kind_str(), "post");
2474 }
2475
2476 #[test]
2477 fn post_or_comment_comment_variant() {
2478 let inner = CommentId::new();
2479 let tagged = PostOrCommentId::Comment(inner);
2480 assert!(tagged.is_comment());
2481 assert!(!tagged.is_post());
2482 assert_eq!(tagged.as_comment(), Some(inner));
2483 assert_eq!(tagged.as_post(), None);
2484 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
2485 assert_eq!(tagged.kind_str(), "comment");
2486 }
2487
2488 #[test]
2489 fn post_or_comment_from_conversions() {
2490 let post = PostId::new();
2491 let comment = CommentId::new();
2492 let via_post: PostOrCommentId = post.into();
2493 let via_comment: PostOrCommentId = comment.into();
2494 assert_eq!(via_post, PostOrCommentId::Post(post));
2495 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
2496 }
2497
2498 #[test]
2499 fn post_or_comment_display_is_kind_colon_uuid() {
2500 let post = PostId::new();
2501 let tagged = PostOrCommentId::Post(post);
2502 let rendered = tagged.to_string();
2503 assert!(rendered.starts_with("post:"));
2504 assert!(rendered.contains(&post.to_string()));
2505 }
2506
2507 const FULL: &str = "7ad26ccd-922f-484a-a37c-51777344a98c";
2510
2511 #[test]
2514 fn a_bad_id_fails_in_plain_words() {
2515 let bad = "7ad26ccd-922f-484a-a37c-51777344a";
2516 let want = format!(
2517 "not a valid PostId: expected a UUID like \"{FULL}\", got \"{bad}\""
2518 );
2519 assert_eq!(bad.parse::<PostId>().unwrap_err().to_string(), want);
2520 let err = serde_json::from_str::<PostId>(&format!("\"{bad}\""))
2521 .unwrap_err()
2522 .to_string();
2523 assert!(err.starts_with(&want), "{err}");
2524 assert!(!err.contains("group"), "{err}");
2525 let err = serde_json::from_str::<AgentId>("5")
2526 .unwrap_err()
2527 .to_string();
2528 assert!(err.contains("expected AgentId as a UUID string"), "{err}");
2529 let long = "x".repeat(500);
2531 let msg = long.parse::<CommentId>().unwrap_err().to_string();
2532 assert!(msg.len() < 200 && msg.ends_with("…\""), "{msg}");
2533 }
2534
2535 #[test]
2537 fn ids_still_round_trip() {
2538 let id: PostId = FULL.parse().unwrap();
2539 let json = serde_json::to_string(&id).unwrap();
2540 assert_eq!(serde_json::from_str::<PostId>(&json).unwrap(), id);
2541 let map: std::collections::HashMap<PostId, i64> =
2542 [(id, 3)].into_iter().collect();
2543 let json = serde_json::to_string(&map).unwrap();
2544 let back: std::collections::HashMap<PostId, i64> =
2545 serde_json::from_str(&json).unwrap();
2546 assert_eq!(back, map);
2547 }
2548
2549 fn target(s: &str) -> Result<ContentTarget, ContentTargetError> {
2550 s.parse()
2551 }
2552
2553 #[test]
2554 fn full_and_short_targets_parse() {
2555 let full = target(FULL).unwrap();
2556 assert_eq!(full.full().unwrap().to_string(), FULL);
2557 assert_eq!(full.to_string(), FULL);
2558 let short = target(" 7AD26CCD ").unwrap();
2559 assert!(short.full().is_none());
2560 assert_eq!(short.to_string(), "7ad26ccd");
2561 for variant in [
2563 FULL.replace('-', ""),
2564 FULL.to_uppercase(),
2565 FULL.replace('-', "\u{2011}"),
2566 ] {
2567 assert_eq!(target(&variant).unwrap(), full, "{variant}");
2568 }
2569 }
2570
2571 #[test]
2572 fn a_truncated_uuid_is_an_error_not_its_prefix() {
2573 let err = target("7ad26ccd-922f-484a-a37c-51777344a").unwrap_err();
2574 let msg = err.in_field("reply_to").to_string();
2575 assert!(
2576 msg.starts_with(
2577 "`reply_to` must be a post or comment id: a full UUID or its first 8 hex digits"
2578 ),
2579 "{msg}"
2580 );
2581 for internal in [
2582 "group",
2583 "UUID parsing",
2584 "invalid length",
2585 "invalid character",
2586 ] {
2587 assert!(!msg.contains(internal), "{internal} leaked: {msg}");
2588 }
2589 assert!(target("7ad26cc").is_err());
2590 assert!(target("7ad26ccd9").is_err());
2591 }
2592
2593 #[test]
2594 fn trailing_text_names_the_id_it_starts_with() {
2595 let msg = target(&format!("{FULL} (the reply above)"))
2596 .unwrap_err()
2597 .to_string();
2598 assert!(msg.contains(&format!("starts with the id {FULL}")), "{msg}");
2599 }
2600
2601 #[derive(Debug, serde::Deserialize)]
2602 struct Probe {
2603 #[serde(deserialize_with = "content_target::reply_to")]
2604 #[allow(dead_code)]
2605 reply_to: ContentTarget,
2606 }
2607
2608 #[test]
2609 fn target_errors_name_the_field_for_strings_and_other_types() {
2610 let err = serde_json::from_str::<Probe>(r#"{"reply_to": "nope"}"#)
2611 .unwrap_err();
2612 assert!(
2613 err.to_string()
2614 .starts_with("`reply_to` must be a post or comment id"),
2615 "{err}"
2616 );
2617 let err =
2618 serde_json::from_str::<Probe>(r#"{"reply_to": 5}"#).unwrap_err();
2619 assert!(
2620 err.to_string()
2621 .contains("expected `reply_to` to be a post or comment id"),
2622 "{err}"
2623 );
2624 }
2625
2626 #[cfg(feature = "schemars")]
2627 #[test]
2628 fn target_schema_is_inline_and_admits_both_forms() {
2629 let schema = schemars::schema_for!(ContentTarget);
2630 let v = schema.as_value();
2631 assert!(v.get("$ref").is_none() && v.get("$defs").is_none(), "{v}");
2632 assert!(v.get("format").is_none(), "a short id is not format: uuid");
2633 assert_eq!(v["pattern"], CONTENT_TARGET_PATTERN);
2634 }
2635}