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}