use std::fmt;
use std::str::FromStr;
use serde_with::{DeserializeFromStr, SerializeDisplay};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)]
pub struct TypeUri {
authority: String,
urn: bool,
slug: String,
major: u32,
minor: u32,
variant: Option<Variant>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Variant {
Request,
Response,
}
impl Variant {
fn as_fragment(self) -> &'static str {
match self {
Variant::Request => "request",
Variant::Response => "response",
}
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ParseTypeUriError {
#[error("type URI must have a scheme other than http (SPEC §6.1): {0}")]
Scheme(String),
#[error(
"type URI must be <prefix>/spec/<slug>/<major.minor>, or \
urn:<nid>:<prefix>:spec:<slug-segments>:<major.minor>: {0}"
)]
Shape(String),
#[error("slug {0:?} does not satisfy SPEC §6.1 grammar")]
Slug(String),
#[error("slug {0:?} is reserved per SPEC §6.1")]
ReservedSlug(String),
#[error("version {0:?} must be MAJOR.MINOR per SPEC §5.1")]
Version(String),
#[error("only #request and #response fragments are permitted (got #{0})")]
Fragment(String),
}
impl TypeUri {
pub fn canonical(
slug: impl Into<String>,
major: u32,
minor: u32,
) -> Result<Self, ParseTypeUriError> {
Self::new("https://trusttasks.org", slug, major, minor, None)
}
pub fn new(
authority: impl Into<String>,
slug: impl Into<String>,
major: u32,
minor: u32,
variant: Option<Variant>,
) -> Result<Self, ParseTypeUriError> {
let slug = slug.into();
validate_slug(&slug)?;
Ok(Self {
authority: authority.into().trim_end_matches('/').to_string(),
urn: false,
slug,
major,
minor,
variant,
})
}
pub fn new_urn(
prefix: impl Into<String>,
slug: impl Into<String>,
major: u32,
minor: u32,
variant: Option<Variant>,
) -> Result<Self, ParseTypeUriError> {
let slug = slug.into();
validate_slug(&slug)?;
Ok(Self {
authority: prefix.into().trim_end_matches(':').to_string(),
urn: true,
slug,
major,
minor,
variant,
})
}
pub fn slug(&self) -> &str {
&self.slug
}
pub fn major(&self) -> u32 {
self.major
}
pub fn minor(&self) -> u32 {
self.minor
}
pub fn variant(&self) -> Option<Variant> {
self.variant
}
pub fn is_response(&self) -> bool {
self.variant == Some(Variant::Response)
}
pub fn bare(&self) -> Self {
self.with_variant(None)
}
pub fn for_routing(&self) -> Self {
match self.variant {
Some(Variant::Request) | None => self.with_variant(None),
Some(Variant::Response) => self.clone(),
}
}
pub fn with_response(&self) -> Self {
self.with_variant(Some(Variant::Response))
}
pub fn with_request(&self) -> Self {
self.with_variant(Some(Variant::Request))
}
pub fn with_variant(&self, v: Option<Variant>) -> Self {
Self {
variant: v,
..self.clone()
}
}
pub(crate) fn specification_key(&self) -> String {
let mut bare = self.with_variant(None);
bare.major = 0;
bare.minor = 0;
bare.to_string()
}
pub(crate) fn with_version(&self, major: u32, minor: u32) -> Self {
Self {
major,
minor,
..self.clone()
}
}
}
impl fmt::Display for TypeUri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.urn {
write!(
f,
"{}:spec:{}:{}.{}",
self.authority,
self.slug.replace('/', ":"),
self.major,
self.minor
)?;
} else {
write!(
f,
"{}/spec/{}/{}.{}",
self.authority, self.slug, self.major, self.minor
)?;
}
if let Some(v) = self.variant {
write!(f, "#{}", v.as_fragment())?;
}
Ok(())
}
}
impl FromStr for TypeUri {
type Err = ParseTypeUriError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (without_fragment, fragment) = match s.split_once('#') {
Some((u, f)) => (u, Some(f)),
None => (s, None),
};
let variant = match fragment {
None => None,
Some("request") => Some(Variant::Request),
Some("response") => Some(Variant::Response),
Some(other) => return Err(ParseTypeUriError::Fragment(other.to_string())),
};
let (scheme, _) = without_fragment
.split_once(':')
.ok_or_else(|| ParseTypeUriError::Scheme(s.to_string()))?;
let scheme_ok = scheme
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
if !scheme_ok || scheme.eq_ignore_ascii_case("http") {
return Err(ParseTypeUriError::Scheme(s.to_string()));
}
let shape = || ParseTypeUriError::Shape(s.to_string());
let (authority, slug, version, urn) = if scheme.eq_ignore_ascii_case("urn") {
let (prefix, rest) = without_fragment.split_once(":spec:").ok_or_else(shape)?;
let mut parts = prefix.splitn(3, ':');
let (_, nid, ns) = (parts.next(), parts.next(), parts.next());
if nid.is_none_or(str::is_empty) || ns.is_none_or(str::is_empty) {
return Err(shape());
}
if rest.contains('/') {
return Err(shape());
}
let (slug, version) = rest.rsplit_once(':').ok_or_else(shape)?;
(prefix, slug.replace(':', "/"), version, true)
} else {
let spec_idx = without_fragment.find("/spec/").ok_or_else(shape)?;
let authority = &without_fragment[..spec_idx];
let after_scheme = &authority[scheme.len() + 1..];
if after_scheme.trim_start_matches('/').is_empty()
|| (scheme.eq_ignore_ascii_case("https") && !after_scheme.starts_with("//"))
{
return Err(shape());
}
let rest = &without_fragment[spec_idx + "/spec/".len()..];
let (slug, version) = rest.rsplit_once('/').ok_or_else(shape)?;
(authority, slug.to_string(), version, false)
};
validate_slug(&slug)?;
let (major, minor) = parse_version(version)?;
Ok(Self {
authority: authority.to_string(),
urn,
slug,
major,
minor,
variant,
})
}
}
fn parse_version(s: &str) -> Result<(u32, u32), ParseTypeUriError> {
let (maj, min) = s
.split_once('.')
.ok_or_else(|| ParseTypeUriError::Version(s.to_string()))?;
if has_leading_zero(maj) || has_leading_zero(min) {
return Err(ParseTypeUriError::Version(s.to_string()));
}
let major = maj
.parse::<u32>()
.map_err(|_| ParseTypeUriError::Version(s.to_string()))?;
let minor = min
.parse::<u32>()
.map_err(|_| ParseTypeUriError::Version(s.to_string()))?;
Ok((major, minor))
}
fn has_leading_zero(s: &str) -> bool {
s.len() > 1 && s.starts_with('0')
}
fn validate_slug(slug: &str) -> Result<(), ParseTypeUriError> {
if slug.is_empty() {
return Err(ParseTypeUriError::Slug(slug.to_string()));
}
for segment in slug.split('/') {
validate_segment(segment).ok_or_else(|| ParseTypeUriError::Slug(slug.to_string()))?;
}
if is_reserved_namespace(slug) && !is_allowed_framework_slug(slug) {
return Err(ParseTypeUriError::ReservedSlug(slug.to_string()));
}
Ok(())
}
fn validate_segment(seg: &str) -> Option<()> {
let mut chars = seg.chars();
let first = chars.next()?;
if !first.is_ascii_lowercase() {
return None;
}
let mut prev_hyphen = false;
for c in chars {
match c {
'a'..='z' | '0'..='9' => prev_hyphen = false,
'-' => {
if prev_hyphen {
return None;
}
prev_hyphen = true;
}
_ => return None,
}
}
if prev_hyphen {
return None;
}
Some(())
}
fn is_reserved_namespace(slug: &str) -> bool {
let first = slug.split('/').next().unwrap_or("");
for stem in ["trust-task", "trust-ceremony"] {
if first == stem || first.starts_with(&format!("{stem}-")) {
return true;
}
}
false
}
fn is_allowed_framework_slug(slug: &str) -> bool {
matches!(
slug,
"trust-task"
| "trust-task-error"
| "trust-task-ok"
| "trust-task-next-step"
| "trust-task-discovery"
| "trust-task-control"
| "trust-ceremony-receipt"
)
}
#[cfg(test)]
mod reserved_slug_conformance {
use super::*;
#[test]
fn both_halves_of_the_reservation_are_enforced() {
for slug in [
"trust-task",
"trust-task-error",
"trust-task-anything",
"trust-ceremony",
"trust-ceremony-receipt",
"trust-ceremony-anything",
"trust-task/nested",
] {
assert!(is_reserved_namespace(slug), "{slug} is reserved by §6.1");
}
for slug in ["acl/grant", "trust-tasks", "trusty-task", "vault/get"] {
assert!(!is_reserved_namespace(slug), "{slug} is not reserved");
}
}
#[test]
fn published_framework_type_uris_parse() {
for slug in [
"trust-task-error",
"trust-task-ok",
"trust-task-next-step",
"trust-task-discovery",
"trust-task-control",
"trust-ceremony-receipt",
] {
let uri = format!("https://trusttasks.org/spec/{slug}/0.1");
assert!(
uri.parse::<TypeUri>().is_ok(),
"{slug} is published by the framework and must parse"
);
}
}
#[test]
fn an_unclaimed_reserved_slug_is_refused() {
for slug in ["trust-task-evil", "trust-ceremony-evil"] {
let uri = format!("https://trusttasks.org/spec/{slug}/0.1");
assert!(
uri.parse::<TypeUri>().is_err(),
"{slug} sits inside the §6.1 reservation and is not framework-published"
);
}
}
#[test]
fn reserved_allowlist_matches_the_meta_schema() {
let meta =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../specs/spec.meta.schema.json");
let Ok(raw) = std::fs::read_to_string(&meta) else {
return;
};
let json: serde_json::Value = serde_json::from_str(&raw).expect("meta-schema parses");
let listed = json["properties"]["slug"]["anyOf"][1]["enum"]
.as_array()
.expect("reserved-slug allowlist is an array");
for v in listed {
let slug = v.as_str().expect("allowlist entries are strings");
assert!(
is_allowed_framework_slug(slug),
"{slug} is allowlisted by specs/spec.meta.schema.json but not by \
is_allowed_framework_slug — the two copies have drifted"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_canonical_form() {
let uri: TypeUri = "https://trusttasks.org/spec/trust-task-error/0.5"
.parse()
.unwrap();
assert_eq!(uri.slug(), "trust-task-error");
assert_eq!(uri.major(), 0);
assert_eq!(uri.minor(), 5);
assert_eq!(uri.variant(), None);
assert_eq!(
uri.to_string(),
"https://trusttasks.org/spec/trust-task-error/0.5"
);
}
#[test]
fn parses_hierarchical_slug() {
let uri: TypeUri = "https://trusttasks.org/spec/acl/grant/0.1".parse().unwrap();
assert_eq!(uri.slug(), "acl/grant");
assert_eq!(uri.major(), 0);
assert_eq!(uri.minor(), 1);
}
#[test]
fn parses_request_and_response_fragments() {
let req: TypeUri = "https://trusttasks.org/spec/acl/grant/0.1#request"
.parse()
.unwrap();
assert_eq!(req.variant(), Some(Variant::Request));
let resp: TypeUri = "https://trusttasks.org/spec/acl/grant/0.1#response"
.parse()
.unwrap();
assert_eq!(resp.variant(), Some(Variant::Response));
assert!(resp.is_response());
assert_eq!(
resp.bare().to_string(),
"https://trusttasks.org/spec/acl/grant/0.1"
);
}
#[test]
fn rejects_http_scheme() {
for uri in [
"http://trusttasks.org/spec/acl/grant/0.1",
"HTTP://example.com/spec/acl/grant/0.1",
"http://example.com/x/spec/acl/grant/0.1#response",
] {
let err = uri.parse::<TypeUri>().unwrap_err();
assert!(matches!(err, ParseTypeUriError::Scheme(_)), "{uri}");
}
}
#[test]
fn every_spec_6_1_form_round_trips() {
for (uri, slug) in [
("https://trusttasks.org/spec/acl/grant/0.1", "acl/grant"),
(
"https://example.com/trust-tasks/spec/acl/grant/0.1",
"acl/grant",
),
(
"did:example:123456789abcdefghi/spec/acl/grant/0.1",
"acl/grant",
),
("did:web:tasks.example.com:x/spec/pay/0.1", "pay"),
("urn:example:tasks:spec:acl:grant:0.1", "acl/grant"),
("urn:example:tasks:sub:spec:pay:3.10", "pay"),
("tag:example.com,2026:tasks/spec/acl/grant/0.1", "acl/grant"),
] {
for fragment in ["", "#request", "#response"] {
let s = format!("{uri}{fragment}");
let parsed: TypeUri = s.parse().unwrap_or_else(|e| panic!("{s}: {e}"));
assert_eq!(parsed.slug(), slug, "{s}");
assert_eq!(parsed.to_string(), s);
assert_eq!(parsed.bare().to_string(), uri);
let json = serde_json::to_string(&parsed).unwrap();
assert_eq!(serde_json::from_str::<TypeUri>(&json).unwrap(), parsed);
}
}
let urn: TypeUri = "urn:example:tasks:spec:acl:grant:0.1".parse().unwrap();
assert_eq!((urn.major(), urn.minor()), (0, 1));
assert_eq!(
urn.with_response().to_string(),
"urn:example:tasks:spec:acl:grant:0.1#response"
);
assert_eq!(
urn.with_version(0, 2).to_string(),
"urn:example:tasks:spec:acl:grant:0.2"
);
assert_eq!(
TypeUri::new_urn("urn:example:tasks", "acl/grant", 0, 1, None)
.unwrap()
.to_string(),
"urn:example:tasks:spec:acl:grant:0.1"
);
}
#[test]
fn the_authority_is_part_of_identity() {
let a: TypeUri = "https://trusttasks.org/spec/vault/get/0.3".parse().unwrap();
let b: TypeUri = "https://tasks.example.com/spec/vault/get/0.3"
.parse()
.unwrap();
let c: TypeUri = "urn:example:tasks:spec:vault:get:0.3".parse().unwrap();
let d: TypeUri = "did:web:tasks.example.com/spec/vault/get/0.3"
.parse()
.unwrap();
for (x, y) in [(&a, &b), (&a, &c), (&b, &c), (&b, &d), (&c, &d)] {
assert_eq!(x.slug(), y.slug());
assert_ne!(x, y);
assert_ne!(x.for_routing(), y.for_routing());
}
}
#[test]
fn malformed_forms_are_refused() {
for (uri, scheme_error) in [
("trusttasks.org/spec/acl/grant/0.1", true),
("1https://example.com/spec/a/0.1", true),
("https:/spec/a/0.1", false),
("https:///spec/a/0.1", false),
("https:example.com/spec/a/0.1", false),
("did:/spec/a/0.1", false),
("urn:example:spec:a:0.1", false),
("urn::tasks:spec:a:0.1", false),
("urn:example:tasks:spec:acl/grant:0.1", false),
("urn:example:tasks/spec/acl/grant/0.1", false),
("did:example:123", false),
] {
let err = uri.parse::<TypeUri>().unwrap_err();
assert_eq!(
matches!(err, ParseTypeUriError::Scheme(_)),
scheme_error,
"{uri}: {err:?}"
);
}
assert!(matches!(
"urn:example:tasks:spec:Acl:0.1".parse::<TypeUri>(),
Err(ParseTypeUriError::Slug(_))
));
assert!(matches!(
"urn:example:tasks:spec:acl:01.0".parse::<TypeUri>(),
Err(ParseTypeUriError::Version(_))
));
assert!(matches!(
"urn:example:tasks:spec:trust-task-custom:0.1".parse::<TypeUri>(),
Err(ParseTypeUriError::ReservedSlug(_))
));
}
#[test]
fn rejects_unknown_fragment() {
let err = "https://trusttasks.org/spec/acl/grant/0.1#cancel"
.parse::<TypeUri>()
.unwrap_err();
assert!(matches!(err, ParseTypeUriError::Fragment(s) if s == "cancel"));
}
#[test]
fn rejects_reserved_slug() {
let err = "https://example.com/spec/trust-task-custom/0.1"
.parse::<TypeUri>()
.unwrap_err();
assert!(matches!(err, ParseTypeUriError::ReservedSlug(_)));
}
#[test]
fn allows_framework_slug() {
let uri: TypeUri = "https://trusttasks.org/spec/trust-task-error/0.1"
.parse()
.unwrap();
assert_eq!(uri.slug(), "trust-task-error");
}
#[test]
fn accepts_private_authority() {
let uri: TypeUri = "https://example.com/trust-tasks/spec/my-task/0.1"
.parse()
.unwrap();
assert_eq!(uri.slug(), "my-task");
assert_eq!(
uri.to_string(),
"https://example.com/trust-tasks/spec/my-task/0.1"
);
}
#[test]
fn rejects_bad_slug() {
for bad in [
"Acl", "acl--bad", "-leading",
"trailing-",
"1numeric-start",
"with_underscore",
] {
let s = format!("https://trusttasks.org/spec/{bad}/1.0");
let err = s.parse::<TypeUri>().unwrap_err();
assert!(matches!(err, ParseTypeUriError::Slug(_)), "{bad}: {err:?}");
}
}
#[test]
fn rejects_bad_version() {
for bad in ["01.0", "1.01", "1", "1.0.0", "a.b"] {
let s = format!("https://trusttasks.org/spec/x/{bad}");
let err = s.parse::<TypeUri>().unwrap_err();
assert!(
matches!(err, ParseTypeUriError::Version(_)),
"{bad}: {err:?}"
);
}
}
#[test]
fn round_trips_via_serde() {
let uri: TypeUri = "https://trusttasks.org/spec/acl/change-role/0.1#response"
.parse()
.unwrap();
let json = serde_json::to_string(&uri).unwrap();
assert_eq!(
json,
"\"https://trusttasks.org/spec/acl/change-role/0.1#response\""
);
let back: TypeUri = serde_json::from_str(&json).unwrap();
assert_eq!(back, uri);
}
}