1#![forbid(unsafe_code)]
36#![deny(missing_docs)]
37#![warn(clippy::pedantic)]
38
39use core::fmt;
40use core::str::FromStr;
41
42use serde::{Deserialize, Serialize};
43use uuid::Uuid;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
50pub enum EntityType {
51 Person,
53 Worker,
55 Organization,
57 Case,
59 Place,
61 Thing,
63 Event,
65 Course,
67 CourseInstance,
69 CarePathway,
71}
72
73impl EntityType {
74 pub const ALL: [EntityType; 10] = [
76 EntityType::Person,
77 EntityType::Worker,
78 EntityType::Organization,
79 EntityType::Case,
80 EntityType::Place,
81 EntityType::Thing,
82 EntityType::Event,
83 EntityType::Course,
84 EntityType::CourseInstance,
85 EntityType::CarePathway,
86 ];
87
88 #[must_use]
91 pub const fn as_str(self) -> &'static str {
92 match self {
93 EntityType::Person => "person",
94 EntityType::Worker => "worker",
95 EntityType::Organization => "organization",
96 EntityType::Case => "case",
97 EntityType::Place => "place",
98 EntityType::Thing => "thing",
99 EntityType::Event => "event",
100 EntityType::Course => "course",
101 EntityType::CourseInstance => "courseinstance",
102 EntityType::CarePathway => "care_pathway",
103 }
104 }
105
106 #[must_use]
110 pub const fn service(self) -> &'static str {
111 match self {
112 EntityType::Person => "person-service",
113 EntityType::Worker => "worker-service",
114 EntityType::Organization => "organization-service",
115 EntityType::Case => "case-service",
116 EntityType::Place => "place-service",
117 EntityType::Thing => "thing-service",
118 EntityType::Event => "event-service",
119 EntityType::Course | EntityType::CourseInstance => "course-service",
120 EntityType::CarePathway => "care-pathway-service",
121 }
122 }
123
124 #[must_use]
126 pub fn from_token(token: &str) -> Option<Self> {
127 Self::ALL.into_iter().find(|t| t.as_str() == token)
128 }
129}
130
131impl fmt::Display for EntityType {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 f.write_str(self.as_str())
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
139pub enum ParseEntityRefError {
140 #[error("malformed entity ref (expected `entity_type:uuid`): {0:?}")]
142 Malformed(String),
143 #[error("unknown entity type: {0:?}")]
145 UnknownType(String),
146 #[error("invalid uuid in entity ref: {0:?}")]
148 BadId(String),
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
162#[serde(into = "String", try_from = "String")]
163pub struct EntityRef {
164 pub entity_type: EntityType,
166 pub id: Uuid,
168}
169
170impl EntityRef {
171 #[must_use]
173 pub const fn new(entity_type: EntityType, id: Uuid) -> Self {
174 Self { entity_type, id }
175 }
176
177 #[must_use]
179 pub const fn service(&self) -> &'static str {
180 self.entity_type.service()
181 }
182}
183
184impl fmt::Display for EntityRef {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 write!(f, "{}:{}", self.entity_type.as_str(), self.id)
187 }
188}
189
190impl FromStr for EntityRef {
191 type Err = ParseEntityRefError;
192
193 fn from_str(s: &str) -> Result<Self, Self::Err> {
194 let (type_token, id_token) = s
197 .split_once(':')
198 .filter(|(t, id)| !t.is_empty() && !id.is_empty() && !id.contains(':'))
199 .ok_or_else(|| ParseEntityRefError::Malformed(s.to_string()))?;
200 let entity_type = EntityType::from_token(type_token)
201 .ok_or_else(|| ParseEntityRefError::UnknownType(type_token.to_string()))?;
202 let id = Uuid::parse_str(id_token)
203 .map_err(|_| ParseEntityRefError::BadId(id_token.to_string()))?;
204 Ok(Self { entity_type, id })
205 }
206}
207
208impl From<EntityRef> for String {
209 fn from(r: EntityRef) -> Self {
210 r.to_string()
211 }
212}
213
214impl TryFrom<String> for EntityRef {
215 type Error = ParseEntityRefError;
216
217 fn try_from(s: String) -> Result<Self, Self::Error> {
218 s.parse()
219 }
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
225pub enum Sensitivity {
226 Medium,
228 High,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
237pub enum EdgeKind {
238 SameIdentity,
241 WorksAt,
243 MemberOf,
245 EmployedBy,
248 SubjectOf,
251}
252
253impl EdgeKind {
254 pub const ALL: [EdgeKind; 5] = [
256 EdgeKind::SameIdentity,
257 EdgeKind::WorksAt,
258 EdgeKind::MemberOf,
259 EdgeKind::EmployedBy,
260 EdgeKind::SubjectOf,
261 ];
262
263 #[must_use]
265 pub const fn as_str(self) -> &'static str {
266 match self {
267 EdgeKind::SameIdentity => "same_identity",
268 EdgeKind::WorksAt => "works_at",
269 EdgeKind::MemberOf => "member_of",
270 EdgeKind::EmployedBy => "employed_by",
271 EdgeKind::SubjectOf => "subject_of",
272 }
273 }
274
275 #[must_use]
277 pub fn from_token(token: &str) -> Option<Self> {
278 Self::ALL.into_iter().find(|k| k.as_str() == token)
279 }
280
281 #[must_use]
284 pub const fn is_symmetric(self) -> bool {
285 matches!(self, EdgeKind::SameIdentity)
286 }
287
288 #[must_use]
292 pub const fn is_temporal(self) -> bool {
293 matches!(
294 self,
295 EdgeKind::WorksAt | EdgeKind::MemberOf | EdgeKind::EmployedBy | EdgeKind::SubjectOf
296 )
297 }
298
299 #[must_use]
302 pub const fn inverse(self) -> Option<&'static str> {
303 match self {
304 EdgeKind::SameIdentity => None,
305 EdgeKind::WorksAt | EdgeKind::MemberOf => Some("has_member"),
306 EdgeKind::EmployedBy => Some("employs"),
307 EdgeKind::SubjectOf => Some("is_subject_of"),
308 }
309 }
310
311 #[must_use]
313 pub const fn sensitivity(self) -> Sensitivity {
314 match self {
315 EdgeKind::SubjectOf => Sensitivity::High,
316 _ => Sensitivity::Medium,
317 }
318 }
319
320 #[must_use]
324 pub fn permits(self, from: EntityType, to: EntityType) -> bool {
325 use EntityType::{Case, Organization, Person, Worker};
326 match self {
327 EdgeKind::SameIdentity => {
328 matches!((from, to), (Person, Worker) | (Worker, Person))
329 }
330 EdgeKind::WorksAt | EdgeKind::MemberOf => (from, to) == (Person, Organization),
331 EdgeKind::EmployedBy => (from, to) == (Worker, Organization),
332 EdgeKind::SubjectOf => (from, to) == (Case, Person),
333 }
334 }
335}
336
337impl fmt::Display for EdgeKind {
338 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339 f.write_str(self.as_str())
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 fn a_uuid() -> Uuid {
348 Uuid::parse_str("0c4f1e2a-0000-4000-8000-000000000000").unwrap()
349 }
350
351 #[test]
352 fn entity_ref_round_trips_through_its_urn() {
353 let r = EntityRef::new(EntityType::Person, a_uuid());
354 let urn = r.to_string();
355 assert_eq!(urn, "person:0c4f1e2a-0000-4000-8000-000000000000");
356 assert_eq!(urn.parse::<EntityRef>().unwrap(), r);
357 }
358
359 #[test]
360 fn parses_every_entity_type_token() {
361 for t in EntityType::ALL {
362 let urn = format!("{}:{}", t.as_str(), a_uuid());
363 assert_eq!(urn.parse::<EntityRef>().unwrap().entity_type, t);
364 }
365 }
366
367 #[test]
368 fn course_and_courseinstance_share_one_service() {
369 assert_eq!(EntityType::Course.service(), "course-service");
370 assert_eq!(EntityType::CourseInstance.service(), "course-service");
371 }
372
373 #[test]
374 fn rejects_unknown_type_bad_uuid_and_malformed() {
375 assert!(matches!(
376 "widget:0c4f1e2a-0000-4000-8000-000000000000".parse::<EntityRef>(),
377 Err(ParseEntityRefError::UnknownType(_))
378 ));
379 assert!(matches!(
380 "person:not-a-uuid".parse::<EntityRef>(),
381 Err(ParseEntityRefError::BadId(_))
382 ));
383 for bad in ["", "person", "person:", ":uuid", "a:b:c"] {
384 assert!(
385 matches!(
386 bad.parse::<EntityRef>(),
387 Err(ParseEntityRefError::Malformed(_) | ParseEntityRefError::BadId(_))
388 ),
389 "should reject {bad:?}"
390 );
391 }
392 }
393
394 #[test]
395 fn serde_uses_the_urn_string_form() {
396 let r = EntityRef::new(EntityType::Case, a_uuid());
397 let json = serde_json::to_string(&r).unwrap();
398 assert_eq!(json, "\"case:0c4f1e2a-0000-4000-8000-000000000000\"");
399 assert_eq!(serde_json::from_str::<EntityRef>(&json).unwrap(), r);
400 }
401
402 #[test]
403 fn edge_kind_registry_endpoint_rules() {
404 use EntityType::{Case, Organization, Person, Worker};
405 assert!(EdgeKind::SameIdentity.permits(Person, Worker));
407 assert!(EdgeKind::SameIdentity.permits(Worker, Person));
408 assert!(!EdgeKind::SameIdentity.permits(Person, Person));
409 assert!(EdgeKind::WorksAt.permits(Person, Organization));
411 assert!(!EdgeKind::WorksAt.permits(Organization, Person));
412 assert!(EdgeKind::EmployedBy.permits(Worker, Organization));
413 assert!(EdgeKind::SubjectOf.permits(Case, Person));
414 assert!(!EdgeKind::SubjectOf.permits(Person, Case));
415 }
416
417 #[test]
418 fn edge_kind_metadata_matches_the_registry() {
419 assert!(EdgeKind::SameIdentity.is_symmetric());
420 assert!(EdgeKind::SameIdentity.inverse().is_none());
421 assert!(!EdgeKind::SameIdentity.is_temporal());
422 assert_eq!(EdgeKind::EmployedBy.inverse(), Some("employs"));
423 assert!(EdgeKind::EmployedBy.is_temporal());
424 assert_eq!(EdgeKind::SubjectOf.sensitivity(), Sensitivity::High);
426 for k in EdgeKind::ALL {
427 if k != EdgeKind::SubjectOf {
428 assert_eq!(k.sensitivity(), Sensitivity::Medium);
429 }
430 }
431 }
432
433 #[test]
434 fn edge_kind_tokens_round_trip() {
435 for k in EdgeKind::ALL {
436 assert_eq!(EdgeKind::from_token(k.as_str()), Some(k));
437 }
438 assert_eq!(EdgeKind::from_token("nope"), None);
439 }
440}