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 #[cfg(feature = "schemars")]
64 impl schemars::JsonSchema for $name {
65 fn inline_schema() -> bool {
66 true
67 }
68
69 fn schema_name() -> std::borrow::Cow<'static, str> {
70 std::borrow::Cow::Borrowed(stringify!($name))
71 }
72
73 fn schema_id() -> std::borrow::Cow<'static, str> {
74 std::borrow::Cow::Borrowed(concat!(module_path!(), "::", stringify!($name)))
75 }
76
77 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
78 schemars::json_schema!({
79 "type": "string",
80 "format": "uuid",
81 })
82 }
83 }
84 };
85}
86
87define_id! {
88 AgentId
90}
91
92define_id! {
93 ReactorId
95}
96
97define_id! {
98 OperatorId
100}
101
102define_id! {
103 PostId
105}
106
107define_id! {
108 CommentId
110}
111
112define_id! {
113 CommunityId
115}
116
117define_id! {
118 VoteId
120}
121
122define_id! {
123 ModerationActionId
125}
126
127define_id! {
128 AppealId
130}
131
132define_id! {
133 FlagId
135}
136
137define_id! {
138 CouncilMeetingId
140}
141
142define_id! {
143 AgendaItemId
145}
146
147define_id! {
148 DecisionId
150}
151
152define_id! {
153 BatchTrackingId
155}
156
157define_id! {
158 ThreadSummaryId
160}
161
162define_id! {
163 McpSessionId
165}
166
167define_id! {
168 EmailVerificationTokenId
170}
171
172define_id! {
173 PostEmbeddingId
175}
176
177define_id! {
178 DataExportId
184}
185
186define_id! {
187 RefreshTokenId
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
222pub enum PostOrCommentId {
223 Post(PostId),
224 Comment(CommentId),
225}
226
227impl PostOrCommentId {
228 pub fn as_uuid(&self) -> Uuid {
230 match self {
231 PostOrCommentId::Post(id) => *id.as_uuid(),
232 PostOrCommentId::Comment(id) => *id.as_uuid(),
233 }
234 }
235
236 pub fn is_post(&self) -> bool {
238 matches!(self, PostOrCommentId::Post(_))
239 }
240
241 pub fn is_comment(&self) -> bool {
243 matches!(self, PostOrCommentId::Comment(_))
244 }
245
246 pub fn as_post(&self) -> Option<PostId> {
248 match self {
249 PostOrCommentId::Post(id) => Some(*id),
250 PostOrCommentId::Comment(_) => None,
251 }
252 }
253
254 pub fn as_comment(&self) -> Option<CommentId> {
256 match self {
257 PostOrCommentId::Comment(id) => Some(*id),
258 PostOrCommentId::Post(_) => None,
259 }
260 }
261
262 pub fn kind_str(&self) -> &'static str {
265 match self {
266 PostOrCommentId::Post(_) => "post",
267 PostOrCommentId::Comment(_) => "comment",
268 }
269 }
270}
271
272impl From<PostId> for PostOrCommentId {
273 fn from(id: PostId) -> Self {
274 PostOrCommentId::Post(id)
275 }
276}
277
278impl From<CommentId> for PostOrCommentId {
279 fn from(id: CommentId) -> Self {
280 PostOrCommentId::Comment(id)
281 }
282}
283
284impl std::fmt::Display for PostOrCommentId {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn ids_are_unique() {
296 let a = AgentId::new();
297 let b = AgentId::new();
298 assert_ne!(a, b);
299 }
300
301 #[test]
302 fn serde_round_trip() {
303 let id = PostId::new();
304 let json = serde_json::to_string(&id).unwrap();
305 let deserialized: PostId = serde_json::from_str(&json).unwrap();
306 assert_eq!(id, deserialized);
307 }
308
309 #[test]
310 fn display_shows_uuid() {
311 let id = CommunityId::new();
312 let display = id.to_string();
313 assert_eq!(display.len(), 36);
315 assert!(display.contains('-'));
316 }
317
318 #[test]
319 fn from_uuid_round_trip() {
320 let uuid = Uuid::new_v4();
321 let id = AgentId::from(uuid);
322 let back: Uuid = id.into();
323 assert_eq!(uuid, back);
324 }
325
326 #[test]
327 fn json_is_plain_uuid_string() {
328 let uuid = Uuid::new_v4();
329 let id = AgentId::from(uuid);
330 let id_json = serde_json::to_string(&id).unwrap();
332 let uuid_json = serde_json::to_string(&uuid).unwrap();
333 assert_eq!(id_json, uuid_json);
334 }
335
336 #[cfg(feature = "schemars")]
341 #[test]
342 fn id_json_schema_is_inlined() {
343 use schemars::JsonSchema;
344
345 assert!(
346 <PostId as JsonSchema>::inline_schema(),
347 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
348 );
349 assert!(<AgentId as JsonSchema>::inline_schema());
350 assert!(<CommentId as JsonSchema>::inline_schema());
351 assert!(<CommunityId as JsonSchema>::inline_schema());
352
353 #[derive(schemars::JsonSchema)]
357 #[allow(dead_code)]
358 struct Container {
359 post_id: PostId,
361 agent_id: Option<AgentId>,
363 }
364
365 let schema = schemars::schema_for!(Container);
366 let value = serde_json::to_value(&schema).unwrap();
367
368 assert!(
370 value.get("$defs").is_none(),
371 "no $defs should be emitted for ID-only container; got schema: {value}"
372 );
373
374 let post_id = &value["properties"]["post_id"];
376 assert!(
377 post_id.get("$ref").is_none(),
378 "post_id must not be a $ref; got: {post_id}"
379 );
380 assert_eq!(post_id["type"], "string");
381 assert_eq!(post_id["format"], "uuid");
382
383 let agent_id = &value["properties"]["agent_id"];
388 assert!(
389 agent_id.get("$ref").is_none(),
390 "agent_id must not be a $ref; got: {agent_id}"
391 );
392 let agent_id_str = agent_id.to_string();
393 assert!(
394 !agent_id_str.contains("$ref"),
395 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
396 );
397 assert!(
398 agent_id_str.contains("\"format\":\"uuid\""),
399 "agent_id should still carry format=uuid; got: {agent_id}"
400 );
401 }
402
403 #[test]
404 fn post_or_comment_post_variant() {
405 let inner = PostId::new();
406 let tagged = PostOrCommentId::Post(inner);
407 assert!(tagged.is_post());
408 assert!(!tagged.is_comment());
409 assert_eq!(tagged.as_post(), Some(inner));
410 assert_eq!(tagged.as_comment(), None);
411 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
412 assert_eq!(tagged.kind_str(), "post");
413 }
414
415 #[test]
416 fn post_or_comment_comment_variant() {
417 let inner = CommentId::new();
418 let tagged = PostOrCommentId::Comment(inner);
419 assert!(tagged.is_comment());
420 assert!(!tagged.is_post());
421 assert_eq!(tagged.as_comment(), Some(inner));
422 assert_eq!(tagged.as_post(), None);
423 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
424 assert_eq!(tagged.kind_str(), "comment");
425 }
426
427 #[test]
428 fn post_or_comment_from_conversions() {
429 let post = PostId::new();
430 let comment = CommentId::new();
431 let via_post: PostOrCommentId = post.into();
432 let via_comment: PostOrCommentId = comment.into();
433 assert_eq!(via_post, PostOrCommentId::Post(post));
434 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
435 }
436
437 #[test]
438 fn post_or_comment_display_is_kind_colon_uuid() {
439 let post = PostId::new();
440 let tagged = PostOrCommentId::Post(post);
441 let rendered = tagged.to_string();
442 assert!(rendered.starts_with("post:"));
443 assert!(rendered.contains(&post.to_string()));
444 }
445}