cloacina_workflow/context.rs
1/*
2 * Copyright 2025-2026 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//! # Minimal Context for Workflow Authoring
18//!
19//! This module provides a minimal `Context` type for sharing data between tasks.
20//! It contains only the core data operations without runtime-specific features
21//! like database persistence or dependency loading.
22
23use crate::error::ContextError;
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26use std::fmt::Debug;
27use tracing::{debug, warn};
28
29/// A context that holds data for pipeline execution.
30///
31/// The context is a type-safe, serializable container that flows through your pipeline,
32/// allowing tasks to share data. It supports JSON serialization and provides key-value
33/// access patterns with comprehensive error handling.
34///
35/// ## Type Parameter
36///
37/// - `T`: The type of values stored in the context. Must implement `Serialize`, `Deserialize`, and `Debug`.
38///
39/// ## Examples
40///
41/// ```rust
42/// use cloacina_workflow::Context;
43/// use serde_json::Value;
44///
45/// // Create a context for JSON values
46/// let mut context = Context::<Value>::new();
47///
48/// // Insert and retrieve data
49/// context.insert("user_id", serde_json::json!(123)).unwrap();
50/// let user_id = context.get("user_id").unwrap();
51/// ```
52#[derive(Debug)]
53pub struct Context<T = serde_json::Value>
54where
55 T: Serialize + for<'de> Deserialize<'de> + Debug,
56{
57 data: HashMap<String, T>,
58}
59
60impl<T> Context<T>
61where
62 T: Serialize + for<'de> Deserialize<'de> + Debug,
63{
64 /// Creates a new empty context.
65 ///
66 /// # Examples
67 ///
68 /// ```rust
69 /// use cloacina_workflow::Context;
70 ///
71 /// let context = Context::<i32>::new();
72 /// assert!(context.get("any_key").is_none());
73 /// ```
74 pub fn new() -> Self {
75 debug!("Creating new empty context");
76 Self {
77 data: HashMap::new(),
78 }
79 }
80
81 /// Creates a clone of this context's data.
82 ///
83 /// # Performance
84 ///
85 /// - Time complexity: O(n) where n is the number of key-value pairs
86 /// - Space complexity: O(n) for the cloned data
87 pub fn clone_data(&self) -> Self
88 where
89 T: Clone,
90 {
91 debug!("Cloning context data");
92 Self {
93 data: self.data.clone(),
94 }
95 }
96
97 /// Inserts a value into the context.
98 ///
99 /// # Arguments
100 ///
101 /// * `key` - The key to insert (can be any type that converts to String)
102 /// * `value` - The value to store
103 ///
104 /// # Returns
105 ///
106 /// * `Ok(())` - If the insertion was successful
107 /// * `Err(ContextError::KeyExists)` - If the key already exists
108 ///
109 /// # Examples
110 ///
111 /// ```rust
112 /// use cloacina_workflow::{Context, ContextError};
113 ///
114 /// let mut context = Context::<i32>::new();
115 ///
116 /// // First insertion succeeds
117 /// assert!(context.insert("count", 42).is_ok());
118 ///
119 /// // Duplicate insertion fails
120 /// assert!(matches!(context.insert("count", 43), Err(ContextError::KeyExists(_))));
121 /// ```
122 pub fn insert(&mut self, key: impl Into<String>, value: T) -> Result<(), ContextError> {
123 let key = key.into();
124 if self.data.contains_key(&key) {
125 warn!("Attempted to insert duplicate key: {}", key);
126 return Err(ContextError::KeyExists(key));
127 }
128 debug!("Inserting value for key: {}", key);
129 self.data.insert(key, value);
130 Ok(())
131 }
132
133 /// Updates an existing value in the context.
134 ///
135 /// # Arguments
136 ///
137 /// * `key` - The key to update
138 /// * `value` - The new value
139 ///
140 /// # Returns
141 ///
142 /// * `Ok(())` - If the update was successful
143 /// * `Err(ContextError::KeyNotFound)` - If the key doesn't exist
144 ///
145 /// # Examples
146 ///
147 /// ```rust
148 /// use cloacina_workflow::{Context, ContextError};
149 ///
150 /// let mut context = Context::<i32>::new();
151 /// context.insert("count", 42).unwrap();
152 ///
153 /// // Update existing key
154 /// assert!(context.update("count", 100).is_ok());
155 /// assert_eq!(context.get("count"), Some(&100));
156 ///
157 /// // Update non-existent key fails
158 /// assert!(matches!(context.update("missing", 1), Err(ContextError::KeyNotFound(_))));
159 /// ```
160 pub fn update(&mut self, key: impl Into<String>, value: T) -> Result<(), ContextError> {
161 let key = key.into();
162 if !self.data.contains_key(&key) {
163 warn!("Attempted to update non-existent key: {}", key);
164 return Err(ContextError::KeyNotFound(key));
165 }
166 debug!("Updating value for key: {}", key);
167 self.data.insert(key, value);
168 Ok(())
169 }
170
171 /// Gets a reference to a value from the context.
172 ///
173 /// # Arguments
174 ///
175 /// * `key` - The key to look up
176 ///
177 /// # Returns
178 ///
179 /// * `Some(&T)` - If the key exists
180 /// * `None` - If the key doesn't exist
181 ///
182 /// # Examples
183 ///
184 /// ```rust
185 /// use cloacina_workflow::Context;
186 ///
187 /// let mut context = Context::<String>::new();
188 /// context.insert("message", "Hello".to_string()).unwrap();
189 ///
190 /// assert_eq!(context.get("message"), Some(&"Hello".to_string()));
191 /// assert_eq!(context.get("missing"), None);
192 /// ```
193 pub fn get(&self, key: &str) -> Option<&T> {
194 debug!("Getting value for key: {}", key);
195 self.data.get(key)
196 }
197
198 /// Removes and returns a value from the context.
199 ///
200 /// # Arguments
201 ///
202 /// * `key` - The key to remove
203 ///
204 /// # Returns
205 ///
206 /// * `Some(T)` - If the key existed and was removed
207 /// * `None` - If the key didn't exist
208 ///
209 /// # Examples
210 ///
211 /// ```rust
212 /// use cloacina_workflow::Context;
213 ///
214 /// let mut context = Context::<i32>::new();
215 /// context.insert("temp", 42).unwrap();
216 ///
217 /// assert_eq!(context.remove("temp"), Some(42));
218 /// assert_eq!(context.get("temp"), None);
219 /// assert_eq!(context.remove("missing"), None);
220 /// ```
221 pub fn remove(&mut self, key: &str) -> Option<T> {
222 debug!("Removing value for key: {}", key);
223 self.data.remove(key)
224 }
225
226 /// Gets a reference to the underlying data HashMap.
227 ///
228 /// This method provides direct access to the internal data structure
229 /// for advanced use cases that need to iterate over all key-value pairs.
230 ///
231 /// # Returns
232 ///
233 /// A reference to the HashMap containing all context data
234 ///
235 /// # Examples
236 ///
237 /// ```rust
238 /// use cloacina_workflow::Context;
239 ///
240 /// let mut context = Context::<i32>::new();
241 /// context.insert("a", 1).unwrap();
242 /// context.insert("b", 2).unwrap();
243 ///
244 /// for (key, value) in context.data() {
245 /// println!("{}: {}", key, value);
246 /// }
247 /// ```
248 pub fn data(&self) -> &HashMap<String, T> {
249 &self.data
250 }
251
252 /// Consumes the context and returns the underlying data HashMap.
253 ///
254 /// # Returns
255 ///
256 /// The HashMap containing all context data
257 pub fn into_data(self) -> HashMap<String, T> {
258 self.data
259 }
260
261 /// Creates a Context from a HashMap.
262 ///
263 /// # Arguments
264 ///
265 /// * `data` - The HashMap to use as context data
266 ///
267 /// # Returns
268 ///
269 /// A new Context with the provided data
270 pub fn from_data(data: HashMap<String, T>) -> Self {
271 Self { data }
272 }
273
274 /// Serializes the context to a JSON string.
275 ///
276 /// # Returns
277 ///
278 /// * `Ok(String)` - The JSON representation of the context
279 /// * `Err(ContextError)` - If serialization fails
280 pub fn to_json(&self) -> Result<String, ContextError> {
281 debug!("Serializing context to JSON");
282 let json = serde_json::to_string(&self.data)?;
283 debug!("Context serialized successfully");
284 Ok(json)
285 }
286
287 /// Deserializes a context from a JSON string.
288 ///
289 /// # Arguments
290 ///
291 /// * `json` - The JSON string to deserialize
292 ///
293 /// # Returns
294 ///
295 /// * `Ok(Context<T>)` - The deserialized context
296 /// * `Err(ContextError)` - If deserialization fails
297 pub fn from_json(json: String) -> Result<Self, ContextError> {
298 debug!("Deserializing context from JSON");
299 let data = serde_json::from_str(&json)?;
300 debug!("Context deserialized successfully");
301 Ok(Self { data })
302 }
303}
304
305/// Typed accessors for the task context (`Context<serde_json::Value>`).
306///
307/// Task bodies operate on a `Context<serde_json::Value>`, so reading an input
308/// otherwise means `get(...).and_then(|v| v.as_*()).ok_or_else(...)?` plus a
309/// `serde_json::from_value` round-trip, and writing means wrapping every value
310/// in `serde_json::json!(...)`. These helpers fold that boilerplate and return
311/// a [`TaskError`] so they compose with `?` in a task body (CLOACI-T-0733).
312///
313/// This mirrors the ergonomics Python authors already get from
314/// `context.get(key, default)` / `context.set(key, value)`.
315impl Context<serde_json::Value> {
316 /// Get a value by key and deserialize it into `V`.
317 ///
318 /// Returns `Ok(None)` when the key is absent, `Ok(Some(value))` when it is
319 /// present and deserializes cleanly, and `Err(TaskError::ValidationFailed)`
320 /// when the stored JSON does not match `V` (the message names the key and
321 /// target type).
322 ///
323 /// # Examples
324 ///
325 /// ```rust
326 /// use cloacina_workflow::Context;
327 ///
328 /// let mut ctx = Context::new();
329 /// ctx.insert("count", serde_json::json!(7)).unwrap();
330 /// let n: Option<i64> = ctx.get_as("count").unwrap();
331 /// assert_eq!(n, Some(7));
332 /// assert_eq!(ctx.get_as::<i64>("missing").unwrap(), None);
333 /// ```
334 pub fn get_as<V>(&self, key: &str) -> Result<Option<V>, crate::error::TaskError>
335 where
336 V: serde::de::DeserializeOwned,
337 {
338 match self.data.get(key) {
339 None => Ok(None),
340 Some(value) => serde_json::from_value(value.clone())
341 .map(Some)
342 .map_err(|e| crate::error::TaskError::ValidationFailed {
343 message: format!(
344 "context key '{}' could not be read as {}: {}",
345 key,
346 std::any::type_name::<V>(),
347 e
348 ),
349 }),
350 }
351 }
352
353 /// Get a value by key, deserialize it into `V`, and error if the key is
354 /// missing.
355 ///
356 /// `Err(TaskError::ValidationFailed)` when the key is absent or the stored
357 /// JSON does not match `V`.
358 ///
359 /// # Examples
360 ///
361 /// ```rust
362 /// use cloacina_workflow::Context;
363 ///
364 /// let mut ctx = Context::new();
365 /// ctx.insert("name", serde_json::json!("ada")).unwrap();
366 /// let name: String = ctx.get_required("name").unwrap();
367 /// assert_eq!(name, "ada");
368 /// assert!(ctx.get_required::<String>("missing").is_err());
369 /// ```
370 pub fn get_required<V>(&self, key: &str) -> Result<V, crate::error::TaskError>
371 where
372 V: serde::de::DeserializeOwned,
373 {
374 match self.get_as(key)? {
375 Some(value) => Ok(value),
376 None => Err(crate::error::TaskError::ValidationFailed {
377 message: format!(
378 "required context key '{}' is missing (expected {})",
379 key,
380 std::any::type_name::<V>()
381 ),
382 }),
383 }
384 }
385
386 /// Serialize a value and write it under `key`, **upserting** (insert or
387 /// overwrite).
388 ///
389 /// Folds the `serde_json::json!(...)` / `to_value` wrapping — and the
390 /// "exists? update : insert" dance — that every context write otherwise
391 /// repeats. Upsert semantics mirror Python's `context.set(key, value)`
392 /// (unlike the lower-level [`Context::insert`], which errors on an existing
393 /// key). Errors with `TaskError::ValidationFailed` only if the value cannot
394 /// be serialized.
395 ///
396 /// # Examples
397 ///
398 /// ```rust
399 /// use cloacina_workflow::Context;
400 ///
401 /// let mut ctx = Context::new();
402 /// ctx.insert_as("total", 42u32).unwrap();
403 /// assert_eq!(ctx.get_as::<u32>("total").unwrap(), Some(42));
404 /// // Upserts — overwriting an existing key is fine.
405 /// ctx.insert_as("total", 100u32).unwrap();
406 /// assert_eq!(ctx.get_as::<u32>("total").unwrap(), Some(100));
407 /// ```
408 pub fn insert_as<V>(
409 &mut self,
410 key: impl Into<String>,
411 value: V,
412 ) -> Result<(), crate::error::TaskError>
413 where
414 V: serde::Serialize,
415 {
416 let key = key.into();
417 let json =
418 serde_json::to_value(value).map_err(|e| crate::error::TaskError::ValidationFailed {
419 message: format!("context key '{}' could not be serialized: {}", key, e),
420 })?;
421 // Upsert: overwrite if present, insert otherwise.
422 self.data.insert(key, json);
423 Ok(())
424 }
425}
426
427impl<T> Default for Context<T>
428where
429 T: Serialize + for<'de> Deserialize<'de> + Debug,
430{
431 fn default() -> Self {
432 Self::new()
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 fn setup_test_context() -> Context<i32> {
441 Context::new()
442 }
443
444 #[test]
445 fn test_context_operations() {
446 let mut context = setup_test_context();
447
448 // Test empty context
449 assert!(context.data.is_empty());
450
451 // Test insert and get
452 context.insert("test", 42).unwrap();
453 assert_eq!(context.get("test"), Some(&42));
454
455 // Test duplicate insert fails
456 assert!(matches!(
457 context.insert("test", 43),
458 Err(ContextError::KeyExists(_))
459 ));
460
461 // Test update
462 context.update("test", 43).unwrap();
463 assert_eq!(context.get("test"), Some(&43));
464
465 // Test update nonexistent key fails
466 assert!(matches!(
467 context.update("nonexistent", 42),
468 Err(ContextError::KeyNotFound(_))
469 ));
470 }
471
472 #[test]
473 fn test_context_serialization() {
474 let mut context = setup_test_context();
475 context.insert("test", 42).unwrap();
476
477 let json = context.to_json().unwrap();
478 let deserialized = Context::<i32>::from_json(json).unwrap();
479
480 assert_eq!(deserialized.get("test"), Some(&42));
481 }
482
483 #[test]
484 fn test_context_clone_data() {
485 let mut context = Context::<i32>::new();
486 context.insert("a", 1).unwrap();
487 context.insert("b", 2).unwrap();
488
489 let cloned = context.clone_data();
490 assert_eq!(cloned.get("a"), Some(&1));
491 assert_eq!(cloned.get("b"), Some(&2));
492 }
493
494 #[test]
495 fn test_context_from_data() {
496 let mut data = HashMap::new();
497 data.insert("key".to_string(), 42);
498
499 let context = Context::from_data(data);
500 assert_eq!(context.get("key"), Some(&42));
501 }
502
503 #[test]
504 fn test_context_into_data() {
505 let mut context = Context::<i32>::new();
506 context.insert("key", 42).unwrap();
507
508 let data = context.into_data();
509 assert_eq!(data.get("key"), Some(&42));
510 }
511
512 // CLOACI-T-0733: typed accessors on Context<serde_json::Value>.
513 #[test]
514 fn test_typed_accessors_roundtrip() {
515 let mut ctx = Context::new();
516 ctx.insert_as("count", 7u32).unwrap();
517 ctx.insert_as("name", "ada").unwrap();
518
519 // get_as: present + absent
520 assert_eq!(ctx.get_as::<u32>("count").unwrap(), Some(7));
521 assert_eq!(ctx.get_as::<String>("missing").unwrap(), None);
522
523 // get_required: present
524 let name: String = ctx.get_required("name").unwrap();
525 assert_eq!(name, "ada");
526
527 // insert_as upserts (overwrites) without erroring
528 ctx.insert_as("count", 100u32).unwrap();
529 assert_eq!(ctx.get_as::<u32>("count").unwrap(), Some(100));
530 }
531
532 #[test]
533 fn test_typed_accessor_errors_are_actionable() {
534 let mut ctx = Context::new();
535 ctx.insert("count", serde_json::json!("not-a-number"))
536 .unwrap();
537
538 // Type mismatch names the key and target type.
539 let err = ctx.get_as::<u32>("count").unwrap_err();
540 let msg = err.to_string();
541 assert!(msg.contains("count"), "msg should name the key: {msg}");
542
543 // Missing required key errors and names the key.
544 let err = ctx.get_required::<u32>("absent").unwrap_err();
545 let msg = err.to_string();
546 assert!(msg.contains("absent"), "msg should name the key: {msg}");
547 }
548}