Skip to main content

actr_protocol/
actr_ext.rs

1//! Actor identity formatting and parsing utilities
2//!
3//! This module provides string formatting and parsing helpers for `ActrType` and `ActrId`.
4//! String forms are stable for logging, configuration, and CLI interactions.
5//!
6//! ## String formats
7//!
8//! | Type     | Format                                        | Example                      |
9//! |----------|-----------------------------------------------|------------------------------|
10//! | ActrType | `manufacturer:name:version` | `acme:echo-service:1.0.0` |
11//! | ActrId   | `<serial_hex>@<realm_id>/<actr_type>`         | `1a2b3c@101/acme:echo:1.0.0` |
12
13use crate::{ActrId, ActrType, Realm, name::Name};
14use std::str::FromStr;
15use thiserror::Error;
16
17/// Errors for actor identity parsing and formatting.
18///
19/// Covers only syntactic/structural validity of `ActrId` and `ActrType` strings.
20/// Runtime and RPC errors belong to `ActrIdError` in the `error` module.
21#[derive(Error, Debug, PartialEq, Eq)]
22pub enum ActrIdError {
23    #[error(
24        "Invalid Actor ID format: '{0}'. Expected: <serial_hex>@<realm_id>/<manufacturer>:<name>:<version>"
25    )]
26    InvalidFormat(String),
27
28    #[error("Invalid component in actor identity: {0}")]
29    InvalidComponent(String),
30
31    #[error("Invalid actor type format: '{0}'. Expected: <manufacturer>:<name>:<version>")]
32    InvalidTypeFormat(String),
33}
34
35impl ActrType {
36    /// Convert to stable string representation.
37    ///
38    /// Always returns `"manufacturer:name:version"`.
39    pub fn to_string_repr(&self) -> String {
40        debug_assert!(
41            !self.version.is_empty(),
42            "ActrType.version must be non-empty"
43        );
44
45        format!("{}:{}:{}", self.manufacturer, self.name, self.version)
46    }
47
48    /// Parse from string representation.
49    ///
50    /// Accepts:
51    /// - `"manufacturer:name:version"` — with version (required)
52    ///
53    /// Returns `Err(ActrIdError::InvalidTypeFormat)` if version is absent,
54    /// as `ActrType.version` is now a required field.
55    pub fn from_string_repr(s: &str) -> Result<Self, ActrIdError> {
56        // Require exactly 3 segments: manufacturer, name, version
57        let parts: Vec<&str> = s.splitn(4, ':').collect();
58        let (manufacturer, name, version) = match parts.as_slice() {
59            [_, _] => {
60                return Err(ActrIdError::InvalidTypeFormat(format!(
61                    "{s} (version is required, expected <manufacturer>:<name>:<version>)"
62                )));
63            }
64            [m, n, v] => (*m, *n, *v),
65            _ => return Err(ActrIdError::InvalidTypeFormat(s.to_string())),
66        };
67
68        Name::new(manufacturer.to_string())
69            .map_err(|e| ActrIdError::InvalidComponent(format!("Invalid manufacturer: {e}")))?;
70        Name::new(name.to_string())
71            .map_err(|e| ActrIdError::InvalidComponent(format!("Invalid type name: {e}")))?;
72        if version.is_empty() {
73            return Err(ActrIdError::InvalidComponent(
74                "Invalid version: version cannot be empty".to_string(),
75            ));
76        }
77
78        Ok(ActrType {
79            manufacturer: manufacturer.to_string(),
80            name: name.to_string(),
81            version: version.to_string(),
82        })
83    }
84}
85
86impl std::fmt::Display for ActrType {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        write!(f, "{}", self.to_string_repr())
89    }
90}
91
92impl ActrId {
93    /// Convert to `"<serial_hex>@<realm_id>/<actr_type>"`.
94    ///
95    /// The `/` separates the realm ID from the ActrType string, avoiding
96    /// ambiguity with the `:` separators used inside ActrType.
97    pub fn to_string_repr(&self) -> String {
98        format!(
99            "{:x}@{}/{}",
100            self.serial_number,
101            self.realm.realm_id,
102            self.r#type.to_string_repr()
103        )
104    }
105
106    /// Parse from string representation.
107    pub fn from_string_repr(s: &str) -> Result<Self, ActrIdError> {
108        // Format: "<serial_hex>@<realm_id>/<actr_type>"
109        let (serial_part, rest) = s
110            .split_once('@')
111            .ok_or_else(|| ActrIdError::InvalidFormat("Missing '@' separator".to_string()))?;
112
113        let serial_number = u64::from_str_radix(serial_part, 16).map_err(|_| {
114            ActrIdError::InvalidComponent(format!("Invalid serial number hex: {serial_part}"))
115        })?;
116
117        let (realm_part, type_part) = rest
118            .split_once('/')
119            .ok_or_else(|| ActrIdError::InvalidFormat("Missing '/' separator".to_string()))?;
120
121        let realm_id = u32::from_str(realm_part).map_err(|_| {
122            ActrIdError::InvalidComponent(format!("Invalid realm ID: {realm_part}"))
123        })?;
124
125        let actr_type = ActrType::from_string_repr(type_part)?;
126
127        Ok(ActrId {
128            realm: Realm { realm_id },
129            serial_number,
130            r#type: actr_type,
131        })
132    }
133}
134
135impl std::fmt::Display for ActrId {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        write!(f, "{}", self.to_string_repr())
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_actor_id_roundtrip_with_version() {
147        let original = ActrId {
148            realm: Realm { realm_id: 101 },
149            serial_number: 0x1a2b3c,
150            r#type: ActrType {
151                manufacturer: "acme".to_string(),
152                name: "echo-service".to_string(),
153                version: "1.0.0".to_string(),
154            },
155        };
156
157        let s = original.to_string_repr();
158        assert_eq!(s, "1a2b3c@101/acme:echo-service:1.0.0");
159
160        let parsed = ActrId::from_string_repr(&s).unwrap();
161        assert_eq!(parsed.realm.realm_id, original.realm.realm_id);
162        assert_eq!(parsed.serial_number, original.serial_number);
163        assert_eq!(parsed.r#type.manufacturer, original.r#type.manufacturer);
164        assert_eq!(parsed.r#type.name, original.r#type.name);
165        assert_eq!(parsed.r#type.version, original.r#type.version);
166    }
167
168    #[test]
169    fn test_actor_id_roundtrip_without_version_errors() {
170        // Parsing a string without version should now return an error
171        let result = ActrId::from_string_repr("1a2b3c@101/acme:echo-service");
172        assert!(
173            matches!(result, Err(ActrIdError::InvalidTypeFormat(_))),
174            "Expected InvalidTypeFormat error, got: {:?}",
175            result
176        );
177    }
178
179    #[test]
180    fn test_invalid_actor_id_format() {
181        assert!(matches!(
182            ActrId::from_string_repr("invalid-string"),
183            Err(ActrIdError::InvalidFormat(_))
184        ));
185        // Missing '/' between realm and type
186        assert!(matches!(
187            ActrId::from_string_repr("123@101"),
188            Err(ActrIdError::InvalidFormat(_))
189        ));
190        // Invalid hex serial
191        assert!(matches!(
192            ActrId::from_string_repr("xyz@101/acme:echo"),
193            Err(ActrIdError::InvalidComponent(_))
194        ));
195    }
196
197    #[test]
198    fn test_actr_type_roundtrip_with_version() {
199        let s = "acme:echo:1.2.3";
200        let ty = ActrType::from_string_repr(s).unwrap();
201        assert_eq!(ty.manufacturer, "acme");
202        assert_eq!(ty.name, "echo");
203        assert_eq!(ty.version.as_str(), "1.2.3");
204        assert_eq!(ty.to_string_repr(), s);
205    }
206
207    #[test]
208    fn test_actr_type_without_version_is_error() {
209        // Version is now required; two-segment strings must fail
210        let s = "acme:echo-service";
211        let result = ActrType::from_string_repr(s);
212        assert!(
213            matches!(result, Err(ActrIdError::InvalidTypeFormat(_))),
214            "Expected InvalidTypeFormat, got: {:?}",
215            result
216        );
217    }
218
219    #[test]
220    fn test_actr_type_invalid_format() {
221        // Single segment (no colon) — invalid
222        assert!(matches!(
223            ActrType::from_string_repr("acme-echo"),
224            Err(ActrIdError::InvalidTypeFormat(_))
225        ));
226        // Too many segments (4+)
227        assert!(matches!(
228            ActrType::from_string_repr("a:b:c:d"),
229            Err(ActrIdError::InvalidTypeFormat(_))
230        ));
231        // Invalid manufacturer
232        assert!(matches!(
233            ActrType::from_string_repr("1acme:echo:1.0.0"),
234            Err(ActrIdError::InvalidComponent(_))
235        ));
236        // Invalid name
237        assert!(matches!(
238            ActrType::from_string_repr("acme:echo!:1.0.0"),
239            Err(ActrIdError::InvalidComponent(_))
240        ));
241        assert!(matches!(
242            ActrType::from_string_repr("acme:echo:"),
243            Err(ActrIdError::InvalidComponent(_))
244        ));
245    }
246}