Skip to main content

metalcraft_flows/
state.rs

1//! The running `variables` state threaded through a flow, plus dotted-path
2//! helpers shared with [`crate::template`].
3//!
4//! State is a single JSON object. Nodes read and write named variables; the
5//! reserved keys are:
6//!
7//! - `_last` — the payload of the edge just traversed into the current node
8//!   (its typed input).
9//! - `_inputs` — an immutable copy of the entry inputs the run was seeded with.
10//! - `_run` — run metadata (reserved).
11
12use crate::nodes::InputSpec;
13use serde_json::{Map, Value};
14use std::collections::BTreeMap;
15
16/// Look up a dotted path (`"a.b.c"`) within a JSON value.
17///
18/// An empty path (or `"."`) returns the root. Returns `None` if any segment is
19/// missing or traverses a non-object.
20pub fn lookup_path<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
21    let path = path.trim();
22    if path.is_empty() || path == "." {
23        return Some(root);
24    }
25    let mut cur = root;
26    for seg in path.split('.') {
27        if seg.is_empty() {
28            continue;
29        }
30        cur = cur.get(seg)?;
31    }
32    Some(cur)
33}
34
35/// The mutable variable bag for one flow run.
36#[derive(Debug, Clone, Default, PartialEq)]
37pub struct Variables {
38    root: Value,
39}
40
41impl Variables {
42    /// An empty state (`{}`).
43    pub fn new() -> Self {
44        Self { root: Value::Object(Map::new()) }
45    }
46
47    /// Wrap an existing JSON object as state. A non-object is replaced by `{}`.
48    pub fn from_value(value: Value) -> Self {
49        if value.is_object() {
50            Self { root: value }
51        } else {
52            Self::new()
53        }
54    }
55
56    /// Seed state from an entry node's declared `inputs` and the caller-supplied
57    /// argument object. Required inputs missing from `args` and lacking a default
58    /// are reported by name (so the caller can reject the invocation); present
59    /// values and defaults are written, and `_inputs` is set to the seeded map.
60    pub fn seed_from_inputs(
61        inputs: &BTreeMap<String, InputSpec>,
62        args: &Value,
63    ) -> (Self, Vec<String>) {
64        let mut state = Self::new();
65        let mut missing = Vec::new();
66        let mut seeded = Map::new();
67        for (name, spec) in inputs {
68            let provided = args.get(name).cloned();
69            let value = match provided {
70                Some(v) => Some(v),
71                None => match &spec.default {
72                    Some(d) => Some(d.clone()),
73                    None => {
74                        if spec.required {
75                            missing.push(name.clone());
76                        }
77                        None
78                    }
79                },
80            };
81            if let Some(v) = value {
82                state.set(name, v.clone());
83                seeded.insert(name.clone(), v);
84            }
85        }
86        state.set("_inputs", Value::Object(seeded));
87        (state, missing)
88    }
89
90    /// Borrow the underlying JSON object.
91    pub fn as_value(&self) -> &Value {
92        &self.root
93    }
94
95    /// Consume and return the underlying JSON object.
96    pub fn into_value(self) -> Value {
97        self.root
98    }
99
100    /// Look up a dotted path within the state.
101    pub fn get(&self, path: &str) -> Option<&Value> {
102        lookup_path(&self.root, path)
103    }
104
105    /// Set a **top-level** variable. (Nested assignment uses [`Self::set_path`].)
106    pub fn set(&mut self, name: &str, value: Value) {
107        if let Value::Object(map) = &mut self.root {
108            map.insert(name.to_string(), value);
109        }
110    }
111
112    /// Set the reserved `_last` edge payload (the next node's input).
113    pub fn set_last(&mut self, value: Value) {
114        self.set("_last", value);
115    }
116
117    /// Set a value at a dotted path, creating intermediate objects as needed.
118    /// A leading/empty segment is ignored; an empty path is a no-op.
119    pub fn set_path(&mut self, path: &str, value: Value) {
120        let segments: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
121        if segments.is_empty() {
122            return;
123        }
124        let mut cur = &mut self.root;
125        for seg in &segments[..segments.len() - 1] {
126            if !cur.is_object() {
127                *cur = Value::Object(Map::new());
128            }
129            let map = cur.as_object_mut().expect("just ensured object");
130            cur = map.entry(seg.to_string()).or_insert_with(|| Value::Object(Map::new()));
131        }
132        if !cur.is_object() {
133            *cur = Value::Object(Map::new());
134        }
135        cur.as_object_mut()
136            .expect("just ensured object")
137            .insert(segments[segments.len() - 1].to_string(), value);
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use serde_json::json;
145
146    #[test]
147    fn lookup_nested_and_root() {
148        let v = json!({ "a": { "b": 2 } });
149        assert_eq!(lookup_path(&v, "a.b"), Some(&json!(2)));
150        assert_eq!(lookup_path(&v, "."), Some(&v));
151        assert_eq!(lookup_path(&v, "a.missing"), None);
152    }
153
154    #[test]
155    fn set_and_get() {
156        let mut s = Variables::new();
157        s.set("temp", json!(18));
158        s.set_last(json!({ "celsius": 5 }));
159        assert_eq!(s.get("temp"), Some(&json!(18)));
160        assert_eq!(s.get("_last.celsius"), Some(&json!(5)));
161    }
162
163    #[test]
164    fn set_path_creates_intermediates() {
165        let mut s = Variables::new();
166        s.set_path("triage.severity", json!("P0"));
167        assert_eq!(s.get("triage.severity"), Some(&json!("P0")));
168    }
169
170    #[test]
171    fn seed_reports_missing_required() {
172        let mut inputs = BTreeMap::new();
173        inputs.insert("repo".to_string(), InputSpec { type_name: "string".into(), required: true, default: None });
174        inputs.insert("since".to_string(), InputSpec { type_name: "string".into(), required: false, default: Some(json!("24h")) });
175
176        let (state, missing) = Variables::seed_from_inputs(&inputs, &json!({ "repo": "acme/app" }));
177        assert!(missing.is_empty());
178        assert_eq!(state.get("repo"), Some(&json!("acme/app")));
179        assert_eq!(state.get("since"), Some(&json!("24h")));
180
181        let (_state2, missing2) = Variables::seed_from_inputs(&inputs, &json!({}));
182        assert_eq!(missing2, vec!["repo".to_string()]);
183    }
184}