Skip to main content

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}