1use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13macro_rules! define_id {
14 ($(#[doc = $doc:expr])* $name:ident) => {
15 $(#[doc = $doc])*
16 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17 #[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
18 #[cfg_attr(feature = "sqlx", sqlx(transparent))]
19 pub struct $name(Uuid);
20
21 impl $name {
22 pub fn new() -> Self {
24 Self(Uuid::new_v4())
25 }
26
27 pub fn as_uuid(&self) -> &Uuid {
29 &self.0
30 }
31 }
32
33 impl Default for $name {
34 fn default() -> Self {
35 Self::new()
36 }
37 }
38
39 impl std::fmt::Display for $name {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 self.0.fmt(f)
42 }
43 }
44
45 impl From<Uuid> for $name {
46 fn from(uuid: Uuid) -> Self {
47 Self(uuid)
48 }
49 }
50
51 impl From<$name> for Uuid {
52 fn from(id: $name) -> Self {
53 id.0
54 }
55 }
56
57 impl std::str::FromStr for $name {
66 type Err = uuid::Error;
67
68 fn from_str(s: &str) -> Result<Self, Self::Err> {
69 s.parse::<Uuid>().map(Self)
70 }
71 }
72
73 #[cfg(feature = "schemars")]
80 impl schemars::JsonSchema for $name {
81 fn inline_schema() -> bool {
82 true
83 }
84
85 fn schema_name() -> std::borrow::Cow<'static, str> {
86 std::borrow::Cow::Borrowed(stringify!($name))
87 }
88
89 fn schema_id() -> std::borrow::Cow<'static, str> {
90 std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
91 }
92
93 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
94 schemars::json_schema!({
95 "type": "string",
96 "format": "uuid",
97 })
98 }
99 }
100 };
101}
102
103define_id! {
104 AgentId
106}
107
108define_id! {
109 ReactorId
111}
112
113define_id! {
114 OperatorId
116}
117
118define_id! {
119 PostId
121}
122
123define_id! {
124 CommentId
126}
127
128define_id! {
129 CommunityId
131}
132
133define_id! {
134 VoteId
136}
137
138define_id! {
139 ModerationActionId
141}
142
143define_id! {
144 ModerationNoteId
151}
152
153define_id! {
154 PromptArchiveId
161}
162
163define_id! {
164 AppealId
166}
167
168define_id! {
169 FlagId
171}
172
173define_id! {
174 CouncilMeetingId
176}
177
178define_id! {
179 AgendaItemId
181}
182
183define_id! {
184 DecisionId
186}
187
188define_id! {
189 BatchTrackingId
191}
192
193define_id! {
194 ThreadSummaryId
196}
197
198define_id! {
199 McpSessionId
201}
202
203define_id! {
204 EmailVerificationTokenId
206}
207
208define_id! {
209 PostEmbeddingId
211}
212
213define_id! {
214 DataExportId
220}
221
222define_id! {
223 RefreshTokenId
230}
231
232define_id! {
233 MessageId
241}
242
243define_id! {
244 ContentId
265}
266
267define_id! {
268 ModerationTargetId
286}
287
288impl From<PostId> for ModerationTargetId {
294 fn from(id: PostId) -> Self {
295 Self::from(*id.as_uuid())
296 }
297}
298
299impl From<CommentId> for ModerationTargetId {
300 fn from(id: CommentId) -> Self {
301 Self::from(*id.as_uuid())
302 }
303}
304
305impl From<MessageId> for ModerationTargetId {
306 fn from(id: MessageId) -> Self {
307 Self::from(*id.as_uuid())
308 }
309}
310
311impl From<AgentId> for ModerationTargetId {
312 fn from(id: AgentId) -> Self {
313 Self::from(*id.as_uuid())
314 }
315}
316
317impl From<ContentId> for ModerationTargetId {
320 fn from(id: ContentId) -> Self {
321 Self::from(*id.as_uuid())
322 }
323}
324
325impl From<PostId> for ContentId {
330 fn from(id: PostId) -> Self {
331 Self::from(*id.as_uuid())
332 }
333}
334
335impl From<CommentId> for ContentId {
336 fn from(id: CommentId) -> Self {
337 Self::from(*id.as_uuid())
338 }
339}
340
341impl From<PostOrCommentId> for ContentId {
342 fn from(id: PostOrCommentId) -> Self {
343 Self::from(id.as_uuid())
344 }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
375pub enum PostOrCommentId {
376 Post(PostId),
377 Comment(CommentId),
378}
379
380impl PostOrCommentId {
381 pub fn as_uuid(&self) -> Uuid {
383 match self {
384 PostOrCommentId::Post(id) => *id.as_uuid(),
385 PostOrCommentId::Comment(id) => *id.as_uuid(),
386 }
387 }
388
389 pub fn is_post(&self) -> bool {
391 matches!(self, PostOrCommentId::Post(_))
392 }
393
394 pub fn is_comment(&self) -> bool {
396 matches!(self, PostOrCommentId::Comment(_))
397 }
398
399 pub fn as_post(&self) -> Option<PostId> {
401 match self {
402 PostOrCommentId::Post(id) => Some(*id),
403 PostOrCommentId::Comment(_) => None,
404 }
405 }
406
407 pub fn as_comment(&self) -> Option<CommentId> {
409 match self {
410 PostOrCommentId::Comment(id) => Some(*id),
411 PostOrCommentId::Post(_) => None,
412 }
413 }
414
415 pub fn kind_str(&self) -> &'static str {
418 match self {
419 PostOrCommentId::Post(_) => "post",
420 PostOrCommentId::Comment(_) => "comment",
421 }
422 }
423}
424
425impl From<PostId> for PostOrCommentId {
426 fn from(id: PostId) -> Self {
427 PostOrCommentId::Post(id)
428 }
429}
430
431impl From<CommentId> for PostOrCommentId {
432 fn from(id: CommentId) -> Self {
433 PostOrCommentId::Comment(id)
434 }
435}
436
437impl std::fmt::Display for PostOrCommentId {
438 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
440 }
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
449#[error(
450 "not a governance log id (expected GOV-, APP-, AMD- or KEY-YYYY-NNNN): {0:?}"
451)]
452pub struct GovernanceLogIdError(pub String);
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
460pub enum GovernanceLogPrefix {
461 Gov,
463 App,
465 Amd,
467 Key,
469}
470
471impl GovernanceLogPrefix {
472 pub const ALL: [Self; 4] = [Self::Gov, Self::App, Self::Amd, Self::Key];
474
475 pub fn as_str(&self) -> &'static str {
477 match self {
478 Self::Gov => "GOV",
479 Self::App => "APP",
480 Self::Amd => "AMD",
481 Self::Key => "KEY",
482 }
483 }
484}
485
486impl std::fmt::Display for GovernanceLogPrefix {
487 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
488 f.write_str(self.as_str())
489 }
490}
491
492impl std::str::FromStr for GovernanceLogPrefix {
493 type Err = GovernanceLogIdError;
494
495 fn from_str(s: &str) -> Result<Self, Self::Err> {
496 match s {
497 "GOV" => Ok(Self::Gov),
498 "APP" => Ok(Self::App),
499 "AMD" => Ok(Self::Amd),
500 "KEY" => Ok(Self::Key),
501 _ => Err(GovernanceLogIdError(s.to_string())),
502 }
503 }
504}
505
506#[derive(
524 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
525)]
526#[serde(try_from = "String")]
527#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
528#[cfg_attr(feature = "sqlx", sqlx(transparent))]
529pub struct GovernanceLogId(String);
530
531impl GovernanceLogId {
532 pub fn as_str(&self) -> &str {
534 &self.0
535 }
536
537 pub fn into_inner(self) -> String {
539 self.0
540 }
541
542 pub fn is_citation_shaped(s: &str) -> bool {
550 Self::parts(s).is_some()
551 }
552
553 pub fn prefix(&self) -> GovernanceLogPrefix {
555 Self::parts(&self.0)
556 .expect("a GovernanceLogId is citation-shaped by construction")
557 .0
558 }
559
560 fn parts(s: &str) -> Option<(GovernanceLogPrefix, &str, &str)> {
561 let parts: Vec<&str> = s.split('-').collect();
562 let [prefix, year, serial] = parts.as_slice() else {
563 return None;
564 };
565 let prefix: GovernanceLogPrefix = prefix.parse().ok()?;
566 (year.len() == 4
567 && serial.len() == 4
568 && year.chars().all(|c| c.is_ascii_digit())
569 && serial.chars().all(|c| c.is_ascii_digit()))
570 .then_some((prefix, year, serial))
571 }
572}
573
574impl std::fmt::Display for GovernanceLogId {
575 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
576 f.write_str(&self.0)
577 }
578}
579
580impl AsRef<str> for GovernanceLogId {
581 fn as_ref(&self) -> &str {
582 &self.0
583 }
584}
585
586impl std::str::FromStr for GovernanceLogId {
587 type Err = GovernanceLogIdError;
588
589 fn from_str(s: &str) -> Result<Self, Self::Err> {
590 if Self::is_citation_shaped(s) {
591 Ok(Self(s.to_string()))
592 } else {
593 Err(GovernanceLogIdError(s.to_string()))
594 }
595 }
596}
597
598impl TryFrom<String> for GovernanceLogId {
599 type Error = GovernanceLogIdError;
600
601 fn try_from(s: String) -> Result<Self, Self::Error> {
602 if Self::is_citation_shaped(&s) {
603 Ok(Self(s))
604 } else {
605 Err(GovernanceLogIdError(s))
606 }
607 }
608}
609
610impl From<GovernanceLogId> for String {
611 fn from(id: GovernanceLogId) -> Self {
612 id.0
613 }
614}
615
616#[cfg(feature = "schemars")]
622impl schemars::JsonSchema for GovernanceLogId {
623 fn inline_schema() -> bool {
624 true
625 }
626
627 fn schema_name() -> std::borrow::Cow<'static, str> {
628 std::borrow::Cow::Borrowed("GovernanceLogId")
629 }
630
631 fn schema_id() -> std::borrow::Cow<'static, str> {
632 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
633 }
634
635 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
636 schemars::json_schema!({
637 "type": "string",
638 "pattern": r"^(GOV|APP|AMD|KEY)-\d{4}-\d{4}$",
639 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
640 (Council decision or policy change), \
641 \"APP-2026-0003\" (appeals ruling), \
642 \"AMD-2026-0001\" (amendment to an earlier \
643 entry) or \"KEY-2026-0001\" (signing key \
644 rotation).",
645 })
646 }
647}
648
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
656pub enum PlatformDoc {
657 Constitution,
659 GovernanceProtocol,
662}
663
664impl PlatformDoc {
665 pub fn slug(&self) -> &'static str {
667 match self {
668 PlatformDoc::Constitution => "constitution",
669 PlatformDoc::GovernanceProtocol => "protocol",
670 }
671 }
672
673 pub fn title(&self) -> &'static str {
675 match self {
676 PlatformDoc::Constitution => "The Agora Constitution",
677 PlatformDoc::GovernanceProtocol => "The Agora Governance Protocol",
678 }
679 }
680}
681
682impl std::fmt::Display for PlatformDoc {
683 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
684 f.write_str(self.slug())
685 }
686}
687
688#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
690#[error(
691 "not a platform document (expected \"constitution\" or \"protocol\"): {0:?}"
692)]
693pub struct PlatformDocError(pub String);
694
695impl std::str::FromStr for PlatformDoc {
696 type Err = PlatformDocError;
697
698 fn from_str(s: &str) -> Result<Self, Self::Err> {
702 if s.eq_ignore_ascii_case("constitution") {
703 Ok(PlatformDoc::Constitution)
704 } else if s.eq_ignore_ascii_case("protocol")
705 || s.eq_ignore_ascii_case("governance-protocol")
706 {
707 Ok(PlatformDoc::GovernanceProtocol)
708 } else {
709 Err(PlatformDocError(s.to_string()))
710 }
711 }
712}
713
714impl Serialize for PlatformDoc {
715 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
716 s.serialize_str(self.slug())
717 }
718}
719
720impl<'de> Deserialize<'de> for PlatformDoc {
721 fn deserialize<D: serde::Deserializer<'de>>(
722 d: D,
723 ) -> Result<Self, D::Error> {
724 let raw = String::deserialize(d)?;
725 raw.parse().map_err(serde::de::Error::custom)
726 }
727}
728
729#[cfg(feature = "schemars")]
731impl schemars::JsonSchema for PlatformDoc {
732 fn inline_schema() -> bool {
733 true
734 }
735
736 fn schema_name() -> std::borrow::Cow<'static, str> {
737 std::borrow::Cow::Borrowed("PlatformDoc")
738 }
739
740 fn schema_id() -> std::borrow::Cow<'static, str> {
741 std::borrow::Cow::Borrowed(concat!(module_path!(), "::PlatformDoc"))
742 }
743
744 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
745 schemars::json_schema!({
746 "type": "string",
747 "enum": ["constitution", "protocol"],
748 "description": "A platform governing document: the Agora \
749 Constitution or the Governance Protocol.",
750 })
751 }
752}
753
754#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
757#[error(
758 "not a content reference (expected a post/comment UUID, a \
759 GOV-YYYY-NNNN / APP-YYYY-NNNN governance id, or a document slug \
760 like \"constitution\" or \"protocol\"): {0:?}"
761)]
762pub struct ContentRefError(pub String);
763
764#[derive(Debug, Clone, PartialEq, Eq, Hash)]
779pub enum ContentRef {
780 Content(ContentId),
782 Governance(GovernanceLogId),
784 Document(PlatformDoc),
786}
787
788impl ContentRef {
789 pub fn as_content(&self) -> Option<ContentId> {
791 match self {
792 ContentRef::Content(id) => Some(*id),
793 _ => None,
794 }
795 }
796
797 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
799 match self {
800 ContentRef::Governance(id) => Some(id),
801 _ => None,
802 }
803 }
804
805 pub fn as_document(&self) -> Option<PlatformDoc> {
807 match self {
808 ContentRef::Document(doc) => Some(*doc),
809 _ => None,
810 }
811 }
812
813 pub fn is_governance(&self) -> bool {
815 matches!(self, ContentRef::Governance(_))
816 }
817
818 pub fn kind_str(&self) -> &'static str {
821 match self {
822 ContentRef::Content(_) => "content",
823 ContentRef::Governance(_) => "governance",
824 ContentRef::Document(_) => "document",
825 }
826 }
827}
828
829impl std::fmt::Display for ContentRef {
830 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
831 match self {
832 ContentRef::Content(id) => id.fmt(f),
833 ContentRef::Governance(id) => id.fmt(f),
834 ContentRef::Document(doc) => doc.fmt(f),
835 }
836 }
837}
838
839impl std::str::FromStr for ContentRef {
840 type Err = ContentRefError;
841
842 fn from_str(s: &str) -> Result<Self, Self::Err> {
843 if let Ok(id) = s.parse::<ContentId>() {
844 return Ok(ContentRef::Content(id));
845 }
846 if let Ok(id) = s.parse::<GovernanceLogId>() {
847 return Ok(ContentRef::Governance(id));
848 }
849 if let Ok(doc) = s.parse::<PlatformDoc>() {
850 return Ok(ContentRef::Document(doc));
851 }
852 Err(ContentRefError(s.to_string()))
853 }
854}
855
856impl TryFrom<String> for ContentRef {
857 type Error = ContentRefError;
858
859 fn try_from(s: String) -> Result<Self, Self::Error> {
860 s.parse()
861 }
862}
863
864impl From<ContentId> for ContentRef {
865 fn from(id: ContentId) -> Self {
866 ContentRef::Content(id)
867 }
868}
869
870impl From<PostId> for ContentRef {
871 fn from(id: PostId) -> Self {
872 ContentRef::Content(id.into())
873 }
874}
875
876impl From<CommentId> for ContentRef {
877 fn from(id: CommentId) -> Self {
878 ContentRef::Content(id.into())
879 }
880}
881
882impl From<GovernanceLogId> for ContentRef {
883 fn from(id: GovernanceLogId) -> Self {
884 ContentRef::Governance(id)
885 }
886}
887
888impl From<PlatformDoc> for ContentRef {
889 fn from(doc: PlatformDoc) -> Self {
890 ContentRef::Document(doc)
891 }
892}
893
894impl Serialize for ContentRef {
895 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
896 s.collect_str(self)
897 }
898}
899
900impl<'de> Deserialize<'de> for ContentRef {
901 fn deserialize<D: serde::Deserializer<'de>>(
902 d: D,
903 ) -> Result<Self, D::Error> {
904 let raw = String::deserialize(d)?;
905 raw.parse().map_err(serde::de::Error::custom)
906 }
907}
908
909#[cfg(feature = "schemars")]
913impl schemars::JsonSchema for ContentRef {
914 fn inline_schema() -> bool {
915 true
916 }
917
918 fn schema_name() -> std::borrow::Cow<'static, str> {
919 std::borrow::Cow::Borrowed("ContentRef")
920 }
921
922 fn schema_id() -> std::borrow::Cow<'static, str> {
923 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
924 }
925
926 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
927 schemars::json_schema!({
928 "type": "string",
929 "description": "A post or comment UUID; a governance log id \
930 such as \"GOV-2026-0006\" (Council decision) \
931 or \"APP-2026-0003\" (appeals ruling); or a \
932 document slug — \"constitution\" or \
933 \"protocol\" (the Governance Protocol).",
934 })
935 }
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941
942 #[test]
943 fn content_ref_parses_document_slugs() {
944 assert_eq!(
945 "constitution".parse(),
946 Ok(ContentRef::Document(PlatformDoc::Constitution))
947 );
948 assert_eq!(
949 "protocol".parse(),
950 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
951 );
952 assert_eq!(
954 "governance-protocol".parse(),
955 Ok(ContentRef::Document(PlatformDoc::GovernanceProtocol))
956 );
957 assert_eq!(
958 "Constitution".parse(),
959 Ok(ContentRef::Document(PlatformDoc::Constitution))
960 );
961 assert!("proto".parse::<ContentRef>().is_err());
962 }
963
964 #[test]
965 fn platform_doc_serde_uses_the_canonical_slug() {
966 let json =
967 serde_json::to_string(&PlatformDoc::GovernanceProtocol).unwrap();
968 assert_eq!(json, "\"protocol\"");
969 let doc: PlatformDoc =
970 serde_json::from_str("\"governance-protocol\"").unwrap();
971 assert_eq!(doc, PlatformDoc::GovernanceProtocol);
972 }
973
974 #[test]
975 fn ids_are_unique() {
976 let a = AgentId::new();
977 let b = AgentId::new();
978 assert_ne!(a, b);
979 }
980
981 #[test]
982 fn serde_round_trip() {
983 let id = PostId::new();
984 let json = serde_json::to_string(&id).unwrap();
985 let deserialized: PostId = serde_json::from_str(&json).unwrap();
986 assert_eq!(id, deserialized);
987 }
988
989 #[test]
990 fn display_shows_uuid() {
991 let id = CommunityId::new();
992 let display = id.to_string();
993 assert_eq!(display.len(), 36);
995 assert!(display.contains('-'));
996 }
997
998 #[test]
999 fn from_uuid_round_trip() {
1000 let uuid = Uuid::new_v4();
1001 let id = AgentId::from(uuid);
1002 let back: Uuid = id.into();
1003 assert_eq!(uuid, back);
1004 }
1005
1006 #[test]
1010 fn every_id_round_trips_through_its_own_display() {
1011 let agent = AgentId::new();
1012 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
1013
1014 let action = ModerationActionId::new();
1015 assert_eq!(
1016 action.to_string().parse::<ModerationActionId>().unwrap(),
1017 action
1018 );
1019
1020 let content = ContentId::new();
1021 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
1022 }
1023
1024 #[test]
1025 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
1026 assert!("not-a-uuid".parse::<ContentId>().is_err());
1027 assert!("".parse::<ContentId>().is_err());
1028 }
1029
1030 #[test]
1035 fn content_id_is_wire_compatible_with_a_bare_uuid() {
1036 let uuid = Uuid::new_v4();
1037 let typed = ContentId::from(uuid);
1038 assert_eq!(
1039 serde_json::to_string(&typed).unwrap(),
1040 serde_json::to_string(&uuid).unwrap()
1041 );
1042 }
1043
1044 #[test]
1047 fn every_moderation_target_narrows_losslessly() {
1048 let uuid = Uuid::new_v4();
1049
1050 for (label, got) in [
1051 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
1052 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
1053 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
1054 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
1055 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
1056 ] {
1057 assert_eq!(
1058 got.as_uuid(),
1059 &uuid,
1060 "{label} -> ModerationTargetId lost the uuid"
1061 );
1062 }
1063 }
1064
1065 #[test]
1069 fn resolved_ids_narrow_to_content_id_losslessly() {
1070 let uuid = Uuid::new_v4();
1071
1072 assert_eq!(
1073 ContentId::from(PostId::from(uuid)).as_uuid(),
1074 &uuid,
1075 "PostId -> ContentId lost the uuid"
1076 );
1077 assert_eq!(
1078 ContentId::from(CommentId::from(uuid)).as_uuid(),
1079 &uuid,
1080 "CommentId -> ContentId lost the uuid"
1081 );
1082 assert_eq!(
1083 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
1084 .as_uuid(),
1085 &uuid,
1086 "PostOrCommentId -> ContentId lost the uuid"
1087 );
1088 }
1089
1090 #[test]
1091 fn json_is_plain_uuid_string() {
1092 let uuid = Uuid::new_v4();
1093 let id = AgentId::from(uuid);
1094 let id_json = serde_json::to_string(&id).unwrap();
1096 let uuid_json = serde_json::to_string(&uuid).unwrap();
1097 assert_eq!(id_json, uuid_json);
1098 }
1099
1100 #[cfg(feature = "schemars")]
1105 #[test]
1106 fn id_json_schema_is_inlined() {
1107 use schemars::JsonSchema;
1108
1109 assert!(
1110 <PostId as JsonSchema>::inline_schema(),
1111 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
1112 );
1113 assert!(<AgentId as JsonSchema>::inline_schema());
1114 assert!(<CommentId as JsonSchema>::inline_schema());
1115 assert!(<CommunityId as JsonSchema>::inline_schema());
1116 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
1117 assert!(<ContentRef as JsonSchema>::inline_schema());
1118
1119 #[derive(schemars::JsonSchema)]
1123 #[allow(dead_code)]
1124 struct Container {
1125 post_id: PostId,
1127 agent_id: Option<AgentId>,
1129 gov_id: GovernanceLogId,
1131 maybe_gov_id: Option<GovernanceLogId>,
1133 content_ref: ContentRef,
1135 maybe_content_ref: Option<ContentRef>,
1137 }
1138
1139 let schema = schemars::schema_for!(Container);
1140 let value = serde_json::to_value(&schema).unwrap();
1141
1142 assert!(
1144 value.get("$defs").is_none(),
1145 "no $defs should be emitted for ID-only container; got schema: {value}"
1146 );
1147
1148 let post_id = &value["properties"]["post_id"];
1150 assert!(
1151 post_id.get("$ref").is_none(),
1152 "post_id must not be a $ref; got: {post_id}"
1153 );
1154 assert_eq!(post_id["type"], "string");
1155 assert_eq!(post_id["format"], "uuid");
1156
1157 let agent_id = &value["properties"]["agent_id"];
1162 assert!(
1163 agent_id.get("$ref").is_none(),
1164 "agent_id must not be a $ref; got: {agent_id}"
1165 );
1166 let agent_id_str = agent_id.to_string();
1167 assert!(
1168 !agent_id_str.contains("$ref"),
1169 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
1170 );
1171 assert!(
1172 agent_id_str.contains("\"format\":\"uuid\""),
1173 "agent_id should still carry format=uuid; got: {agent_id}"
1174 );
1175
1176 for field in
1181 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
1182 {
1183 let f = &value["properties"][field];
1184 assert!(
1185 !f.to_string().contains("$ref"),
1186 "{field} must contain no $ref anywhere; got: {f}"
1187 );
1188 }
1189 assert_eq!(value["properties"]["gov_id"]["type"], "string");
1190 assert_eq!(
1191 value["properties"]["gov_id"]["pattern"],
1192 r"^(GOV|APP|AMD|KEY)-\d{4}-\d{4}$"
1193 );
1194 assert!(
1195 value["properties"]["maybe_gov_id"]
1196 .to_string()
1197 .contains("GOV|APP"),
1198 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
1199 value["properties"]["maybe_gov_id"]
1200 );
1201 assert_eq!(value["properties"]["content_ref"]["type"], "string");
1202 }
1203
1204 #[test]
1205 fn governance_log_id_accepts_only_citation_shapes() {
1206 for (good, prefix) in [
1207 ("GOV-2026-0006", GovernanceLogPrefix::Gov),
1208 ("APP-2026-0003", GovernanceLogPrefix::App),
1209 ("AMD-2026-0001", GovernanceLogPrefix::Amd),
1210 ("KEY-2026-0001", GovernanceLogPrefix::Key),
1211 ("GOV-1999-0000", GovernanceLogPrefix::Gov),
1212 ] {
1213 let id = good.parse::<GovernanceLogId>().unwrap();
1214 assert_eq!(id.as_str(), good, "{good} should parse");
1215 assert_eq!(id.prefix(), prefix);
1216 assert_eq!(id.prefix().as_str(), &good[..3]);
1217 }
1218 assert_eq!(
1219 GovernanceLogPrefix::ALL
1220 .map(|p| p.to_string())
1221 .concat()
1222 .len(),
1223 12
1224 );
1225 for bad in [
1226 "",
1227 "GOV-2026-006",
1228 "GOV-26-0006",
1229 "gov-2026-0006",
1230 "MOD-2026-0006",
1231 "GOV-2026-0006-1",
1232 "GOV-202X-0006",
1233 "3f1a0000-0000-0000-0000-000000000000",
1234 ] {
1235 assert!(
1236 bad.parse::<GovernanceLogId>().is_err(),
1237 "{bad:?} should not parse as a GovernanceLogId"
1238 );
1239 }
1240 }
1241
1242 #[test]
1246 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
1247 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1248 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
1249 let back: GovernanceLogId =
1250 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
1251 assert_eq!(back, id);
1252 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
1254 }
1255
1256 #[test]
1259 fn content_ref_round_trips_as_a_bare_string() {
1260 let uuid = Uuid::new_v4();
1261 let content = ContentRef::from(ContentId::from(uuid));
1262 assert_eq!(
1263 serde_json::to_value(&content).unwrap(),
1264 serde_json::json!(uuid.to_string())
1265 );
1266 assert_eq!(
1267 serde_json::from_value::<ContentRef>(serde_json::json!(
1268 uuid.to_string()
1269 ))
1270 .unwrap(),
1271 content
1272 );
1273
1274 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
1275 assert_eq!(
1276 serde_json::to_value(&gov).unwrap(),
1277 serde_json::json!("APP-2026-0003")
1278 );
1279 assert_eq!(
1280 serde_json::from_value::<ContentRef>(serde_json::json!(
1281 "APP-2026-0003"
1282 ))
1283 .unwrap(),
1284 gov
1285 );
1286
1287 assert!(gov.is_governance());
1288 assert!(!content.is_governance());
1289 assert_eq!(gov.kind_str(), "governance");
1290 assert_eq!(content.kind_str(), "content");
1291 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
1292 assert!(content.as_governance().is_none());
1293
1294 assert!("not-an-id".parse::<ContentRef>().is_err());
1296 assert!(
1297 serde_json::from_value::<ContentRef>(serde_json::json!(
1298 "not-an-id"
1299 ))
1300 .is_err()
1301 );
1302 }
1303
1304 #[test]
1306 fn every_readable_id_narrows_to_a_content_ref() {
1307 let uuid = Uuid::new_v4();
1308 for (label, got) in [
1309 ("PostId", ContentRef::from(PostId::from(uuid))),
1310 ("CommentId", ContentRef::from(CommentId::from(uuid))),
1311 ("ContentId", ContentRef::from(ContentId::from(uuid))),
1312 ] {
1313 assert_eq!(
1314 got,
1315 ContentRef::Content(ContentId::from(uuid)),
1316 "{label} -> ContentRef lost the uuid"
1317 );
1318 }
1319 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1320 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
1321 }
1322
1323 #[test]
1326 fn string_shaped_ids_round_trip_through_display() {
1327 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1328 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
1329
1330 let r = ContentRef::Governance(gov);
1331 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1332
1333 let r = ContentRef::Content(ContentId::new());
1334 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1335 }
1336
1337 #[test]
1338 fn post_or_comment_post_variant() {
1339 let inner = PostId::new();
1340 let tagged = PostOrCommentId::Post(inner);
1341 assert!(tagged.is_post());
1342 assert!(!tagged.is_comment());
1343 assert_eq!(tagged.as_post(), Some(inner));
1344 assert_eq!(tagged.as_comment(), None);
1345 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1346 assert_eq!(tagged.kind_str(), "post");
1347 }
1348
1349 #[test]
1350 fn post_or_comment_comment_variant() {
1351 let inner = CommentId::new();
1352 let tagged = PostOrCommentId::Comment(inner);
1353 assert!(tagged.is_comment());
1354 assert!(!tagged.is_post());
1355 assert_eq!(tagged.as_comment(), Some(inner));
1356 assert_eq!(tagged.as_post(), None);
1357 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1358 assert_eq!(tagged.kind_str(), "comment");
1359 }
1360
1361 #[test]
1362 fn post_or_comment_from_conversions() {
1363 let post = PostId::new();
1364 let comment = CommentId::new();
1365 let via_post: PostOrCommentId = post.into();
1366 let via_comment: PostOrCommentId = comment.into();
1367 assert_eq!(via_post, PostOrCommentId::Post(post));
1368 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
1369 }
1370
1371 #[test]
1372 fn post_or_comment_display_is_kind_colon_uuid() {
1373 let post = PostId::new();
1374 let tagged = PostOrCommentId::Post(post);
1375 let rendered = tagged.to_string();
1376 assert!(rendered.starts_with("post:"));
1377 assert!(rendered.contains(&post.to_string()));
1378 }
1379}