Skip to main content

aion_package/
namespace.rs

1//! Pure logical-name <-> deployed-name bijection for content-hash namespacing.
2
3use std::collections::BTreeSet;
4use std::str::FromStr;
5
6use crate::{BeamSet, ContentHash, hash::ContentHashParseError};
7
8/// Separator between a logical module name and its package content hash.
9///
10/// The `.aion` format mandates the literal `$` character so engine code can
11/// split deployed module names on the identical boundary. Gleam logical module
12/// names do not contain `$`, keeping valid workflow module names unambiguous.
13pub const DEPLOYED_NAME_SEPARATOR: char = '$';
14
15/// A deployed module name parsed into its logical module name and content hash.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct ParsedDeployedName {
18    logical: String,
19    hash: ContentHash,
20}
21
22impl ParsedDeployedName {
23    /// Creates a parsed deployed-name value from its two components.
24    #[must_use]
25    pub fn new(logical: String, hash: ContentHash) -> Self {
26        Self { logical, hash }
27    }
28
29    /// Returns the logical module name before deployment-time namespacing.
30    #[must_use]
31    pub fn logical(&self) -> &str {
32        &self.logical
33    }
34
35    /// Returns the content hash embedded in the deployed module name.
36    #[must_use]
37    pub const fn hash(&self) -> &ContentHash {
38        &self.hash
39    }
40
41    /// Consumes the parsed value into its owned components.
42    #[must_use]
43    pub fn into_parts(self) -> (String, ContentHash) {
44        (self.logical, self.hash)
45    }
46}
47
48/// Errors produced when parsing a deployed module name.
49#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
50pub enum NamespaceError {
51    /// The deployed module name did not contain the mandated separator.
52    #[error("deployed module name is missing the '$' namespace separator")]
53    MissingSeparator,
54
55    /// The logical module-name component was empty.
56    #[error("deployed module name has an empty logical module component")]
57    EmptyLogicalName,
58
59    /// The logical module-name component contained the mandated separator.
60    #[error(
61        "deployed module name has a logical module component containing the '$' namespace separator"
62    )]
63    SeparatorInLogicalName,
64
65    /// The hash component was not a valid content-hash textual form.
66    #[error("deployed module name has an invalid content hash: {source}")]
67    InvalidHash {
68        /// Hash parser error from the hash component.
69        source: ContentHashParseError,
70    },
71}
72
73/// Returns the deployed module name for a logical module in a package version.
74///
75/// This is the application-level versioning layer on top of beamr's VM-level
76/// dual-version hot-loading: the engine registers each workflow module under a
77/// logical-name plus content-hash name, allowing multiple immutable workflow
78/// versions to coexist without reusing the bare logical module atom.
79#[must_use]
80pub fn deployed_name(logical: &str, hash: &ContentHash) -> String {
81    format!("{logical}{DEPLOYED_NAME_SEPARATOR}{hash}")
82}
83
84/// Parses a deployed module name back into its logical module name and hash.
85///
86/// The parser accepts exactly one [`DEPLOYED_NAME_SEPARATOR`] boundary. Valid
87/// Gleam logical module names do not contain the separator, so any additional
88/// separator before the hash component is rejected as malformed rather than being
89/// silently folded into the logical name.
90///
91/// # Errors
92///
93/// Returns [`NamespaceError::MissingSeparator`] when the separator is absent,
94/// [`NamespaceError::EmptyLogicalName`] when the logical component is empty,
95/// [`NamespaceError::SeparatorInLogicalName`] when the logical component contains
96/// another separator, and [`NamespaceError::InvalidHash`] when the trailing
97/// component is not a valid [`ContentHash`] textual form.
98pub fn parse_deployed_name(deployed: &str) -> Result<ParsedDeployedName, NamespaceError> {
99    let Some((logical, hash_text)) = deployed.split_once(DEPLOYED_NAME_SEPARATOR) else {
100        return Err(NamespaceError::MissingSeparator);
101    };
102
103    if logical.is_empty() {
104        return Err(NamespaceError::EmptyLogicalName);
105    }
106    if hash_text.contains(DEPLOYED_NAME_SEPARATOR) {
107        return Err(NamespaceError::SeparatorInLogicalName);
108    }
109
110    let hash = ContentHash::from_str(hash_text)
111        .map_err(|source| NamespaceError::InvalidHash { source })?;
112
113    Ok(ParsedDeployedName::new(logical.to_owned(), hash))
114}
115
116/// Returns all deployed module names for a canonical beam set and package hash.
117///
118/// The returned set is the engine-ready registry name set for the package. The
119/// content-hash suffix is what sidesteps beamr's two-deep same-name version limit
120/// for workflow modules: different hashes produce disjoint deployed names even
121/// when the logical module names are identical.
122#[must_use]
123pub fn deployed_names(beams: &BeamSet, hash: &ContentHash) -> BTreeSet<String> {
124    beams
125        .iter()
126        .map(|module| deployed_name(module.name(), hash))
127        .collect()
128}
129
130#[cfg(test)]
131mod tests {
132    use std::collections::BTreeSet;
133
134    use super::{
135        DEPLOYED_NAME_SEPARATOR, NamespaceError, deployed_name, deployed_names, parse_deployed_name,
136    };
137    use crate::{BeamModule, BeamSet, ContentHash, hash::ContentHashParseError};
138
139    fn hash(byte: u8) -> ContentHash {
140        ContentHash::from_bytes([byte; 32])
141    }
142
143    fn beam_set() -> Result<BeamSet, crate::PackageError> {
144        BeamSet::new(vec![
145            BeamModule::new("workflow/b", vec![2]),
146            BeamModule::new("workflow/a", vec![1]),
147            BeamModule::new("stdlib/list", vec![3]),
148        ])
149    }
150
151    #[test]
152    fn forward_transform_uses_mandated_separator_and_hash_text() {
153        let hash = hash(0xab);
154        let deployed = deployed_name("order_workflow", &hash);
155
156        assert_eq!(
157            deployed,
158            "order_workflow$abababababababababababababababababababababababababababababababab"
159        );
160        assert!(deployed.contains(DEPLOYED_NAME_SEPARATOR));
161    }
162
163    #[test]
164    fn forward_then_inverse_round_trips_many_pairs() -> Result<(), NamespaceError> {
165        let cases = [
166            ("order_workflow", hash(0x00)),
167            ("workflow_with_underscores", hash(0x11)),
168            ("nested/module/name", hash(0x7f)),
169            ("workflow_123", hash(0xff)),
170        ];
171
172        for (logical, hash) in cases {
173            let parsed = parse_deployed_name(&deployed_name(logical, &hash))?;
174            assert_eq!(parsed.logical(), logical);
175            assert_eq!(parsed.hash(), &hash);
176        }
177
178        Ok(())
179    }
180
181    #[test]
182    fn inverse_then_forward_recovers_deployed_name() -> Result<(), NamespaceError> {
183        let original = deployed_name("workflow_with_underscores", &hash(0x42));
184        let parsed = parse_deployed_name(&original)?;
185        let recovered = deployed_name(parsed.logical(), parsed.hash());
186
187        assert_eq!(recovered, original);
188        Ok(())
189    }
190
191    #[test]
192    fn parse_preserves_separator_neighbouring_chars() -> Result<(), NamespaceError> {
193        let original = deployed_name("logical_name_with_underscores", &hash(0x33));
194        let parsed = parse_deployed_name(&original)?;
195
196        assert_eq!(parsed.logical(), "logical_name_with_underscores");
197        assert_eq!(deployed_name(parsed.logical(), parsed.hash()), original);
198        Ok(())
199    }
200
201    #[test]
202    fn malformed_deployed_names_return_typed_errors() {
203        assert_eq!(
204            parse_deployed_name("workflow_without_hash"),
205            Err(NamespaceError::MissingSeparator)
206        );
207        assert_eq!(
208            parse_deployed_name(
209                "$0000000000000000000000000000000000000000000000000000000000000000"
210            ),
211            Err(NamespaceError::EmptyLogicalName)
212        );
213        assert_eq!(
214            parse_deployed_name("workflow$not-a-hash"),
215            Err(NamespaceError::InvalidHash {
216                source: ContentHashParseError::InvalidLength { found: 10 }
217            })
218        );
219        assert_eq!(
220            parse_deployed_name(
221                "workflow$nested$0000000000000000000000000000000000000000000000000000000000000000"
222            ),
223            Err(NamespaceError::SeparatorInLogicalName)
224        );
225    }
226
227    #[test]
228    fn deployed_name_sets_for_different_hashes_are_disjoint() -> Result<(), crate::PackageError> {
229        let beams = beam_set()?;
230        let first = deployed_names(&beams, &hash(0x01));
231        let second = deployed_names(&beams, &hash(0x02));
232
233        assert!(first.is_disjoint(&second));
234        Ok(())
235    }
236
237    #[test]
238    fn same_logical_module_under_same_hash_is_idempotent() {
239        let hash = hash(0x55);
240
241        assert_eq!(
242            deployed_name("order_workflow", &hash),
243            deployed_name("order_workflow", &hash)
244        );
245    }
246
247    #[test]
248    fn deployed_names_follow_beam_set_canonical_order() -> Result<(), crate::PackageError> {
249        let beams = beam_set()?;
250        let names = deployed_names(&beams, &hash(0x09));
251        let expected = BTreeSet::from([
252            deployed_name("stdlib/list", &hash(0x09)),
253            deployed_name("workflow/a", &hash(0x09)),
254            deployed_name("workflow/b", &hash(0x09)),
255        ]);
256
257        assert_eq!(names, expected);
258        Ok(())
259    }
260}