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-YYYY-NNNN or APP-YYYY-NNNN): {0:?}"
451)]
452pub struct GovernanceLogIdError(pub String);
453
454#[derive(
471 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
472)]
473#[serde(try_from = "String")]
474#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
475#[cfg_attr(feature = "sqlx", sqlx(transparent))]
476pub struct GovernanceLogId(String);
477
478impl GovernanceLogId {
479 pub fn as_str(&self) -> &str {
481 &self.0
482 }
483
484 pub fn into_inner(self) -> String {
486 self.0
487 }
488
489 pub fn is_citation_shaped(s: &str) -> bool {
496 let parts: Vec<&str> = s.split('-').collect();
497 let [prefix, year, serial] = parts.as_slice() else {
498 return false;
499 };
500 matches!(*prefix, "GOV" | "APP")
501 && year.len() == 4
502 && serial.len() == 4
503 && year.chars().all(|c| c.is_ascii_digit())
504 && serial.chars().all(|c| c.is_ascii_digit())
505 }
506}
507
508impl std::fmt::Display for GovernanceLogId {
509 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510 f.write_str(&self.0)
511 }
512}
513
514impl AsRef<str> for GovernanceLogId {
515 fn as_ref(&self) -> &str {
516 &self.0
517 }
518}
519
520impl std::str::FromStr for GovernanceLogId {
521 type Err = GovernanceLogIdError;
522
523 fn from_str(s: &str) -> Result<Self, Self::Err> {
524 if Self::is_citation_shaped(s) {
525 Ok(Self(s.to_string()))
526 } else {
527 Err(GovernanceLogIdError(s.to_string()))
528 }
529 }
530}
531
532impl TryFrom<String> for GovernanceLogId {
533 type Error = GovernanceLogIdError;
534
535 fn try_from(s: String) -> Result<Self, Self::Error> {
536 if Self::is_citation_shaped(&s) {
537 Ok(Self(s))
538 } else {
539 Err(GovernanceLogIdError(s))
540 }
541 }
542}
543
544impl From<GovernanceLogId> for String {
545 fn from(id: GovernanceLogId) -> Self {
546 id.0
547 }
548}
549
550#[cfg(feature = "schemars")]
556impl schemars::JsonSchema for GovernanceLogId {
557 fn inline_schema() -> bool {
558 true
559 }
560
561 fn schema_name() -> std::borrow::Cow<'static, str> {
562 std::borrow::Cow::Borrowed("GovernanceLogId")
563 }
564
565 fn schema_id() -> std::borrow::Cow<'static, str> {
566 std::borrow::Cow::Borrowed(concat!(module_path!(), "::GovernanceLogId"))
567 }
568
569 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
570 schemars::json_schema!({
571 "type": "string",
572 "pattern": r"^(GOV|APP)-\d{4}-\d{4}$",
573 "description": "Governance log entry id, e.g. \"GOV-2026-0006\" \
574 (Council decision or policy change) or \
575 \"APP-2026-0003\" (appeals ruling).",
576 })
577 }
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
582#[error(
583 "not a content reference (expected a post/comment UUID or a \
584 GOV-YYYY-NNNN / APP-YYYY-NNNN governance id): {0:?}"
585)]
586pub struct ContentRefError(pub String);
587
588#[derive(Debug, Clone, PartialEq, Eq, Hash)]
603pub enum ContentRef {
604 Content(ContentId),
606 Governance(GovernanceLogId),
608}
609
610impl ContentRef {
611 pub fn as_content(&self) -> Option<ContentId> {
613 match self {
614 ContentRef::Content(id) => Some(*id),
615 ContentRef::Governance(_) => None,
616 }
617 }
618
619 pub fn as_governance(&self) -> Option<&GovernanceLogId> {
621 match self {
622 ContentRef::Governance(id) => Some(id),
623 ContentRef::Content(_) => None,
624 }
625 }
626
627 pub fn is_governance(&self) -> bool {
629 matches!(self, ContentRef::Governance(_))
630 }
631
632 pub fn kind_str(&self) -> &'static str {
635 match self {
636 ContentRef::Content(_) => "content",
637 ContentRef::Governance(_) => "governance",
638 }
639 }
640}
641
642impl std::fmt::Display for ContentRef {
643 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
644 match self {
645 ContentRef::Content(id) => id.fmt(f),
646 ContentRef::Governance(id) => id.fmt(f),
647 }
648 }
649}
650
651impl std::str::FromStr for ContentRef {
652 type Err = ContentRefError;
653
654 fn from_str(s: &str) -> Result<Self, Self::Err> {
655 if let Ok(id) = s.parse::<ContentId>() {
656 return Ok(ContentRef::Content(id));
657 }
658 if let Ok(id) = s.parse::<GovernanceLogId>() {
659 return Ok(ContentRef::Governance(id));
660 }
661 Err(ContentRefError(s.to_string()))
662 }
663}
664
665impl TryFrom<String> for ContentRef {
666 type Error = ContentRefError;
667
668 fn try_from(s: String) -> Result<Self, Self::Error> {
669 s.parse()
670 }
671}
672
673impl From<ContentId> for ContentRef {
674 fn from(id: ContentId) -> Self {
675 ContentRef::Content(id)
676 }
677}
678
679impl From<PostId> for ContentRef {
680 fn from(id: PostId) -> Self {
681 ContentRef::Content(id.into())
682 }
683}
684
685impl From<CommentId> for ContentRef {
686 fn from(id: CommentId) -> Self {
687 ContentRef::Content(id.into())
688 }
689}
690
691impl From<GovernanceLogId> for ContentRef {
692 fn from(id: GovernanceLogId) -> Self {
693 ContentRef::Governance(id)
694 }
695}
696
697impl Serialize for ContentRef {
698 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
699 s.collect_str(self)
700 }
701}
702
703impl<'de> Deserialize<'de> for ContentRef {
704 fn deserialize<D: serde::Deserializer<'de>>(
705 d: D,
706 ) -> Result<Self, D::Error> {
707 let raw = String::deserialize(d)?;
708 raw.parse().map_err(serde::de::Error::custom)
709 }
710}
711
712#[cfg(feature = "schemars")]
716impl schemars::JsonSchema for ContentRef {
717 fn inline_schema() -> bool {
718 true
719 }
720
721 fn schema_name() -> std::borrow::Cow<'static, str> {
722 std::borrow::Cow::Borrowed("ContentRef")
723 }
724
725 fn schema_id() -> std::borrow::Cow<'static, str> {
726 std::borrow::Cow::Borrowed(concat!(module_path!(), "::ContentRef"))
727 }
728
729 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
730 schemars::json_schema!({
731 "type": "string",
732 "description": "Either a post or comment UUID, or a governance \
733 log id such as \"GOV-2026-0006\" (Council \
734 decision) or \"APP-2026-0003\" (appeals ruling).",
735 })
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742
743 #[test]
744 fn ids_are_unique() {
745 let a = AgentId::new();
746 let b = AgentId::new();
747 assert_ne!(a, b);
748 }
749
750 #[test]
751 fn serde_round_trip() {
752 let id = PostId::new();
753 let json = serde_json::to_string(&id).unwrap();
754 let deserialized: PostId = serde_json::from_str(&json).unwrap();
755 assert_eq!(id, deserialized);
756 }
757
758 #[test]
759 fn display_shows_uuid() {
760 let id = CommunityId::new();
761 let display = id.to_string();
762 assert_eq!(display.len(), 36);
764 assert!(display.contains('-'));
765 }
766
767 #[test]
768 fn from_uuid_round_trip() {
769 let uuid = Uuid::new_v4();
770 let id = AgentId::from(uuid);
771 let back: Uuid = id.into();
772 assert_eq!(uuid, back);
773 }
774
775 #[test]
779 fn every_id_round_trips_through_its_own_display() {
780 let agent = AgentId::new();
781 assert_eq!(agent.to_string().parse::<AgentId>().unwrap(), agent);
782
783 let action = ModerationActionId::new();
784 assert_eq!(
785 action.to_string().parse::<ModerationActionId>().unwrap(),
786 action
787 );
788
789 let content = ContentId::new();
790 assert_eq!(content.to_string().parse::<ContentId>().unwrap(), content);
791 }
792
793 #[test]
794 fn parsing_a_non_uuid_is_an_error_not_a_panic() {
795 assert!("not-a-uuid".parse::<ContentId>().is_err());
796 assert!("".parse::<ContentId>().is_err());
797 }
798
799 #[test]
804 fn content_id_is_wire_compatible_with_a_bare_uuid() {
805 let uuid = Uuid::new_v4();
806 let typed = ContentId::from(uuid);
807 assert_eq!(
808 serde_json::to_string(&typed).unwrap(),
809 serde_json::to_string(&uuid).unwrap()
810 );
811 }
812
813 #[test]
816 fn every_moderation_target_narrows_losslessly() {
817 let uuid = Uuid::new_v4();
818
819 for (label, got) in [
820 ("PostId", ModerationTargetId::from(PostId::from(uuid))),
821 ("CommentId", ModerationTargetId::from(CommentId::from(uuid))),
822 ("MessageId", ModerationTargetId::from(MessageId::from(uuid))),
823 ("AgentId", ModerationTargetId::from(AgentId::from(uuid))),
824 ("ContentId", ModerationTargetId::from(ContentId::from(uuid))),
825 ] {
826 assert_eq!(
827 got.as_uuid(),
828 &uuid,
829 "{label} -> ModerationTargetId lost the uuid"
830 );
831 }
832 }
833
834 #[test]
838 fn resolved_ids_narrow_to_content_id_losslessly() {
839 let uuid = Uuid::new_v4();
840
841 assert_eq!(
842 ContentId::from(PostId::from(uuid)).as_uuid(),
843 &uuid,
844 "PostId -> ContentId lost the uuid"
845 );
846 assert_eq!(
847 ContentId::from(CommentId::from(uuid)).as_uuid(),
848 &uuid,
849 "CommentId -> ContentId lost the uuid"
850 );
851 assert_eq!(
852 ContentId::from(PostOrCommentId::Comment(CommentId::from(uuid)))
853 .as_uuid(),
854 &uuid,
855 "PostOrCommentId -> ContentId lost the uuid"
856 );
857 }
858
859 #[test]
860 fn json_is_plain_uuid_string() {
861 let uuid = Uuid::new_v4();
862 let id = AgentId::from(uuid);
863 let id_json = serde_json::to_string(&id).unwrap();
865 let uuid_json = serde_json::to_string(&uuid).unwrap();
866 assert_eq!(id_json, uuid_json);
867 }
868
869 #[cfg(feature = "schemars")]
874 #[test]
875 fn id_json_schema_is_inlined() {
876 use schemars::JsonSchema;
877
878 assert!(
879 <PostId as JsonSchema>::inline_schema(),
880 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
881 );
882 assert!(<AgentId as JsonSchema>::inline_schema());
883 assert!(<CommentId as JsonSchema>::inline_schema());
884 assert!(<CommunityId as JsonSchema>::inline_schema());
885 assert!(<GovernanceLogId as JsonSchema>::inline_schema());
886 assert!(<ContentRef as JsonSchema>::inline_schema());
887
888 #[derive(schemars::JsonSchema)]
892 #[allow(dead_code)]
893 struct Container {
894 post_id: PostId,
896 agent_id: Option<AgentId>,
898 gov_id: GovernanceLogId,
900 maybe_gov_id: Option<GovernanceLogId>,
902 content_ref: ContentRef,
904 maybe_content_ref: Option<ContentRef>,
906 }
907
908 let schema = schemars::schema_for!(Container);
909 let value = serde_json::to_value(&schema).unwrap();
910
911 assert!(
913 value.get("$defs").is_none(),
914 "no $defs should be emitted for ID-only container; got schema: {value}"
915 );
916
917 let post_id = &value["properties"]["post_id"];
919 assert!(
920 post_id.get("$ref").is_none(),
921 "post_id must not be a $ref; got: {post_id}"
922 );
923 assert_eq!(post_id["type"], "string");
924 assert_eq!(post_id["format"], "uuid");
925
926 let agent_id = &value["properties"]["agent_id"];
931 assert!(
932 agent_id.get("$ref").is_none(),
933 "agent_id must not be a $ref; got: {agent_id}"
934 );
935 let agent_id_str = agent_id.to_string();
936 assert!(
937 !agent_id_str.contains("$ref"),
938 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
939 );
940 assert!(
941 agent_id_str.contains("\"format\":\"uuid\""),
942 "agent_id should still carry format=uuid; got: {agent_id}"
943 );
944
945 for field in
950 ["gov_id", "maybe_gov_id", "content_ref", "maybe_content_ref"]
951 {
952 let f = &value["properties"][field];
953 assert!(
954 !f.to_string().contains("$ref"),
955 "{field} must contain no $ref anywhere; got: {f}"
956 );
957 }
958 assert_eq!(value["properties"]["gov_id"]["type"], "string");
959 assert_eq!(
960 value["properties"]["gov_id"]["pattern"],
961 r"^(GOV|APP)-\d{4}-\d{4}$"
962 );
963 assert!(
964 value["properties"]["maybe_gov_id"]
965 .to_string()
966 .contains("GOV|APP"),
967 "Option<GovernanceLogId> should keep the citation pattern; got: {}",
968 value["properties"]["maybe_gov_id"]
969 );
970 assert_eq!(value["properties"]["content_ref"]["type"], "string");
971 }
972
973 #[test]
974 fn governance_log_id_accepts_only_citation_shapes() {
975 for good in ["GOV-2026-0006", "APP-2026-0003", "GOV-1999-0000"] {
976 assert_eq!(
977 good.parse::<GovernanceLogId>().unwrap().as_str(),
978 good,
979 "{good} should parse"
980 );
981 }
982 for bad in [
983 "",
984 "GOV-2026-006",
985 "GOV-26-0006",
986 "gov-2026-0006",
987 "MOD-2026-0006",
988 "GOV-2026-0006-1",
989 "GOV-202X-0006",
990 "3f1a0000-0000-0000-0000-000000000000",
991 ] {
992 assert!(
993 bad.parse::<GovernanceLogId>().is_err(),
994 "{bad:?} should not parse as a GovernanceLogId"
995 );
996 }
997 }
998
999 #[test]
1003 fn governance_log_id_is_wire_compatible_with_a_bare_string() {
1004 let id: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1005 assert_eq!(serde_json::to_string(&id).unwrap(), "\"GOV-2026-0006\"");
1006 let back: GovernanceLogId =
1007 serde_json::from_str("\"GOV-2026-0006\"").unwrap();
1008 assert_eq!(back, id);
1009 assert!(serde_json::from_str::<GovernanceLogId>("\"nope\"").is_err());
1011 }
1012
1013 #[test]
1016 fn content_ref_round_trips_as_a_bare_string() {
1017 let uuid = Uuid::new_v4();
1018 let content = ContentRef::from(ContentId::from(uuid));
1019 assert_eq!(
1020 serde_json::to_value(&content).unwrap(),
1021 serde_json::json!(uuid.to_string())
1022 );
1023 assert_eq!(
1024 serde_json::from_value::<ContentRef>(serde_json::json!(
1025 uuid.to_string()
1026 ))
1027 .unwrap(),
1028 content
1029 );
1030
1031 let gov = ContentRef::Governance("APP-2026-0003".parse().unwrap());
1032 assert_eq!(
1033 serde_json::to_value(&gov).unwrap(),
1034 serde_json::json!("APP-2026-0003")
1035 );
1036 assert_eq!(
1037 serde_json::from_value::<ContentRef>(serde_json::json!(
1038 "APP-2026-0003"
1039 ))
1040 .unwrap(),
1041 gov
1042 );
1043
1044 assert!(gov.is_governance());
1045 assert!(!content.is_governance());
1046 assert_eq!(gov.kind_str(), "governance");
1047 assert_eq!(content.kind_str(), "content");
1048 assert_eq!(content.as_content(), Some(ContentId::from(uuid)));
1049 assert!(content.as_governance().is_none());
1050
1051 assert!("not-an-id".parse::<ContentRef>().is_err());
1053 assert!(
1054 serde_json::from_value::<ContentRef>(serde_json::json!(
1055 "not-an-id"
1056 ))
1057 .is_err()
1058 );
1059 }
1060
1061 #[test]
1063 fn every_readable_id_narrows_to_a_content_ref() {
1064 let uuid = Uuid::new_v4();
1065 for (label, got) in [
1066 ("PostId", ContentRef::from(PostId::from(uuid))),
1067 ("CommentId", ContentRef::from(CommentId::from(uuid))),
1068 ("ContentId", ContentRef::from(ContentId::from(uuid))),
1069 ] {
1070 assert_eq!(
1071 got,
1072 ContentRef::Content(ContentId::from(uuid)),
1073 "{label} -> ContentRef lost the uuid"
1074 );
1075 }
1076 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1077 assert_eq!(ContentRef::from(gov.clone()), ContentRef::Governance(gov));
1078 }
1079
1080 #[test]
1083 fn string_shaped_ids_round_trip_through_display() {
1084 let gov: GovernanceLogId = "GOV-2026-0006".parse().unwrap();
1085 assert_eq!(gov.to_string().parse::<GovernanceLogId>().unwrap(), gov);
1086
1087 let r = ContentRef::Governance(gov);
1088 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1089
1090 let r = ContentRef::Content(ContentId::new());
1091 assert_eq!(r.to_string().parse::<ContentRef>().unwrap(), r);
1092 }
1093
1094 #[test]
1095 fn post_or_comment_post_variant() {
1096 let inner = PostId::new();
1097 let tagged = PostOrCommentId::Post(inner);
1098 assert!(tagged.is_post());
1099 assert!(!tagged.is_comment());
1100 assert_eq!(tagged.as_post(), Some(inner));
1101 assert_eq!(tagged.as_comment(), None);
1102 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1103 assert_eq!(tagged.kind_str(), "post");
1104 }
1105
1106 #[test]
1107 fn post_or_comment_comment_variant() {
1108 let inner = CommentId::new();
1109 let tagged = PostOrCommentId::Comment(inner);
1110 assert!(tagged.is_comment());
1111 assert!(!tagged.is_post());
1112 assert_eq!(tagged.as_comment(), Some(inner));
1113 assert_eq!(tagged.as_post(), None);
1114 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
1115 assert_eq!(tagged.kind_str(), "comment");
1116 }
1117
1118 #[test]
1119 fn post_or_comment_from_conversions() {
1120 let post = PostId::new();
1121 let comment = CommentId::new();
1122 let via_post: PostOrCommentId = post.into();
1123 let via_comment: PostOrCommentId = comment.into();
1124 assert_eq!(via_post, PostOrCommentId::Post(post));
1125 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
1126 }
1127
1128 #[test]
1129 fn post_or_comment_display_is_kind_colon_uuid() {
1130 let post = PostId::new();
1131 let tagged = PostOrCommentId::Post(post);
1132 let rendered = tagged.to_string();
1133 assert!(rendered.starts_with("post:"));
1134 assert!(rendered.contains(&post.to_string()));
1135 }
1136}