Skip to main content

cloacina_workflow/
namespace.rs

1/*
2 *  Copyright 2025 Colliery Software
3 *
4 *  Licensed under the Apache License, Version 2.0 (the "License");
5 *  you may not use this file except in compliance with the License.
6 *  You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 *  Unless required by applicable law or agreed to in writing, software
11 *  distributed under the License is distributed on an "AS IS" BASIS,
12 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 *  See the License for the specific language governing permissions and
14 *  limitations under the License.
15 */
16
17//! Task namespace management for isolated task execution.
18//!
19//! This module provides hierarchical namespace support for tasks, enabling:
20//! - Multi-tenant task isolation
21//! - Packaged workflow separation
22//! - Conflict resolution between workflows with same task IDs
23//!
24//! ## Namespace Format
25//!
26//! Namespaces follow the format: `tenant_id::package_name::workflow_id::task_id`
27//!
28//! - `tenant_id`: Default "public", can be tenant-specific for multi-tenancy
29//! - `package_name`: Default "embedded", or name from .so file metadata
30//! - `workflow_id`: From workflow! macro name field (required)
31//! - `task_id`: From #[task] macro id parameter (required)
32//!
33//! ## Examples
34//!
35//! ```rust
36//! use cloacina_workflow::TaskNamespace;
37//!
38//! // Embedded workflow (most common)
39//! let ns = TaskNamespace::new("public", "embedded", "customer_etl", "extract_data");
40//! assert_eq!(ns.to_string(), "public::embedded::customer_etl::extract_data");
41//!
42//! // Packaged workflow
43//! let ns = TaskNamespace::new("public", "analytics.so", "data_pipeline", "extract_data");
44//! assert_eq!(ns.to_string(), "public::analytics.so::data_pipeline::extract_data");
45//!
46//! // Multi-tenant scenario
47//! let ns = TaskNamespace::new("tenant_123", "embedded", "customer_etl", "extract_data");
48//! assert_eq!(ns.to_string(), "tenant_123::embedded::customer_etl::extract_data");
49//! ```
50
51use std::fmt::{Display, Formatter, Result as FmtResult};
52
53/// Hierarchical namespace for task identification and isolation.
54///
55/// Provides a structured way to identify tasks across different contexts:
56/// multi-tenant environments, packaged workflows, and embedded workflows.
57///
58/// The namespace components form a hierarchy from most general (tenant) to
59/// most specific (task), enabling precise task resolution while supporting
60/// fallback strategies for compatibility.
61#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
62pub struct TaskNamespace {
63    /// Tenant identifier for multi-tenancy support.
64    /// Default: "public" for single-tenant or public access
65    pub tenant_id: String,
66
67    /// Package or deployment context identifier.
68    /// Default: "embedded" for tasks compiled into the binary
69    /// For packaged workflows: name from .so file metadata
70    pub package_name: String,
71
72    /// Workflow identifier from workflow macro.
73    /// Groups related tasks together within a package/tenant
74    pub workflow_id: String,
75
76    /// Individual task identifier from task macro.
77    /// Unique within the workflow context
78    pub task_id: String,
79}
80
81impl TaskNamespace {
82    /// Create a complete namespace from all components.
83    ///
84    /// This is the most general constructor, useful when all namespace
85    /// components are known and need to be specified explicitly.
86    ///
87    /// # Arguments
88    ///
89    /// * `tenant_id` - Tenant identifier
90    /// * `package_name` - Package identifier
91    /// * `workflow_id` - Workflow identifier
92    /// * `task_id` - Task identifier
93    pub fn new(tenant_id: &str, package_name: &str, workflow_id: &str, task_id: &str) -> Self {
94        Self {
95            tenant_id: tenant_id.to_string(),
96            package_name: package_name.to_string(),
97            workflow_id: workflow_id.to_string(),
98            task_id: task_id.to_string(),
99        }
100    }
101
102    /// Create a TaskNamespace from a string representation.
103    ///
104    /// Parses a namespace string in the format "tenant::package::workflow::task"
105    /// into a TaskNamespace struct.
106    ///
107    /// # Arguments
108    ///
109    /// * `namespace_str` - String in format "tenant::package::workflow::task"
110    ///
111    /// # Returns
112    ///
113    /// * `Result<TaskNamespace, String>` - Successfully parsed namespace or error message
114    ///
115    /// # Examples
116    ///
117    /// ```rust
118    /// use cloacina_workflow::TaskNamespace;
119    ///
120    /// let ns = TaskNamespace::from_string("public::embedded::etl::extract").unwrap();
121    /// assert_eq!(ns.tenant_id, "public");
122    /// assert_eq!(ns.task_id, "extract");
123    ///
124    /// // Invalid format
125    /// assert!(TaskNamespace::from_string("invalid_format").is_err());
126    /// ```
127    pub fn from_string(namespace_str: &str) -> Result<Self, String> {
128        parse_namespace(namespace_str)
129    }
130
131    /// Check if this is a public (non-tenant-specific) namespace.
132    ///
133    /// # Returns
134    ///
135    /// `true` if this namespace uses the default "public" tenant
136    pub fn is_public(&self) -> bool {
137        self.tenant_id == "public"
138    }
139
140    /// Check if this is an embedded (non-packaged) namespace.
141    ///
142    /// # Returns
143    ///
144    /// `true` if this namespace uses the default "embedded" package
145    pub fn is_embedded(&self) -> bool {
146        self.package_name == "embedded"
147    }
148}
149
150impl Display for TaskNamespace {
151    /// Format the namespace as a string using the standard format.
152    ///
153    /// Format: `tenant_id::package_name::workflow_id::task_id`
154    ///
155    /// # Examples
156    ///
157    /// ```rust
158    /// use cloacina_workflow::TaskNamespace;
159    ///
160    /// let ns = TaskNamespace::new("public", "embedded", "etl", "extract");
161    /// assert_eq!(ns.to_string(), "public::embedded::etl::extract");
162    ///
163    /// let ns = TaskNamespace::new("tenant_1", "pkg.so", "analytics", "process");
164    /// assert_eq!(ns.to_string(), "tenant_1::pkg.so::analytics::process");
165    /// ```
166    fn fmt(&self, f: &mut Formatter) -> FmtResult {
167        write!(
168            f,
169            "{}::{}::{}::{}",
170            self.tenant_id, self.package_name, self.workflow_id, self.task_id
171        )
172    }
173}
174
175/// Parse a namespace string back into a TaskNamespace.
176///
177/// Supports parsing namespace strings in the standard format back into
178/// structured TaskNamespace objects.
179///
180/// # Arguments
181///
182/// * `namespace_str` - String in format "tenant::package::workflow::task"
183///
184/// # Returns
185///
186/// * `Ok(TaskNamespace)` - Successfully parsed namespace
187/// * `Err(String)` - Parse error message
188///
189/// # Examples
190///
191/// ```rust
192/// use cloacina_workflow::parse_namespace;
193///
194/// let ns = parse_namespace("public::embedded::etl::extract").unwrap();
195/// assert_eq!(ns.tenant_id, "public");
196/// assert_eq!(ns.task_id, "extract");
197///
198/// // Invalid format
199/// assert!(parse_namespace("invalid_format").is_err());
200/// ```
201pub fn parse_namespace(namespace_str: &str) -> Result<TaskNamespace, String> {
202    let parts: Vec<&str> = namespace_str.split("::").collect();
203
204    if parts.len() != 4 {
205        return Err(format!(
206            "Invalid namespace format '{}'. Expected 'tenant::package::workflow::task'",
207            namespace_str
208        ));
209    }
210
211    Ok(TaskNamespace::new(parts[0], parts[1], parts[2], parts[3]))
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_embedded_namespace() {
220        let ns = TaskNamespace::new("public", "embedded", "customer_etl", "extract_data");
221
222        assert_eq!(ns.tenant_id, "public");
223        assert_eq!(ns.package_name, "embedded");
224        assert_eq!(ns.workflow_id, "customer_etl");
225        assert_eq!(ns.task_id, "extract_data");
226
227        assert!(ns.is_public());
228        assert!(ns.is_embedded());
229    }
230
231    #[test]
232    fn test_packaged_namespace() {
233        let ns = TaskNamespace::new("public", "analytics.so", "data_pipeline", "extract_data");
234
235        assert_eq!(ns.tenant_id, "public");
236        assert_eq!(ns.package_name, "analytics.so");
237        assert_eq!(ns.workflow_id, "data_pipeline");
238        assert_eq!(ns.task_id, "extract_data");
239
240        assert!(ns.is_public());
241        assert!(!ns.is_embedded());
242    }
243
244    #[test]
245    fn test_tenant_namespace() {
246        let ns = TaskNamespace::new(
247            "customer_123",
248            "embedded",
249            "order_processing",
250            "validate_order",
251        );
252
253        assert_eq!(ns.tenant_id, "customer_123");
254        assert_eq!(ns.package_name, "embedded");
255        assert_eq!(ns.workflow_id, "order_processing");
256        assert_eq!(ns.task_id, "validate_order");
257
258        assert!(!ns.is_public());
259        assert!(ns.is_embedded());
260    }
261
262    #[test]
263    fn test_namespace_display() {
264        let ns = TaskNamespace::new("public", "embedded", "etl", "extract");
265        assert_eq!(ns.to_string(), "public::embedded::etl::extract");
266
267        let ns = TaskNamespace::new("tenant_1", "pkg.so", "analytics", "process");
268        assert_eq!(ns.to_string(), "tenant_1::pkg.so::analytics::process");
269    }
270
271    #[test]
272    fn test_namespace_equality_and_hashing() {
273        let ns1 = TaskNamespace::new("public", "embedded", "etl", "extract");
274        let ns2 = TaskNamespace::new("public", "embedded", "etl", "extract");
275        let ns3 = TaskNamespace::new("public", "embedded", "etl", "transform");
276
277        assert_eq!(ns1, ns2);
278        assert_ne!(ns1, ns3);
279
280        // Test that they can be used as HashMap keys
281        use std::collections::HashMap;
282        let mut map = HashMap::new();
283        map.insert(ns1.clone(), "task1");
284        map.insert(ns3.clone(), "task2");
285
286        assert_eq!(map.get(&ns2), Some(&"task1"));
287        assert_eq!(map.len(), 2);
288    }
289
290    #[test]
291    fn test_parse_namespace() {
292        let ns = parse_namespace("public::embedded::etl::extract").unwrap();
293        assert_eq!(ns.tenant_id, "public");
294        assert_eq!(ns.package_name, "embedded");
295        assert_eq!(ns.workflow_id, "etl");
296        assert_eq!(ns.task_id, "extract");
297
298        // Test invalid format
299        assert!(parse_namespace("invalid").is_err());
300        assert!(parse_namespace("a::b::c").is_err());
301        assert!(parse_namespace("a::b::c::d::e").is_err());
302    }
303
304    #[test]
305    fn test_from_string() {
306        let ns = TaskNamespace::from_string("tenant::pkg::wf::task").unwrap();
307        assert_eq!(ns.tenant_id, "tenant");
308        assert_eq!(ns.task_id, "task");
309
310        assert!(TaskNamespace::from_string("invalid").is_err());
311    }
312}