Skip to main content

postproject_core/
identifier.rs

1//! External, industry, and application identifier values.
2
3use crate::{Error, ErrorKind, Result};
4
5/// Maximum UTF-8 byte length of an external identifier scheme.
6pub const MAX_IDENTIFIER_SCHEME_BYTES: usize = 255;
7
8/// Maximum UTF-8 byte length of an external identifier value.
9pub const MAX_IDENTIFIER_VALUE_BYTES: usize = 4096;
10
11/// Maximum UTF-8 byte length of an optional identifier qualifier.
12pub const MAX_IDENTIFIER_QUALIFIER_BYTES: usize = 1024;
13
14/// An extensible scheme name for an external identifier.
15///
16/// Schemes are deliberately strings rather than a closed enum. Core performs
17/// only bounded, transport-safe validation; standards-specific validation is a
18/// separate opt-in concern.
19#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
20pub struct IdentifierScheme(String);
21
22impl IdentifierScheme {
23    /// Creates a scheme while preserving its exact spelling.
24    ///
25    /// # Errors
26    ///
27    /// Returns [`ErrorKind::InvalidArgument`] when `value` is empty, exceeds
28    /// [`MAX_IDENTIFIER_SCHEME_BYTES`], or contains whitespace/control bytes.
29    pub fn new(value: impl Into<String>) -> Result<Self> {
30        let value = value.into();
31        if value.is_empty() || value.len() > MAX_IDENTIFIER_SCHEME_BYTES {
32            return Err(Error::new(
33                ErrorKind::InvalidArgument,
34                format!(
35                    "identifier scheme must contain 1-{MAX_IDENTIFIER_SCHEME_BYTES} UTF-8 bytes"
36                ),
37            ));
38        }
39        if value.chars().any(char::is_whitespace) || value.chars().any(char::is_control) {
40            return Err(Error::new(
41                ErrorKind::InvalidArgument,
42                "identifier scheme must not contain whitespace or control characters",
43            ));
44        }
45        Ok(Self(value))
46    }
47
48    /// Returns the exact scheme supplied by the caller.
49    #[must_use]
50    pub fn as_str(&self) -> &str {
51        &self.0
52    }
53
54    /// Consumes the scheme and returns its owned string.
55    #[must_use]
56    pub fn into_string(self) -> String {
57        self.0
58    }
59}
60
61/// An identifier assigned by an external standard, vendor, or application.
62///
63/// This value does not replace a `PostProject` object ID. It is opaque to core
64/// unless a caller explicitly applies a scheme-specific validator.
65#[derive(Clone, Debug, Eq, Hash, PartialEq)]
66pub struct ExternalIdentifier {
67    scheme: IdentifierScheme,
68    value: String,
69    qualifier: Option<String>,
70}
71
72impl ExternalIdentifier {
73    /// Creates an external identifier without normalizing its value.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`ErrorKind::InvalidArgument`] when the value or qualifier is
78    /// empty when present, or exceeds its documented byte limit.
79    pub fn new(
80        scheme: IdentifierScheme,
81        value: impl Into<String>,
82        qualifier: Option<String>,
83    ) -> Result<Self> {
84        let value = value.into();
85        validate_bounded_text("identifier value", &value, MAX_IDENTIFIER_VALUE_BYTES)?;
86        if let Some(qualifier) = qualifier.as_deref() {
87            validate_bounded_text(
88                "identifier qualifier",
89                qualifier,
90                MAX_IDENTIFIER_QUALIFIER_BYTES,
91            )?;
92        }
93        Ok(Self {
94            scheme,
95            value,
96            qualifier,
97        })
98    }
99
100    /// Returns the identifier scheme.
101    #[must_use]
102    pub const fn scheme(&self) -> &IdentifierScheme {
103        &self.scheme
104    }
105
106    /// Returns the exact, opaque identifier value.
107    #[must_use]
108    pub fn value(&self) -> &str {
109        &self.value
110    }
111
112    /// Returns the optional exact scope or qualifier.
113    #[must_use]
114    pub fn qualifier(&self) -> Option<&str> {
115        self.qualifier.as_deref()
116    }
117}
118
119fn validate_bounded_text(label: &str, value: &str, maximum: usize) -> Result<()> {
120    if value.is_empty() || value.len() > maximum || value.contains('\0') {
121        return Err(Error::new(
122            ErrorKind::InvalidArgument,
123            format!("{label} must contain 1-{maximum} UTF-8 bytes without NUL"),
124        ));
125    }
126    Ok(())
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn unknown_scheme_and_value_round_trip_exactly() {
135        let scheme = IdentifierScheme::new("com.example.camera.serial").expect("valid scheme");
136        let identifier = ExternalIdentifier::new(
137            scheme,
138            "  Vendor Value 01  ",
139            Some("camera-body".to_owned()),
140        )
141        .expect("valid identifier");
142
143        assert_eq!(identifier.scheme().as_str(), "com.example.camera.serial");
144        assert_eq!(identifier.value(), "  Vendor Value 01  ");
145        assert_eq!(identifier.qualifier(), Some("camera-body"));
146    }
147
148    #[test]
149    fn same_scheme_can_describe_multiple_values() {
150        let scheme = IdentifierScheme::new("urn:smpte:umid").expect("valid scheme");
151        let first = ExternalIdentifier::new(scheme.clone(), "first", None).expect("valid id");
152        let second = ExternalIdentifier::new(scheme, "second", None).expect("valid id");
153
154        assert_ne!(first, second);
155    }
156
157    #[test]
158    fn generic_validation_rejects_unbounded_or_ambiguous_scheme_text() {
159        for invalid in ["", "urn:example:has space", "urn:example:\ncontrol"] {
160            let error = IdentifierScheme::new(invalid).expect_err("scheme must fail");
161            assert_eq!(error.kind(), ErrorKind::InvalidArgument);
162        }
163
164        let oversized = "s".repeat(MAX_IDENTIFIER_SCHEME_BYTES + 1);
165        assert_eq!(
166            IdentifierScheme::new(oversized)
167                .expect_err("oversized scheme must fail")
168                .kind(),
169            ErrorKind::InvalidArgument
170        );
171    }
172
173    #[test]
174    fn generic_validation_bounds_values_and_qualifiers() {
175        let scheme = IdentifierScheme::new("com.example.id").expect("valid scheme");
176        assert_eq!(
177            ExternalIdentifier::new(scheme.clone(), "", None)
178                .expect_err("empty value must fail")
179                .kind(),
180            ErrorKind::InvalidArgument
181        );
182        assert_eq!(
183            ExternalIdentifier::new(scheme, "value", Some(String::new()))
184                .expect_err("empty qualifier must fail")
185                .kind(),
186            ErrorKind::InvalidArgument
187        );
188        let scheme = IdentifierScheme::new("com.example.id").expect("valid scheme");
189        assert_eq!(
190            ExternalIdentifier::new(scheme, "value\0suffix", None)
191                .expect_err("NUL-containing value must fail")
192                .kind(),
193            ErrorKind::InvalidArgument
194        );
195    }
196}