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
196define_id! {
197 MessageId
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
233pub enum PostOrCommentId {
234 Post(PostId),
235 Comment(CommentId),
236}
237
238impl PostOrCommentId {
239 pub fn as_uuid(&self) -> Uuid {
241 match self {
242 PostOrCommentId::Post(id) => *id.as_uuid(),
243 PostOrCommentId::Comment(id) => *id.as_uuid(),
244 }
245 }
246
247 pub fn is_post(&self) -> bool {
249 matches!(self, PostOrCommentId::Post(_))
250 }
251
252 pub fn is_comment(&self) -> bool {
254 matches!(self, PostOrCommentId::Comment(_))
255 }
256
257 pub fn as_post(&self) -> Option<PostId> {
259 match self {
260 PostOrCommentId::Post(id) => Some(*id),
261 PostOrCommentId::Comment(_) => None,
262 }
263 }
264
265 pub fn as_comment(&self) -> Option<CommentId> {
267 match self {
268 PostOrCommentId::Comment(id) => Some(*id),
269 PostOrCommentId::Post(_) => None,
270 }
271 }
272
273 pub fn kind_str(&self) -> &'static str {
276 match self {
277 PostOrCommentId::Post(_) => "post",
278 PostOrCommentId::Comment(_) => "comment",
279 }
280 }
281}
282
283impl From<PostId> for PostOrCommentId {
284 fn from(id: PostId) -> Self {
285 PostOrCommentId::Post(id)
286 }
287}
288
289impl From<CommentId> for PostOrCommentId {
290 fn from(id: CommentId) -> Self {
291 PostOrCommentId::Comment(id)
292 }
293}
294
295impl std::fmt::Display for PostOrCommentId {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 write!(f, "{}:{}", self.kind_str(), self.as_uuid())
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn ids_are_unique() {
307 let a = AgentId::new();
308 let b = AgentId::new();
309 assert_ne!(a, b);
310 }
311
312 #[test]
313 fn serde_round_trip() {
314 let id = PostId::new();
315 let json = serde_json::to_string(&id).unwrap();
316 let deserialized: PostId = serde_json::from_str(&json).unwrap();
317 assert_eq!(id, deserialized);
318 }
319
320 #[test]
321 fn display_shows_uuid() {
322 let id = CommunityId::new();
323 let display = id.to_string();
324 assert_eq!(display.len(), 36);
326 assert!(display.contains('-'));
327 }
328
329 #[test]
330 fn from_uuid_round_trip() {
331 let uuid = Uuid::new_v4();
332 let id = AgentId::from(uuid);
333 let back: Uuid = id.into();
334 assert_eq!(uuid, back);
335 }
336
337 #[test]
338 fn json_is_plain_uuid_string() {
339 let uuid = Uuid::new_v4();
340 let id = AgentId::from(uuid);
341 let id_json = serde_json::to_string(&id).unwrap();
343 let uuid_json = serde_json::to_string(&uuid).unwrap();
344 assert_eq!(id_json, uuid_json);
345 }
346
347 #[cfg(feature = "schemars")]
352 #[test]
353 fn id_json_schema_is_inlined() {
354 use schemars::JsonSchema;
355
356 assert!(
357 <PostId as JsonSchema>::inline_schema(),
358 "PostId::inline_schema() must return true to avoid $ref in containing schemas"
359 );
360 assert!(<AgentId as JsonSchema>::inline_schema());
361 assert!(<CommentId as JsonSchema>::inline_schema());
362 assert!(<CommunityId as JsonSchema>::inline_schema());
363
364 #[derive(schemars::JsonSchema)]
368 #[allow(dead_code)]
369 struct Container {
370 post_id: PostId,
372 agent_id: Option<AgentId>,
374 }
375
376 let schema = schemars::schema_for!(Container);
377 let value = serde_json::to_value(&schema).unwrap();
378
379 assert!(
381 value.get("$defs").is_none(),
382 "no $defs should be emitted for ID-only container; got schema: {value}"
383 );
384
385 let post_id = &value["properties"]["post_id"];
387 assert!(
388 post_id.get("$ref").is_none(),
389 "post_id must not be a $ref; got: {post_id}"
390 );
391 assert_eq!(post_id["type"], "string");
392 assert_eq!(post_id["format"], "uuid");
393
394 let agent_id = &value["properties"]["agent_id"];
399 assert!(
400 agent_id.get("$ref").is_none(),
401 "agent_id must not be a $ref; got: {agent_id}"
402 );
403 let agent_id_str = agent_id.to_string();
404 assert!(
405 !agent_id_str.contains("$ref"),
406 "agent_id schema must contain no $ref anywhere; got: {agent_id}"
407 );
408 assert!(
409 agent_id_str.contains("\"format\":\"uuid\""),
410 "agent_id should still carry format=uuid; got: {agent_id}"
411 );
412 }
413
414 #[test]
415 fn post_or_comment_post_variant() {
416 let inner = PostId::new();
417 let tagged = PostOrCommentId::Post(inner);
418 assert!(tagged.is_post());
419 assert!(!tagged.is_comment());
420 assert_eq!(tagged.as_post(), Some(inner));
421 assert_eq!(tagged.as_comment(), None);
422 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
423 assert_eq!(tagged.kind_str(), "post");
424 }
425
426 #[test]
427 fn post_or_comment_comment_variant() {
428 let inner = CommentId::new();
429 let tagged = PostOrCommentId::Comment(inner);
430 assert!(tagged.is_comment());
431 assert!(!tagged.is_post());
432 assert_eq!(tagged.as_comment(), Some(inner));
433 assert_eq!(tagged.as_post(), None);
434 assert_eq!(tagged.as_uuid(), *inner.as_uuid());
435 assert_eq!(tagged.kind_str(), "comment");
436 }
437
438 #[test]
439 fn post_or_comment_from_conversions() {
440 let post = PostId::new();
441 let comment = CommentId::new();
442 let via_post: PostOrCommentId = post.into();
443 let via_comment: PostOrCommentId = comment.into();
444 assert_eq!(via_post, PostOrCommentId::Post(post));
445 assert_eq!(via_comment, PostOrCommentId::Comment(comment));
446 }
447
448 #[test]
449 fn post_or_comment_display_is_kind_colon_uuid() {
450 let post = PostId::new();
451 let tagged = PostOrCommentId::Post(post);
452 let rendered = tagged.to_string();
453 assert!(rendered.starts_with("post:"));
454 assert!(rendered.contains(&post.to_string()));
455 }
456}