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)]
226pub enum ContextType {
227 DataSnapshot,
229 Analysis,
231 Prediction,
233 Alert,
235 KeyRevocation,
242 Custom(String),
245}
246
247impl ContextType {
248 pub const KEY_REVOCATION_INTERIM: &'static str = "acdp:key-revocation";
251
252 pub fn is_key_revocation(&self) -> bool {
258 match self {
259 ContextType::KeyRevocation => true,
260 ContextType::Custom(s) => s == Self::KEY_REVOCATION_INTERIM,
261 _ => false,
262 }
263 }
264}
265
266impl Serialize for ContextType {
267 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
268 let s = match self {
269 ContextType::DataSnapshot => "data_snapshot",
270 ContextType::Analysis => "analysis",
271 ContextType::Prediction => "prediction",
272 ContextType::Alert => "alert",
273 ContextType::KeyRevocation => "key-revocation",
274 ContextType::Custom(s) => s.as_str(),
275 };
276 serializer.serialize_str(s)
277 }
278}
279
280impl<'de> Deserialize<'de> for ContextType {
281 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
282 let s = String::deserialize(deserializer)?;
283 Ok(match s.as_str() {
284 "data_snapshot" => ContextType::DataSnapshot,
285 "analysis" => ContextType::Analysis,
286 "prediction" => ContextType::Prediction,
287 "alert" => ContextType::Alert,
288 "key-revocation" => ContextType::KeyRevocation,
289 other => {
290 if !is_namespaced_context_type(other) {
292 return Err(serde::de::Error::custom(format!(
293 "context_type '{other}' is not a known ACDP type and does not match the \
294 namespaced custom pattern ^[a-z][a-z0-9_]*:[a-z][a-z0-9_-]*$"
295 )));
296 }
297 ContextType::Custom(s)
298 }
299 })
300 }
301}
302
303fn is_namespaced_context_type(s: &str) -> bool {
304 let Some((ns, name)) = s.split_once(':') else {
305 return false;
306 };
307 if ns.is_empty()
308 || !ns.chars().next().is_some_and(|c| c.is_ascii_lowercase())
309 || !ns
310 .chars()
311 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
312 {
313 return false;
314 }
315 if name.is_empty()
316 || !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
317 || !name
318 .chars()
319 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
320 {
321 return false;
322 }
323 true
324}
325
326#[derive(Debug, Clone, PartialEq, Eq)]
343pub enum Status {
344 Active,
346 Superseded,
348 Expired,
350 Retracted,
354 Other(String),
357}
358
359impl Status {
360 fn pattern_ok(s: &str) -> bool {
362 !s.is_empty()
363 && s.len() <= 64
364 && s.chars().next().is_some_and(|c| c.is_ascii_lowercase())
365 && s.chars()
366 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
367 }
368
369 pub fn as_str(&self) -> &str {
371 match self {
372 Status::Active => "active",
373 Status::Superseded => "superseded",
374 Status::Expired => "expired",
375 Status::Retracted => "retracted",
376 Status::Other(s) => s,
377 }
378 }
379
380 pub fn parse(s: &str) -> Result<Self, AcdpError> {
382 match s {
383 "active" => Ok(Status::Active),
384 "superseded" => Ok(Status::Superseded),
385 "expired" => Ok(Status::Expired),
386 "retracted" => Ok(Status::Retracted),
387 other => {
388 if !Self::pattern_ok(other) {
389 return Err(AcdpError::SchemaViolation(format!(
390 "status '{other}' does not match the open-enum pattern \
391 ^[a-z][a-z0-9_]*$ (length 1..=64)"
392 )));
393 }
394 Ok(Status::Other(other.to_string()))
395 }
396 }
397 }
398}
399
400impl Serialize for Status {
401 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
402 s.serialize_str(self.as_str())
403 }
404}
405
406impl<'de> Deserialize<'de> for Status {
407 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
408 let s = String::deserialize(d)?;
409 Status::parse(&s).map_err(serde::de::Error::custom)
410 }
411}
412
413impl Status {
414 pub fn is_active(&self) -> bool {
416 matches!(self, Status::Active)
417 }
418
419 pub fn is_superseded(&self) -> bool {
421 matches!(self, Status::Superseded)
422 }
423
424 pub fn is_expired(&self) -> bool {
426 matches!(self, Status::Expired)
427 }
428
429 pub fn is_retracted(&self) -> bool {
431 matches!(self, Status::Retracted)
432 }
433
434 pub fn as_other(&self) -> Option<&str> {
436 match self {
437 Status::Other(s) => Some(s),
438 _ => None,
439 }
440 }
441
442 pub fn known_or_active(&self) -> Status {
448 match self {
449 Status::Other(_) => Status::Active,
450 s => s.clone(),
451 }
452 }
453}
454
455fn is_lowercase_hex(s: &str) -> bool {
458 s.chars()
459 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
460}
461
462pub fn is_valid_dns_authority(s: &str) -> bool {
470 if s.is_empty() || s.len() > 253 {
471 return false;
472 }
473 s.split('.').all(|label| {
474 !label.is_empty()
475 && label.len() <= 63
476 && label
477 .chars()
478 .next()
479 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
480 && label
481 .chars()
482 .last()
483 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
484 && label
485 .chars()
486 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
487 })
488}
489
490fn is_valid_uuid_v4(s: &str) -> bool {
493 let bytes = s.as_bytes();
494 if bytes.len() != 36 {
495 return false;
496 }
497 for (i, &b) in bytes.iter().enumerate() {
498 match i {
499 8 | 13 | 18 | 23 => {
500 if b != b'-' {
501 return false;
502 }
503 }
504 _ => {
505 if !(b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
506 return false;
507 }
508 }
509 }
510 }
511 bytes[14] == b'4' && matches!(bytes[19], b'8' | b'9' | b'a' | b'b')
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use serde_json::json;
518
519 #[test]
520 fn known_status_values_deserialize() {
521 let s: Status = serde_json::from_value(json!("active")).unwrap();
522 assert!(s.is_active());
523 let s: Status = serde_json::from_value(json!("superseded")).unwrap();
524 assert!(s.is_superseded());
525 let s: Status = serde_json::from_value(json!("expired")).unwrap();
526 assert!(s.is_expired());
527 let s: Status = serde_json::from_value(json!("retracted")).unwrap();
530 assert!(s.is_retracted());
531 assert_eq!(s, Status::Retracted);
532 assert!(!s.is_active());
533 assert!(!s.is_superseded());
534 assert!(!s.is_expired());
535 assert_eq!(s.as_other(), None);
536 }
537
538 #[test]
539 fn unknown_status_value_falls_back_to_other() {
540 let s: Status = serde_json::from_value(json!("archived")).unwrap();
543 assert_eq!(s.as_other(), Some("archived"));
544 assert!(!s.is_active());
545 assert!(!s.is_retracted());
546 }
547
548 #[test]
549 fn ctx_id_authority() {
550 let id = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
551 assert_eq!(id.authority(), "registry.example.com");
552 }
553
554 #[test]
555 fn ctx_id_parse_valid() {
556 let id = CtxId::parse(
557 "acdp://registry.example.com/12345678-1234-4321-8123-123456781234".to_string(),
558 )
559 .unwrap();
560 assert_eq!(id.authority(), "registry.example.com");
561 assert!(id.uuid().is_some());
562 }
563
564 #[test]
565 fn ctx_id_parse_rejects_uppercase_authority() {
566 assert!(
567 CtxId::parse("acdp://Registry.Example.com/12345678-1234-4321-8123-123456781234")
568 .is_err()
569 );
570 }
571
572 #[test]
573 fn ctx_id_parse_rejects_non_v4_uuid() {
574 assert!(
576 CtxId::parse("acdp://registry.example.com/12345678-1234-1321-8123-123456781234")
577 .is_err()
578 );
579 }
580
581 #[test]
582 fn ctx_id_parse_rejects_bad_variant() {
583 assert!(
585 CtxId::parse("acdp://registry.example.com/12345678-1234-4321-0123-123456781234")
586 .is_err()
587 );
588 }
589
590 #[test]
591 fn lineage_id_parse() {
592 let l = LineageId::parse(
593 "lin:sha256:b14ccd2a8b34530309255db68c151a10689b6a82feb30aff9222d54fdd871720"
594 .to_string(),
595 )
596 .unwrap();
597 assert!(l.as_str().starts_with("lin:sha256:"));
598 assert!(LineageId::parse("lin:sha256:abc").is_err());
599 assert!(LineageId::parse(
600 "lin:sha256:B14CCD2A8B34530309255DB68C151A10689B6A82FEB30AFF9222D54FDD871720"
601 )
602 .is_err());
603 }
604
605 #[test]
606 fn content_hash_parse() {
607 ContentHash::parse(
608 "sha256:f170150ddbf59d99794e7797824591b374d459782084597b644ecc57a41031b5".to_string(),
609 )
610 .unwrap();
611 assert!(ContentHash::parse("md5:abc").is_err());
612 assert!(ContentHash::parse("sha256:zzzz").is_err());
613 }
614
615 #[test]
616 fn agent_did_parse_valid() {
617 AgentDid::parse("did:web:agents.example.com:test").unwrap();
618 AgentDid::parse("did:key:z6Mki...").unwrap();
619 }
620
621 #[test]
622 fn agent_did_parse_rejects_invalid_method() {
623 assert!(AgentDid::parse("did:WEB:agents.example.com").is_err());
624 assert!(AgentDid::parse("did::test").is_err());
625 assert!(AgentDid::parse("notadid").is_err());
626 }
627
628 #[test]
629 fn agent_did_parse_web_enforces_method() {
630 AgentDid::parse_web("did:web:agents.example.com:test").unwrap();
631 assert!(AgentDid::parse_web("did:key:z6Mki...").is_err());
632 }
633
634 #[test]
635 fn agent_did_new_skips_validation() {
636 let did = AgentDid::new("not-a-did");
638 assert_eq!(did.as_str(), "not-a-did");
639 }
640
641 #[test]
642 fn agent_did_parse_rejects_length_bounds() {
643 assert!(AgentDid::parse("did:w:").is_err(), "too short / empty id");
644 let long = format!("did:web:{}", "a".repeat(2100));
645 assert!(AgentDid::parse(long).is_err(), "over 2048 chars");
646 }
647
648 #[test]
651 fn context_type_known_values_round_trip() {
652 for (s, expect) in [
653 ("data_snapshot", ContextType::DataSnapshot),
654 ("analysis", ContextType::Analysis),
655 ("prediction", ContextType::Prediction),
656 ("alert", ContextType::Alert),
657 ("key-revocation", ContextType::KeyRevocation),
658 ] {
659 let parsed: ContextType = serde_json::from_value(json!(s)).unwrap();
660 assert_eq!(parsed, expect);
661 assert_eq!(serde_json::to_value(&parsed).unwrap(), json!(s));
662 }
663 }
664
665 #[test]
669 fn context_type_key_revocation_recognition() {
670 let standard: ContextType = serde_json::from_value(json!("key-revocation")).unwrap();
671 assert_eq!(standard, ContextType::KeyRevocation);
672 assert!(standard.is_key_revocation());
673
674 let interim: ContextType = serde_json::from_value(json!("acdp:key-revocation")).unwrap();
677 assert_eq!(
678 interim,
679 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into())
680 );
681 assert!(interim.is_key_revocation());
682
683 assert!(!ContextType::DataSnapshot.is_key_revocation());
684 assert!(!ContextType::Custom("acdp:key-rotation".into()).is_key_revocation());
685 }
686
687 #[test]
688 fn context_type_accepts_namespaced_custom() {
689 let parsed: ContextType =
690 serde_json::from_value(json!("finance:portfolio_snapshot")).unwrap();
691 assert_eq!(
692 parsed,
693 ContextType::Custom("finance:portfolio_snapshot".into())
694 );
695 assert_eq!(
697 serde_json::to_value(&parsed).unwrap(),
698 json!("finance:portfolio_snapshot")
699 );
700 }
701
702 #[test]
703 fn context_type_rejects_unnamespaced_unknown() {
704 for bad in [
706 "totally_unknown",
707 "Finance:x",
708 "finance:",
709 ":name",
710 "1ns:name",
711 ] {
712 let parsed: Result<ContextType, _> = serde_json::from_value(json!(bad));
713 assert!(parsed.is_err(), "context_type {bad:?} must be rejected");
714 }
715 }
716
717 #[test]
718 fn namespaced_context_type_helper_edges() {
719 assert!(is_namespaced_context_type("a:b"));
720 assert!(is_namespaced_context_type("ns1:name_2-x"));
721 assert!(!is_namespaced_context_type("nocolon"));
722 assert!(!is_namespaced_context_type("ns:Name")); assert!(!is_namespaced_context_type("ns:-bad")); assert!(!is_namespaced_context_type("ns:1bad")); }
726
727 #[test]
730 fn status_parse_rejects_pattern_violations() {
731 for bad in ["Active", "has space", "", "UPPER", "trailing!"] {
732 assert!(
733 Status::parse(bad).is_err(),
734 "status {bad:?} violates ^[a-z][a-z0-9_]*$ and must be rejected"
735 );
736 }
737 }
738
739 #[test]
740 fn status_deserialize_rejects_malformed() {
741 let parsed: Result<Status, _> = serde_json::from_value(json!("Active"));
743 assert!(parsed.is_err());
744 }
745
746 #[test]
747 fn status_known_or_active_degrades_unknown() {
748 let other = Status::Other("archived".into());
750 assert_eq!(other.known_or_active(), Status::Active);
751 assert_eq!(Status::Superseded.known_or_active(), Status::Superseded);
755 assert_eq!(Status::Expired.known_or_active(), Status::Expired);
756 assert_eq!(Status::Retracted.known_or_active(), Status::Retracted);
757 }
758
759 #[test]
760 fn status_as_str_matches_wire_form() {
761 assert_eq!(Status::Active.as_str(), "active");
762 assert_eq!(Status::Superseded.as_str(), "superseded");
763 assert_eq!(Status::Expired.as_str(), "expired");
764 assert_eq!(Status::Retracted.as_str(), "retracted");
765 assert_eq!(Status::Other("custom".into()).as_str(), "custom");
766 }
767
768 #[test]
771 fn visibility_round_trips_snake_case() {
772 for (s, expect) in [
773 ("public", Visibility::Public),
774 ("restricted", Visibility::Restricted),
775 ("private", Visibility::Private),
776 ] {
777 let parsed: Visibility = serde_json::from_value(json!(s)).unwrap();
778 assert_eq!(parsed, expect);
779 assert_eq!(serde_json::to_value(&parsed).unwrap(), json!(s));
780 }
781 assert!(serde_json::from_value::<Visibility>(json!("Public")).is_err());
782 }
783
784 #[test]
785 fn identifier_display_matches_inner_string() {
786 let ctx = "acdp://r.example.com/12345678-1234-4321-8123-123456781234";
787 assert_eq!(CtxId(ctx.into()).to_string(), ctx);
788 let lin = "lin:sha256:1111111111111111111111111111111111111111111111111111111111111111";
789 assert_eq!(LineageId(lin.into()).to_string(), lin);
790 let hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111";
791 assert_eq!(ContentHash(hash.into()).to_string(), hash);
792 assert_eq!(AgentDid::new("did:web:x").to_string(), "did:web:x");
793 }
794
795 #[test]
796 fn ctx_id_uuid_returns_none_for_malformed() {
797 assert!(CtxId("not-a-ctx-id".into()).uuid().is_none());
799 assert!(CtxId("acdp://host/not-a-uuid".into()).uuid().is_none());
800 }
801}