Skip to main content

agentics_domain/models/
ids.rs

1//! Validated generated identifiers shared by API, database, and CLI DTOs.
2
3use std::borrow::Cow;
4use std::fmt;
5use std::str::FromStr;
6
7use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9use uuid::Uuid;
10
11/// Validation failure for generated UUID identifiers.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct UuidIdError {
14    message: &'static str,
15}
16
17impl UuidIdError {
18    const fn new(message: &'static str) -> Self {
19        Self { message }
20    }
21}
22
23impl fmt::Display for UuidIdError {
24    /// Handles fmt for this module.
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        f.write_str(self.message)
27    }
28}
29
30impl std::error::Error for UuidIdError {}
31
32macro_rules! define_uuid_id_type {
33    ($type_name:ident, $schema_name:literal, $message:literal) => {
34        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
35        pub struct $type_name(String);
36
37        impl $type_name {
38            /// Create a new random generated UUID identifier.
39            pub fn generate() -> Self {
40                Self(Uuid::new_v4().to_string())
41            }
42
43            /// Parse and canonicalize a generated UUID identifier.
44            pub fn try_new(value: impl AsRef<str>) -> Result<Self, UuidIdError> {
45                let value = value.as_ref();
46                if value.trim() != value {
47                    return Err(UuidIdError::new($message));
48                }
49                let canonical = value.to_ascii_lowercase();
50                let Ok(uuid) = Uuid::parse_str(&canonical) else {
51                    return Err(UuidIdError::new($message));
52                };
53                if uuid.to_string() != canonical {
54                    return Err(UuidIdError::new($message));
55                }
56                Ok(Self(canonical))
57            }
58
59            /// Borrow the canonical UUID string.
60            pub fn as_str(&self) -> &str {
61                &self.0
62            }
63        }
64
65        impl fmt::Display for $type_name {
66            /// Handles fmt for this module.
67            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68                f.write_str(self.as_str())
69            }
70        }
71
72        impl AsRef<str> for $type_name {
73            /// Returns ref in the representation required by callers.
74            fn as_ref(&self) -> &str {
75                self.as_str()
76            }
77        }
78
79        impl FromStr for $type_name {
80            type Err = UuidIdError;
81
82            /// Handles from str for this module.
83            fn from_str(value: &str) -> Result<Self, Self::Err> {
84                Self::try_new(value)
85            }
86        }
87
88        impl Serialize for $type_name {
89            /// Handles serialize for this module.
90            fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
91            where
92                S: Serializer,
93            {
94                serializer.serialize_str(self.as_str())
95            }
96        }
97
98        impl<'de> Deserialize<'de> for $type_name {
99            /// Handles deserialize for this module.
100            fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
101            where
102                D: Deserializer<'de>,
103            {
104                let value = String::deserialize(deserializer)?;
105                Self::try_new(&value).map_err(serde::de::Error::custom)
106            }
107        }
108
109        impl JsonSchema for $type_name {
110            /// Handles inline schema for this module.
111            fn inline_schema() -> bool {
112                true
113            }
114
115            /// Handles schema name for this module.
116            fn schema_name() -> Cow<'static, str> {
117                $schema_name.into()
118            }
119
120            /// Handles json schema for this module.
121            fn json_schema(_: &mut SchemaGenerator) -> Schema {
122                json_schema!({
123                    "type": "string",
124                    "format": "uuid",
125                    "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
126                })
127            }
128        }
129    };
130}
131
132define_uuid_id_type!(
133    AgentId,
134    "AgentId",
135    "agent_id must be a canonical UUID string"
136);
137define_uuid_id_type!(
138    AgentTokenId,
139    "AgentTokenId",
140    "agent_token_id must be a canonical UUID string"
141);
142define_uuid_id_type!(
143    HumanId,
144    "HumanId",
145    "human_id must be a canonical UUID string"
146);
147define_uuid_id_type!(
148    HumanSessionId,
149    "HumanSessionId",
150    "human_session_id must be a canonical UUID string"
151);
152define_uuid_id_type!(
153    AdminServiceTokenId,
154    "AdminServiceTokenId",
155    "admin_service_token_id must be a canonical UUID string"
156);
157define_uuid_id_type!(
158    CreatorApiTokenId,
159    "CreatorApiTokenId",
160    "creator_api_token_id must be a canonical UUID string"
161);
162define_uuid_id_type!(
163    PioneerCodeId,
164    "PioneerCodeId",
165    "pioneer_code_id must be a canonical UUID string"
166);
167define_uuid_id_type!(
168    ChallengeReviewRecordId,
169    "ChallengeReviewRecordId",
170    "challenge_review_record_id must be a canonical UUID string"
171);
172define_uuid_id_type!(
173    ChallengePrivateAssetId,
174    "ChallengePrivateAssetId",
175    "challenge_private_asset_id must be a canonical UUID string"
176);
177define_uuid_id_type!(
178    ChallengeReviewValidationRecordId,
179    "ChallengeReviewValidationRecordId",
180    "challenge_review_validation_record_id must be a canonical UUID string"
181);
182define_uuid_id_type!(
183    ChallengeReviewAuditEventId,
184    "ChallengeReviewAuditEventId",
185    "challenge_review_audit_event_id must be a canonical UUID string"
186);
187define_uuid_id_type!(
188    ChallengeReviewPublishClaimId,
189    "ChallengeReviewPublishClaimId",
190    "challenge_review_publish_claim_id must be a canonical UUID string"
191);
192define_uuid_id_type!(
193    ChallengeShortlistRevisionId,
194    "ChallengeShortlistRevisionId",
195    "challenge_shortlist_revision_id must be a canonical UUID string"
196);
197define_uuid_id_type!(
198    EvaluationJobId,
199    "EvaluationJobId",
200    "evaluation_job_id must be a canonical UUID string"
201);
202define_uuid_id_type!(
203    EvaluationId,
204    "EvaluationId",
205    "evaluation_id must be a canonical UUID string"
206);
207define_uuid_id_type!(
208    SolutionSubmissionId,
209    "SolutionSubmissionId",
210    "solution_submission_id must be a canonical UUID string"
211);
212
213#[cfg(test)]
214mod tests {
215    use super::{AgentId, AgentTokenId, ChallengeReviewRecordId, SolutionSubmissionId};
216
217    /// Verifies that validates solution submission ids.
218    #[test]
219    fn validates_solution_submission_ids() {
220        let valid = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
221        assert!(SolutionSubmissionId::try_new(valid).is_ok());
222        let canonical = SolutionSubmissionId::try_new("F47AC10B-58CC-4372-A567-0E02B2C3D479")
223            .expect("UUID hex case should canonicalize");
224        assert_eq!(canonical.as_str(), valid);
225        assert!(SolutionSubmissionId::try_new("submission-1").is_err());
226        assert!(SolutionSubmissionId::try_new(" f47ac10b-58cc-4372-a567-0e02b2c3d479").is_err());
227        assert!(SolutionSubmissionId::try_new("f47ac10b58cc4372a5670e02b2c3d479").is_err());
228    }
229
230    /// Verifies that serde rejects invalid solution submission ids.
231    #[test]
232    fn serde_rejects_invalid_solution_submission_ids() {
233        let submission: SolutionSubmissionId =
234            serde_json::from_str("\"f47ac10b-58cc-4372-a567-0e02b2c3d479\"")
235                .expect("valid submission id should parse");
236        assert_eq!(submission.as_str(), "f47ac10b-58cc-4372-a567-0e02b2c3d479");
237        assert!(serde_json::from_str::<SolutionSubmissionId>("\"submission-1\"").is_err());
238    }
239
240    /// Verifies that generated uuid ids canonicalize hex case.
241    #[test]
242    fn generated_uuid_ids_canonicalize_hex_case() {
243        let canonical = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
244        assert_eq!(
245            AgentId::try_new("F47AC10B-58CC-4372-A567-0E02B2C3D479")
246                .expect("UUID hex case should canonicalize")
247                .as_str(),
248            canonical
249        );
250        assert_eq!(
251            AgentTokenId::try_new(canonical)
252                .expect("agent token id should parse")
253                .as_str(),
254            canonical
255        );
256        assert_eq!(
257            ChallengeReviewRecordId::try_new(canonical)
258                .expect("challenge review record id should parse")
259                .as_str(),
260            canonical
261        );
262        assert!(ChallengeReviewRecordId::try_new(format!(" {canonical}")).is_err());
263        assert!(ChallengeReviewRecordId::try_new("f47ac10b58cc4372a5670e02b2c3d479").is_err());
264    }
265}