1use crate::error::AcdpError;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
8pub struct CtxId(pub String);
9
10impl CtxId {
11 pub fn as_str(&self) -> &str {
13 &self.0
14 }
15
16 pub fn authority(&self) -> &str {
18 self.0
19 .strip_prefix("acdp://")
20 .and_then(|s| s.split('/').next())
21 .unwrap_or("")
22 }
23
24 pub fn parse(s: impl Into<String>) -> Result<Self, AcdpError> {
30 let s: String = s.into();
31 let rest = s.strip_prefix("acdp://").ok_or_else(|| {
32 AcdpError::SchemaViolation(format!("ctx_id must start with 'acdp://', got: {s}"))
33 })?;
34 let (authority, uuid_str) = rest
35 .split_once('/')
36 .ok_or_else(|| AcdpError::SchemaViolation(format!("ctx_id missing '/<uuid>': {s}")))?;
37 if !is_valid_dns_authority(authority) {
38 return Err(AcdpError::SchemaViolation(format!(
39 "ctx_id authority '{authority}' is not a lowercase DNS hostname"
40 )));
41 }
42 if !is_valid_uuid_v4(uuid_str) {
43 return Err(AcdpError::SchemaViolation(format!(
44 "ctx_id uuid '{uuid_str}' is not a lowercase v4 UUID"
45 )));
46 }
47 Ok(Self(s))
48 }
49
50 pub fn uuid(&self) -> Option<uuid::Uuid> {
52 let rest = self.0.strip_prefix("acdp://")?;
53 let (_authority, uuid_str) = rest.split_once('/')?;
54 uuid::Uuid::parse_str(uuid_str).ok()
55 }
56}
57
58impl std::fmt::Display for CtxId {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.write_str(&self.0)
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub struct LineageId(pub String);
67
68impl LineageId {
69 pub fn as_str(&self) -> &str {
71 &self.0
72 }
73
74 pub fn parse(s: impl Into<String>) -> Result<Self, AcdpError> {
77 let s: String = s.into();
78 let hex = s.strip_prefix("lin:sha256:").ok_or_else(|| {
79 AcdpError::SchemaViolation(format!(
80 "lineage_id must start with 'lin:sha256:', got: {s}"
81 ))
82 })?;
83 if hex.len() != 64 || !is_lowercase_hex(hex) {
84 return Err(AcdpError::SchemaViolation(format!(
85 "lineage_id digest must be 64 lowercase hex chars, got: {hex}"
86 )));
87 }
88 Ok(Self(s))
89 }
90}
91
92impl std::fmt::Display for LineageId {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 f.write_str(&self.0)
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
100pub struct ContentHash(pub String);
101
102impl ContentHash {
103 pub fn as_str(&self) -> &str {
105 &self.0
106 }
107
108 pub fn parse(s: impl Into<String>) -> Result<Self, AcdpError> {
111 let s: String = s.into();
112 let hex = s.strip_prefix("sha256:").ok_or_else(|| {
113 AcdpError::SchemaViolation(format!("content_hash must start with 'sha256:', got: {s}"))
114 })?;
115 if hex.len() != 64 || !is_lowercase_hex(hex) {
116 return Err(AcdpError::SchemaViolation(format!(
117 "content_hash digest must be 64 lowercase hex chars, got: {hex}"
118 )));
119 }
120 Ok(Self(s))
121 }
122}
123
124impl std::fmt::Display for ContentHash {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 f.write_str(&self.0)
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
132pub struct AgentDid(pub String);
133
134impl AgentDid {
135 pub fn new(s: impl Into<String>) -> Self {
137 Self(s.into())
138 }
139
140 pub fn as_str(&self) -> &str {
142 &self.0
143 }
144
145 pub fn parse(s: impl Into<String>) -> Result<Self, AcdpError> {
151 let s: String = s.into();
152 if s.len() < 7 || s.len() > 2048 {
153 return Err(AcdpError::SchemaViolation(format!(
154 "DID length {} not in 7..=2048",
155 s.len()
156 )));
157 }
158 let rest = s
159 .strip_prefix("did:")
160 .ok_or_else(|| AcdpError::SchemaViolation(format!("DID missing 'did:' prefix: {s}")))?;
161 let (method, id) = rest.split_once(':').ok_or_else(|| {
162 AcdpError::SchemaViolation(format!("DID must have method:id form: {s}"))
163 })?;
164 if method.is_empty()
165 || !method
166 .chars()
167 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
168 {
169 return Err(AcdpError::SchemaViolation(format!(
170 "DID method '{method}' must match [a-z0-9]+"
171 )));
172 }
173 if id.is_empty()
174 || !id
175 .chars()
176 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '%' | '-'))
177 {
178 return Err(AcdpError::SchemaViolation(format!(
179 "DID method-specific id '{id}' contains invalid characters"
180 )));
181 }
182 Ok(Self(s))
183 }
184
185 pub fn parse_web(s: impl Into<String>) -> Result<Self, AcdpError> {
187 let parsed = Self::parse(s)?;
188 if !parsed.0.starts_with("did:web:") {
189 return Err(AcdpError::SchemaViolation(format!(
190 "v0.1.0 producers MUST use did:web; got: {}",
191 parsed.0
192 )));
193 }
194 Ok(parsed)
195 }
196}
197
198impl std::fmt::Display for AgentDid {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 f.write_str(&self.0)
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum Visibility {
210 Public,
211 Restricted,
212 Private,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum ContextType {
226 DataSnapshot,
228 Analysis,
230 Prediction,
232 Alert,
234 Custom(String),
237}
238
239impl Serialize for ContextType {
240 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
241 let s = match self {
242 ContextType::DataSnapshot => "data_snapshot",
243 ContextType::Analysis => "analysis",
244 ContextType::Prediction => "prediction",
245 ContextType::Alert => "alert",
246 ContextType::Custom(s) => s.as_str(),
247 };
248 serializer.serialize_str(s)
249 }
250}
251
252impl<'de> Deserialize<'de> for ContextType {
253 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
254 let s = String::deserialize(deserializer)?;
255 Ok(match s.as_str() {
256 "data_snapshot" => ContextType::DataSnapshot,
257 "analysis" => ContextType::Analysis,
258 "prediction" => ContextType::Prediction,
259 "alert" => ContextType::Alert,
260 other => {
261 if !is_namespaced_context_type(other) {
263 return Err(serde::de::Error::custom(format!(
264 "context_type '{other}' is not a known ACDP type and does not match the \
265 namespaced custom pattern ^[a-z][a-z0-9_]*:[a-z][a-z0-9_-]*$"
266 )));
267 }
268 ContextType::Custom(s)
269 }
270 })
271 }
272}
273
274fn is_namespaced_context_type(s: &str) -> bool {
275 let Some((ns, name)) = s.split_once(':') else {
276 return false;
277 };
278 if ns.is_empty()
279 || !ns.chars().next().is_some_and(|c| c.is_ascii_lowercase())
280 || !ns
281 .chars()
282 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
283 {
284 return false;
285 }
286 if name.is_empty()
287 || !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
288 || !name
289 .chars()
290 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
291 {
292 return false;
293 }
294 true
295}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
307pub enum Status {
308 Active,
310 Superseded,
312 Expired,
314 Other(String),
317}
318
319impl Status {
320 fn pattern_ok(s: &str) -> bool {
322 !s.is_empty()
323 && s.len() <= 64
324 && s.chars().next().is_some_and(|c| c.is_ascii_lowercase())
325 && s.chars()
326 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
327 }
328
329 pub fn as_str(&self) -> &str {
331 match self {
332 Status::Active => "active",
333 Status::Superseded => "superseded",
334 Status::Expired => "expired",
335 Status::Other(s) => s,
336 }
337 }
338
339 pub fn parse(s: &str) -> Result<Self, AcdpError> {
341 match s {
342 "active" => Ok(Status::Active),
343 "superseded" => Ok(Status::Superseded),
344 "expired" => Ok(Status::Expired),
345 other => {
346 if !Self::pattern_ok(other) {
347 return Err(AcdpError::SchemaViolation(format!(
348 "status '{other}' does not match the open-enum pattern \
349 ^[a-z][a-z0-9_]*$ (length 1..=64)"
350 )));
351 }
352 Ok(Status::Other(other.to_string()))
353 }
354 }
355 }
356}
357
358impl Serialize for Status {
359 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
360 s.serialize_str(self.as_str())
361 }
362}
363
364impl<'de> Deserialize<'de> for Status {
365 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
366 let s = String::deserialize(d)?;
367 Status::parse(&s).map_err(serde::de::Error::custom)
368 }
369}
370
371impl Status {
372 pub fn is_active(&self) -> bool {
374 matches!(self, Status::Active)
375 }
376
377 pub fn is_superseded(&self) -> bool {
379 matches!(self, Status::Superseded)
380 }
381
382 pub fn is_expired(&self) -> bool {
384 matches!(self, Status::Expired)
385 }
386
387 pub fn as_other(&self) -> Option<&str> {
389 match self {
390 Status::Other(s) => Some(s),
391 _ => None,
392 }
393 }
394
395 pub fn known_or_active(&self) -> Status {
401 match self {
402 Status::Other(_) => Status::Active,
403 s => s.clone(),
404 }
405 }
406}
407
408fn is_lowercase_hex(s: &str) -> bool {
411 s.chars()
412 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
413}
414
415pub fn is_valid_dns_authority(s: &str) -> bool {
423 if s.is_empty() || s.len() > 253 {
424 return false;
425 }
426 s.split('.').all(|label| {
427 !label.is_empty()
428 && label.len() <= 63
429 && label
430 .chars()
431 .next()
432 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
433 && label
434 .chars()
435 .last()
436 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
437 && label
438 .chars()
439 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
440 })
441}
442
443fn is_valid_uuid_v4(s: &str) -> bool {
446 let bytes = s.as_bytes();
447 if bytes.len() != 36 {
448 return false;
449 }
450 for (i, &b) in bytes.iter().enumerate() {
451 match i {
452 8 | 13 | 18 | 23 => {
453 if b != b'-' {
454 return false;
455 }
456 }
457 _ => {
458 if !(b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
459 return false;
460 }
461 }
462 }
463 }
464 bytes[14] == b'4' && matches!(bytes[19], b'8' | b'9' | b'a' | b'b')
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use serde_json::json;
471
472 #[test]
473 fn known_status_values_deserialize() {
474 let s: Status = serde_json::from_value(json!("active")).unwrap();
475 assert!(s.is_active());
476 let s: Status = serde_json::from_value(json!("superseded")).unwrap();
477 assert!(s.is_superseded());
478 let s: Status = serde_json::from_value(json!("expired")).unwrap();
479 assert!(s.is_expired());
480 }
481
482 #[test]
483 fn unknown_status_value_falls_back_to_other() {
484 let s: Status = serde_json::from_value(json!("retracted")).unwrap();
487 assert_eq!(s.as_other(), Some("retracted"));
488 assert!(!s.is_active());
489 assert!(!s.is_superseded());
490 assert!(!s.is_expired());
491
492 let s: Status = serde_json::from_value(json!("archived")).unwrap();
493 assert_eq!(s.as_other(), Some("archived"));
494 }
495
496 #[test]
497 fn ctx_id_authority() {
498 let id = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
499 assert_eq!(id.authority(), "registry.example.com");
500 }
501
502 #[test]
503 fn ctx_id_parse_valid() {
504 let id = CtxId::parse(
505 "acdp://registry.example.com/12345678-1234-4321-8123-123456781234".to_string(),
506 )
507 .unwrap();
508 assert_eq!(id.authority(), "registry.example.com");
509 assert!(id.uuid().is_some());
510 }
511
512 #[test]
513 fn ctx_id_parse_rejects_uppercase_authority() {
514 assert!(
515 CtxId::parse("acdp://Registry.Example.com/12345678-1234-4321-8123-123456781234")
516 .is_err()
517 );
518 }
519
520 #[test]
521 fn ctx_id_parse_rejects_non_v4_uuid() {
522 assert!(
524 CtxId::parse("acdp://registry.example.com/12345678-1234-1321-8123-123456781234")
525 .is_err()
526 );
527 }
528
529 #[test]
530 fn ctx_id_parse_rejects_bad_variant() {
531 assert!(
533 CtxId::parse("acdp://registry.example.com/12345678-1234-4321-0123-123456781234")
534 .is_err()
535 );
536 }
537
538 #[test]
539 fn lineage_id_parse() {
540 let l = LineageId::parse(
541 "lin:sha256:b14ccd2a8b34530309255db68c151a10689b6a82feb30aff9222d54fdd871720"
542 .to_string(),
543 )
544 .unwrap();
545 assert!(l.as_str().starts_with("lin:sha256:"));
546 assert!(LineageId::parse("lin:sha256:abc").is_err());
547 assert!(LineageId::parse(
548 "lin:sha256:B14CCD2A8B34530309255DB68C151A10689B6A82FEB30AFF9222D54FDD871720"
549 )
550 .is_err());
551 }
552
553 #[test]
554 fn content_hash_parse() {
555 ContentHash::parse(
556 "sha256:f170150ddbf59d99794e7797824591b374d459782084597b644ecc57a41031b5".to_string(),
557 )
558 .unwrap();
559 assert!(ContentHash::parse("md5:abc").is_err());
560 assert!(ContentHash::parse("sha256:zzzz").is_err());
561 }
562
563 #[test]
564 fn agent_did_parse_valid() {
565 AgentDid::parse("did:web:agents.example.com:test").unwrap();
566 AgentDid::parse("did:key:z6Mki...").unwrap();
567 }
568
569 #[test]
570 fn agent_did_parse_rejects_invalid_method() {
571 assert!(AgentDid::parse("did:WEB:agents.example.com").is_err());
572 assert!(AgentDid::parse("did::test").is_err());
573 assert!(AgentDid::parse("notadid").is_err());
574 }
575
576 #[test]
577 fn agent_did_parse_web_enforces_method() {
578 AgentDid::parse_web("did:web:agents.example.com:test").unwrap();
579 assert!(AgentDid::parse_web("did:key:z6Mki...").is_err());
580 }
581
582 #[test]
583 fn agent_did_new_skips_validation() {
584 let did = AgentDid::new("not-a-did");
586 assert_eq!(did.as_str(), "not-a-did");
587 }
588
589 #[test]
590 fn agent_did_parse_rejects_length_bounds() {
591 assert!(AgentDid::parse("did:w:").is_err(), "too short / empty id");
592 let long = format!("did:web:{}", "a".repeat(2100));
593 assert!(AgentDid::parse(long).is_err(), "over 2048 chars");
594 }
595
596 #[test]
599 fn context_type_known_values_round_trip() {
600 for (s, expect) in [
601 ("data_snapshot", ContextType::DataSnapshot),
602 ("analysis", ContextType::Analysis),
603 ("prediction", ContextType::Prediction),
604 ("alert", ContextType::Alert),
605 ] {
606 let parsed: ContextType = serde_json::from_value(json!(s)).unwrap();
607 assert_eq!(parsed, expect);
608 assert_eq!(serde_json::to_value(&parsed).unwrap(), json!(s));
609 }
610 }
611
612 #[test]
613 fn context_type_accepts_namespaced_custom() {
614 let parsed: ContextType =
615 serde_json::from_value(json!("finance:portfolio_snapshot")).unwrap();
616 assert_eq!(
617 parsed,
618 ContextType::Custom("finance:portfolio_snapshot".into())
619 );
620 assert_eq!(
622 serde_json::to_value(&parsed).unwrap(),
623 json!("finance:portfolio_snapshot")
624 );
625 }
626
627 #[test]
628 fn context_type_rejects_unnamespaced_unknown() {
629 for bad in [
631 "totally_unknown",
632 "Finance:x",
633 "finance:",
634 ":name",
635 "1ns:name",
636 ] {
637 let parsed: Result<ContextType, _> = serde_json::from_value(json!(bad));
638 assert!(parsed.is_err(), "context_type {bad:?} must be rejected");
639 }
640 }
641
642 #[test]
643 fn namespaced_context_type_helper_edges() {
644 assert!(is_namespaced_context_type("a:b"));
645 assert!(is_namespaced_context_type("ns1:name_2-x"));
646 assert!(!is_namespaced_context_type("nocolon"));
647 assert!(!is_namespaced_context_type("ns:Name")); assert!(!is_namespaced_context_type("ns:-bad")); assert!(!is_namespaced_context_type("ns:1bad")); }
651
652 #[test]
655 fn status_parse_rejects_pattern_violations() {
656 for bad in ["Active", "has space", "", "UPPER", "trailing!"] {
657 assert!(
658 Status::parse(bad).is_err(),
659 "status {bad:?} violates ^[a-z][a-z0-9_]*$ and must be rejected"
660 );
661 }
662 }
663
664 #[test]
665 fn status_deserialize_rejects_malformed() {
666 let parsed: Result<Status, _> = serde_json::from_value(json!("Active"));
668 assert!(parsed.is_err());
669 }
670
671 #[test]
672 fn status_known_or_active_degrades_unknown() {
673 let other = Status::Other("retracted".into());
675 assert_eq!(other.known_or_active(), Status::Active);
676 assert_eq!(Status::Superseded.known_or_active(), Status::Superseded);
678 assert_eq!(Status::Expired.known_or_active(), Status::Expired);
679 }
680
681 #[test]
682 fn status_as_str_matches_wire_form() {
683 assert_eq!(Status::Active.as_str(), "active");
684 assert_eq!(Status::Superseded.as_str(), "superseded");
685 assert_eq!(Status::Expired.as_str(), "expired");
686 assert_eq!(Status::Other("custom".into()).as_str(), "custom");
687 }
688
689 #[test]
692 fn visibility_round_trips_snake_case() {
693 for (s, expect) in [
694 ("public", Visibility::Public),
695 ("restricted", Visibility::Restricted),
696 ("private", Visibility::Private),
697 ] {
698 let parsed: Visibility = serde_json::from_value(json!(s)).unwrap();
699 assert_eq!(parsed, expect);
700 assert_eq!(serde_json::to_value(&parsed).unwrap(), json!(s));
701 }
702 assert!(serde_json::from_value::<Visibility>(json!("Public")).is_err());
703 }
704
705 #[test]
706 fn identifier_display_matches_inner_string() {
707 let ctx = "acdp://r.example.com/12345678-1234-4321-8123-123456781234";
708 assert_eq!(CtxId(ctx.into()).to_string(), ctx);
709 let lin = "lin:sha256:1111111111111111111111111111111111111111111111111111111111111111";
710 assert_eq!(LineageId(lin.into()).to_string(), lin);
711 let hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111";
712 assert_eq!(ContentHash(hash.into()).to_string(), hash);
713 assert_eq!(AgentDid::new("did:web:x").to_string(), "did:web:x");
714 }
715
716 #[test]
717 fn ctx_id_uuid_returns_none_for_malformed() {
718 assert!(CtxId("not-a-ctx-id".into()).uuid().is_none());
720 assert!(CtxId("acdp://host/not-a-uuid".into()).uuid().is_none());
721 }
722}